[IMP] ir.mail_server: improve handling of non-ASCII in headers
[odoo/odoo.git] / openerp / addons / base / ir / ir_mail_server.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2011 OpenERP S.A (<http://www.openerp.com>)
6 #
7 #    This program is free software: you can redistribute it and/or modify
8 #    it under the terms of the GNU Affero General Public License as
9 #    published by the Free Software Foundation, either version 3 of the
10 #    License, or (at your option) any later version
11 #
12 #    This program is distributed in the hope that it will be useful,
13 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
14 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 #    GNU Affero General Public License for more details
16 #
17 #    You should have received a copy of the GNU Affero General Public License
18 #    along with this program.  If not, see <http://www.gnu.org/licenses/>
19 #
20 ##############################################################################
21
22 from email.MIMEText import MIMEText
23 from email.MIMEBase import MIMEBase
24 from email.MIMEMultipart import MIMEMultipart
25 from email.Header import Header
26 from email.Utils import formatdate, make_msgid, COMMASPACE
27 from email import Encoders
28 import logging
29 import re
30 import smtplib
31
32 from osv import osv
33 from osv import fields
34 from openerp.tools.translate import _
35 from openerp.tools import html2text
36 from openerp.tools.func import wraps
37 import openerp.tools as tools
38
39 # ustr was originally from tools.misc.
40 # it is moved to loglevels until we refactor tools.
41 from openerp.loglevels import ustr
42
43 _logger = logging.getLogger('ir.mail_server')
44
45 class MailDeliveryException(osv.except_osv):
46     """Specific exception subclass for mail delivery errors"""
47     def __init__(self, name, value, exc_type='warning'):
48         super(MailDeliveryException, self).__init__(name, value, exc_type=exc_type)
49
50 class WriteToLogger(object):
51     """debugging helper: behave as a fd and pipe to logger at the given level"""
52     def __init__(self, logger, level=logging.DEBUG):
53         self.logger = logger
54         self.level = level
55
56     def write(self, s):
57         self.logger.log(self.level, s)
58
59
60 def try_coerce_ascii(string_utf8):
61     """Attempts to decode the given utf8-encoded string
62        as ASCII after coercing it to UTF-8, then return
63        the confirmed 7-bit ASCII string.
64
65        If the process fails (because the string
66        contains non-ASCII characters) returns ``None``.
67     """
68     try:
69         string_utf8.decode('ascii')
70     except UnicodeDecodeError:
71         return
72     return string_utf8
73
74 def encode_header(header_text):
75     """Returns an appropriate representation of the given header value,
76        suitable for direct assignment as a header value in an
77        email.message.Message. RFC2822 assumes that headers contain
78        only 7-bit characters, so we ensure it is the case, using
79        RFC2047 encoding when needed.
80
81        :param header_text: unicode or utf-8 encoded string with header value
82        :rtype: string | email.header.Header
83        :return: if ``header_text`` represents a plain ASCII string,
84                 return the same 7-bit string, otherwise returns an email.header.Header
85                 that will perform the appropriate RFC2047 encoding of
86                 non-ASCII values.
87     """
88     if not header_text: return ""
89     # convert anything to utf-8, suitable for testing ASCIIness, as 7-bit chars are
90     # encoded as ASCII in utf-8
91     header_text_utf8 = tools.ustr(header_text).encode('utf-8')
92     header_text_ascii = try_coerce_ascii(header_text_utf8)
93     # if this header contains non-ASCII characters,
94     # we'll need to wrap it up in a message.header.Header
95     # that will take care of RFC2047-encoding it as
96     # 7-bit string.
97     return header_text_ascii if header_text_ascii\
98          else Header(header_text_utf8, 'utf-8')
99
100 name_with_email_pattern = re.compile(r'("[^<@>]+")\s*<([^ ,<@]+@[^> ,]+)>')
101 address_pattern = re.compile(r'([^ ,<@]+@[^> ,]+)')
102
103 def extract_rfc2822_addresses(text):
104     """Returns a list of valid RFC2822 addresses
105        that can be found in ``source``, ignoring 
106        malformed ones and non-ASCII ones.
107     """
108     if not text: return []
109     candidates = address_pattern.findall(tools.ustr(text).encode('utf-8'))
110     return filter(try_coerce_ascii, candidates)
111
112 def encode_rfc2822_address_header(header_text):
113     """If ``header_text`` contains non-ASCII characters,
114        attempts to locate patterns of the form
115        ``"Name" <address@domain>`` and replace the
116        ``"Name"`` portion by the RFC2047-encoded
117        version, preserving the address part untouched.
118     """
119     header_text_utf8 = tools.ustr(header_text).encode('utf-8')
120     header_text_ascii = try_coerce_ascii(header_text_utf8)
121     if header_text_ascii:
122         return header_text_ascii
123     # non-ASCII characters are present, attempt to
124     # replace all "Name" patterns with the RFC2047-
125     # encoded version
126     def replace(match_obj):
127         name, email = match_obj.group(1), match_obj.group(2)
128         name_encoded = str(Header(name, 'utf-8'))
129         return "%s <%s>" % (name_encoded, email)
130     header_text_utf8 = name_with_email_pattern.sub(replace,
131                                                    header_text_utf8)
132     # try again after encoding
133     header_text_ascii = try_coerce_ascii(header_text_utf8)
134     if header_text_ascii:
135         return header_text_ascii
136     # fallback to extracting pure addresses only, which could
137     # still cause a failure downstream if the actual addresses
138     # contain non-ASCII characters
139     return COMMASPACE.join(extract_rfc2822_addresses(header_text_utf8))
140
141  
142 class ir_mail_server(osv.osv):
143     """Represents an SMTP server, able to send outgoing e-mails, with SSL and TLS capabilities."""
144     _name = "ir.mail_server"
145
146     _columns = {
147         'name': fields.char('Description', size=64, required=True, select=True),
148         'smtp_host': fields.char('Server Name', size=128, required=True, help="Hostname or IP of SMTP server"),
149         'smtp_port': fields.integer('SMTP Port', size=5, required=True, help="SMTP Port. Usually 465 for SSL, and 25 or 587 for other cases."),
150         'smtp_user': fields.char('Username', size=64, help="Optional username for SMTP authentication"),
151         'smtp_pass': fields.char('Password', size=64, help="Optional password for SMTP authentication"),
152         'smtp_encryption': fields.selection([('none','None'),
153                                              ('starttls','TLS (STARTTLS)'),
154                                              ('ssl','SSL/TLS')],
155                                             string='Connection Security',
156                                             help="Choose the connection encryption scheme:\n"
157                                                  "- None: SMTP sessions are done in cleartext.\n"
158                                                  "- TLS (STARTTLS): TLS encryption is requested at start of SMTP session (Recommended)\n"
159                                                  "- SSL/TLS: SMTP sessions are encrypted with SSL/TLS through a dedicated port (default: 465)"),
160         'smtp_debug': fields.boolean('Debugging', help="If enabled, the full output of SMTP sessions will "
161                                                        "be written to the server log at DEBUG level"
162                                                        "(this is very verbose and may include confidential info!)"),
163         'sequence': fields.integer('Priority', help="When no specific mail server is requested for a mail, the highest priority one "
164                                                     "is used. Default priority is 10 (smaller number = higher priority)"),
165     }
166
167     _defaults = {
168          'smtp_port': 25,
169          'sequence': 10,
170          'smtp_encryption': 'none',
171      }
172
173     def __init__(self, *args, **kwargs):
174         # Make sure we pipe the smtplib outputs to our own DEBUG logger
175         if not isinstance(smtplib.stderr, WriteToLogger):
176             logpiper = WriteToLogger(_logger)
177             smtplib.stderr = logpiper
178             smtplib.stdout = logpiper
179         return super(ir_mail_server, self).__init__(*args,**kwargs)
180
181     def name_get(self, cr, uid, ids, context=None):
182         return [(a["id"], "(%s)" % (a['name'])) for a in self.read(cr, uid, ids, ['name'], context=context)]
183
184     def test_smtp_connection(self, cr, uid, ids, context=None):
185         for smtp_server in self.browse(cr, uid, ids, context=context):
186             smtp = False
187             try:
188                 smtp = self.connect(smtp_server.smtp_host, smtp_server.smtp_port, user=smtp_server.smtp_user,
189                                     password=smtp_server.smtp_pass, encryption=smtp_server.smtp_encryption,
190                                     smtp_debug=smtp_server.smtp_debug)
191             except Exception, e:
192                 raise osv.except_osv(_("Connection test failed!"), _("Here is what we got instead:\n %s") % e)
193             finally:
194                 try:
195                     if smtp: smtp.quit()
196                 except Exception:
197                     # ignored, just a consequence of the previous exception
198                     pass
199         raise osv.except_osv(_("Connection test succeeded!"), _("Everything seems properly set up!"))
200
201     def connect(self, host, port, user=None, password=None, encryption=False, smtp_debug=False):
202         """Returns a new SMTP connection to the give SMTP server, authenticated
203            with ``user`` and ``password`` if provided, and encrypted as requested
204            by the ``encryption`` parameter.
205         
206            :param host: host or IP of SMTP server to connect to
207            :param int port: SMTP port to connect to
208            :param user: optional username to authenticate with
209            :param password: optional password to authenticate with
210            :param string encryption: optional: ``'ssl'`` | ``'starttls'``
211            :param bool smtp_debug: toggle debugging of SMTP sessions (all i/o
212                               will be output in logs)
213         """
214         if encryption == 'ssl':
215             if not 'SMTP_SSL' in smtplib.__all__:
216                 raise osv.except_osv(
217                              _("SMTP-over-SSL mode unavailable"),
218                              _("Your OpenERP Server does not support SMTP-over-SSL. You could use STARTTLS instead."
219                                "If SSL is needed, an upgrade to Python 2.6 on the server-side should do the trick."))
220             connection = smtplib.SMTP_SSL(host, port)
221         else:
222             connection = smtplib.SMTP(host, port)
223         connection.set_debuglevel(smtp_debug)
224         if encryption == 'starttls':
225             # starttls() will perform ehlo() if needed first
226             # and will discard the previous list of services
227             # after successfully performing STARTTLS command,
228             # (as per RFC 3207) so for example any AUTH
229             # capability that appears only on encrypted channels
230             # will be correctly detected for next step
231             connection.starttls()
232
233         if user:
234             # Attempt authentication - will raise if AUTH service not supported
235             connection.login(user, password)
236         return connection
237
238     def build_email(self, email_from, email_to, subject, body, email_cc=None, email_bcc=None, reply_to=False,
239                attachments=None, message_id=None, references=None, object_id=False, subtype='plain', headers=None):
240         """Constructs an RFC2822 email.message.Message object based on the keyword arguments passed, and returns it.
241
242            :param string email_from: sender email address
243            :param list email_to: list of recipient addresses (to be joined with commas) 
244            :param string subject: email subject (no pre-encoding/quoting necessary)
245            :param string body: email body, according to the ``subtype`` (by default, plaintext).
246                                If html subtype is used, the message will be automatically converted
247                                to plaintext and wrapped in multipart/alternative.
248            :param string reply_to: optional value of Reply-To header
249            :param string object_id: optional tracking identifier, to be included in the message-id for
250                                     recognizing replies. Suggested format for object-id is "res_id-model",
251                                     e.g. "12345-crm.lead".
252            :param string subtype: optional mime subtype for the text body (usually 'plain' or 'html'),
253                                   must match the format of the ``body`` parameter. Default is 'plain',
254                                   making the content part of the mail "text/plain".
255            :param list attachments: list of (filename, filecontents) pairs, where filecontents is a string
256                                     containing the bytes of the attachment
257            :param list email_cc: optional list of string values for CC header (to be joined with commas)
258            :param list email_bcc: optional list of string values for BCC header (to be joined with commas)
259            :param dict headers: optional map of headers to set on the outgoing mail (may override the
260                                 other headers, including Subject, Reply-To, Message-Id, etc.)
261            :rtype: email.message.Message (usually MIMEMultipart)
262            :return: the new RFC2822 email message
263         """
264         email_from = email_from or tools.config.get('email_from')
265         assert email_from, "You must either provide a sender address explicitly or configure "\
266                            "a global sender address in the server configuration or with the "\
267                            "--email-from startup parameter."
268
269         # Note: we must force all strings to to 8-bit utf-8 when crafting message,
270         #       or use encode_header() for headers, which does it automatically.
271
272         headers = headers or {} # need valid dict later
273
274         if not email_cc: email_cc = []
275         if not email_bcc: email_bcc = []
276         if not body: body = u''
277
278         email_body_utf8 = ustr(body).encode('utf-8')
279         email_text_part = MIMEText(email_body_utf8 or '', _subtype=subtype, _charset='utf-8')
280         msg = MIMEMultipart()
281
282         if not message_id:
283             if object_id:
284                 message_id = tools.generate_tracking_message_id(object_id)
285             else:
286                 message_id = make_msgid()
287         msg['Message-Id'] = encode_header(message_id)
288         if references:
289             msg['references'] = encode_header(references)
290         msg['Subject'] = encode_header(subject)
291         msg['From'] = encode_rfc2822_address_header(email_from)
292         del msg['Reply-To']
293         if reply_to:
294             msg['Reply-To'] = encode_rfc2822_address_header(reply_to)
295         else:
296             msg['Reply-To'] = msg['From']
297         msg['To'] = encode_rfc2822_address_header(COMMASPACE.join(email_to))
298         if email_cc:
299             msg['Cc'] = encode_rfc2822_address_header(COMMASPACE.join(email_cc))
300         if email_bcc:
301             msg['Bcc'] = encode_rfc2822_address_header(COMMASPACE.join(email_bcc))
302         msg['Date'] = formatdate(localtime=True)
303         # Custom headers may override normal headers or provide additional ones
304         for key, value in headers.iteritems():
305             msg[ustr(key).encode('utf-8')] = encode_header(value)
306
307         if html2text and subtype == 'html':
308             # Always provide alternative text body if possible.
309             text_utf8 = tools.html2text(email_body_utf8.decode('utf-8')).encode('utf-8')
310             alternative_part = MIMEMultipart(_subtype="alternative")
311             alternative_part.attach(MIMEText(text_utf8, _charset='utf-8', _subtype='plain'))
312             alternative_part.attach(email_text_part)
313             msg.attach(alternative_part)
314         else:
315             msg.attach(email_text_part)
316
317         if attachments:
318             for (fname, fcontent) in attachments:
319                 filename_utf8 = ustr(fname).encode('utf-8')
320                 part = MIMEBase('application', "octet-stream")
321                 part.set_payload(fcontent)
322                 Encoders.encode_base64(part)
323                 # Force RFC2231 encoding for attachment filename
324                 # See email.message.Message.add_header doc
325                 part.add_header('Content-Disposition', 'attachment',
326                                 filename=('utf-8',None,filename_utf8)) 
327                 msg.attach(part)
328         return msg
329
330     def send_email(self, cr, uid, message, mail_server_id=None, smtp_server=None, smtp_port=None,
331                    smtp_user=None, smtp_password=None, smtp_encryption='none', smtp_debug=False,
332                    context=None):
333         """Sends an email directly (no queuing).
334
335         No retries are done, the caller should handle MailDeliveryException in order to ensure that
336         the mail is never lost.
337
338         If the mail_server_id is provided, sends using this mail server, ignoring other smtp_* arguments.
339         If mail_server_id is None and smtp_server is None, use the default mail server (highest priority).
340         If mail_server_id is None and smtp_server is not None, use the provided smtp_* arguments.
341         If both mail_server_id and smtp_server are None, look for an 'smtp_server' value in server config,
342         and fails if not found.
343
344         :param message: the email.message.Message to send. The envelope sender will be extracted from the
345                         ``Return-Path`` or ``From`` headers. The envelope recipients will be
346                         extracted from the combined list of ``To``, ``CC`` and ``BCC`` headers.
347         :param mail_server_id: optional id of ir.mail_server to use for sending. overrides other smtp_* arguments.
348         :param smtp_server: optional hostname of SMTP server to use
349         :param smtp_encryption: one of 'none', 'starttls' or 'ssl' (see ir.mail_server fields for explanation)
350         :param smtp_port: optional SMTP port, if mail_server_id is not passed
351         :param smtp_user: optional SMTP user, if mail_server_id is not passed
352         :param smtp_password: optional SMTP password to use, if mail_server_id is not passed
353         :param smtp_debug: optional SMTP debug flag, if mail_server_id is not passed
354         :param debug: whether to turn on the SMTP level debugging, output to DEBUG log level
355         :return: the Message-ID of the message that was just sent, if successfully sent, otherwise raises
356                  MailDeliveryException and logs root cause.
357         """
358         smtp_from = message['Return-Path'] or message['From']
359         assert smtp_from, "The Return-Path or From header is required for any outbound e-mail"
360
361         # The email's "Envelope From" (Return-Path), and all recipient addresses must only contain ASCII characters.
362         from_rfc2822 = extract_rfc2822_addresses(smtp_from)
363         assert len(from_rfc2822) == 1, "Malformed 'Return-Path' or 'From' address - it may only contain plain ASCII characters"
364         smtp_from = from_rfc2822[0]
365         email_to = message['To']
366         email_cc = message['Cc']
367         email_bcc = message['Bcc']
368         smtp_to_list = filter(None, tools.flatten(map(extract_rfc2822_addresses,[email_to, email_cc, email_bcc])))
369         assert smtp_to_list, "At least one valid recipient address should be specified for outgoing emails (To/Cc/Bcc)"
370
371         # Get SMTP Server Details from Mail Server
372         mail_server = None
373         if mail_server_id:
374             mail_server = self.browse(cr, uid, mail_server_id)
375         elif not smtp_server:
376             mail_server_ids = self.search(cr, uid, [], order='sequence', limit=1)
377             if mail_server_ids:
378                 mail_server = self.browse(cr, uid, mail_server_ids[0])
379         else:
380             # we were passed an explicit smtp_server or nothing at all
381             smtp_server = smtp_server or tools.config.get('smtp_server')
382             smtp_port = tools.config.get('smtp_port', 25) if smtp_port is None else smtp_port
383             smtp_user = smtp_user or tools.config.get('smtp_user')
384             smtp_password = smtp_password or tools.config.get('smtp_password')
385
386         if mail_server:
387             smtp_server = mail_server.smtp_host
388             smtp_user = mail_server.smtp_user
389             smtp_password = mail_server.smtp_pass
390             smtp_port = mail_server.smtp_port
391             smtp_encryption = mail_server.smtp_encryption
392             smtp_debug = smtp_debug or mail_server.smtp_debug
393
394         if not smtp_server:
395             raise osv.except_osv(
396                          _("Missing SMTP Server"),
397                          _("Please define at least one SMTP server, or provide the SMTP parameters explicitly."))
398
399         try:
400             message_id = message['Message-Id']
401
402             # Add email in Maildir if smtp_server contains maildir.
403             if smtp_server.startswith('maildir:/'):
404                 from mailbox import Maildir
405                 maildir_path = smtp_server[8:]
406                 mdir = Maildir(maildir_path, factory=None, create = True)
407                 mdir.add(message.as_string(True))
408                 return message_id
409
410             try:
411                 smtp = self.connect(smtp_server, smtp_port, smtp_user, smtp_password, smtp_encryption, smtp_debug)
412                 smtp.sendmail(smtp_from, smtp_to_list, message.as_string())
413             finally:
414                 try:
415                     # Close Connection of SMTP Server
416                     smtp.quit()
417                 except Exception:
418                     # ignored, just a consequence of the previous exception
419                     pass
420         except Exception, e:
421             msg = _("Mail delivery failed via SMTP server '%s'.\n%s: %s") % (smtp_server, e.__class__.__name__, e)
422             _logger.exception(msg)
423             raise MailDeliveryException(_("Mail delivery failed"), msg)
424         return message_id
425
426     def on_change_encryption(self, cr, uid, ids, smtp_encryption):
427         if smtp_encryption == 'ssl':
428             result = {'value': {'smtp_port': 465}}
429             if not 'SMTP_SSL' in smtplib.__all__:
430                 result['warning'] = {'title': _('Warning'),
431                                      'message': _('Your server does not seem to support SSL, you may want to try STARTTLS instead')}
432         else:
433             result = {'value': {'smtp_port': 25}}
434         return result
435
436 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: