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