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