1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2011-2014 OpenERP S.A. (<http://www.openerp.com>)
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
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
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/>
20 ##############################################################################
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
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
40 # ustr was originally from tools.misc.
41 # it is moved to loglevels until we refactor tools.
42 from openerp.loglevels import ustr
44 _logger = logging.getLogger(__name__)
45 _test_logger = logging.getLogger('openerp.tests')
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)
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):
60 self.logger.log(self.level, s)
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.
68 If the process fails (because the string
69 contains non-ASCII characters) returns ``None``.
72 string_utf8.decode('ascii')
73 except UnicodeDecodeError:
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.
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
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
100 return header_text_ascii if header_text_ascii\
101 else Header(header_text_utf8, 'utf-8')
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.
110 :param param_text: unicode or utf-8 encoded string with header value
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.
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)
123 # TODO master, remove me, no longer used internaly
124 name_with_email_pattern = re.compile(r'("[^<@>]+")\s*<([^ ,<@]+@[^> ,]+)>')
125 address_pattern = re.compile(r'([^ ,<@]+@[^> ,]+)')
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.
132 if not text: return []
133 candidates = address_pattern.findall(tools.ustr(text).encode('utf-8'))
134 return filter(try_coerce_ascii, candidates)
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.
143 def encode_addr(addr):
145 if not try_coerce_ascii(name):
146 name = str(Header(name, 'utf-8'))
147 return formataddr((name, email))
149 addresses = getaddresses([tools.ustr(header_text).encode('utf-8')])
150 return COMMASPACE.join(map(encode_addr, addresses))
152 class ir_mail_server(osv.osv):
153 """Represents an SMTP server, able to send outgoing emails, with SSL and TLS capabilities."""
154 _name = "ir.mail_server"
157 'name': fields.char('Description', required=True, select=True),
158 'smtp_host': fields.char('SMTP Server', required=True, help="Hostname or IP of SMTP server"),
159 'smtp_port': fields.integer('SMTP Port', size=5, required=True, help="SMTP Port. Usually 465 for SSL, and 25 or 587 for other cases."),
160 'smtp_user': fields.char('Username', size=64, help="Optional username for SMTP authentication"),
161 'smtp_pass': fields.char('Password', size=64, help="Optional password for SMTP authentication"),
162 'smtp_encryption': fields.selection([('none','None'),
163 ('starttls','TLS (STARTTLS)'),
165 string='Connection Security', required=True,
166 help="Choose the connection encryption scheme:\n"
167 "- None: SMTP sessions are done in cleartext.\n"
168 "- TLS (STARTTLS): TLS encryption is requested at start of SMTP session (Recommended)\n"
169 "- SSL/TLS: SMTP sessions are encrypted with SSL/TLS through a dedicated port (default: 465)"),
170 'smtp_debug': fields.boolean('Debugging', help="If enabled, the full output of SMTP sessions will "
171 "be written to the server log at DEBUG level"
172 "(this is very verbose and may include confidential info!)"),
173 'sequence': fields.integer('Priority', help="When no specific mail server is requested for a mail, the highest priority one "
174 "is used. Default priority is 10 (smaller number = higher priority)"),
175 'active': fields.boolean('Active')
182 'smtp_encryption': 'none',
185 def __init__(self, *args, **kwargs):
186 # Make sure we pipe the smtplib outputs to our own DEBUG logger
187 if not isinstance(smtplib.stderr, WriteToLogger):
188 logpiper = WriteToLogger(_logger)
189 smtplib.stderr = logpiper
190 smtplib.stdout = logpiper
191 super(ir_mail_server, self).__init__(*args,**kwargs)
193 def name_get(self, cr, uid, ids, context=None):
194 return [(a["id"], "(%s)" % (a['name'])) for a in self.read(cr, uid, ids, ['name'], context=context)]
196 def test_smtp_connection(self, cr, uid, ids, context=None):
197 for smtp_server in self.browse(cr, uid, ids, context=context):
200 smtp = self.connect(smtp_server.smtp_host, smtp_server.smtp_port, user=smtp_server.smtp_user,
201 password=smtp_server.smtp_pass, encryption=smtp_server.smtp_encryption,
202 smtp_debug=smtp_server.smtp_debug)
204 raise osv.except_osv(_("Connection Test Failed!"), _("Here is what we got instead:\n %s") % tools.ustr(e))
209 # ignored, just a consequence of the previous exception
211 raise osv.except_osv(_("Connection Test Succeeded!"), _("Everything seems properly set up!"))
213 def connect(self, host, port, user=None, password=None, encryption=False, smtp_debug=False):
214 """Returns a new SMTP connection to the give SMTP server, authenticated
215 with ``user`` and ``password`` if provided, and encrypted as requested
216 by the ``encryption`` parameter.
218 :param host: host or IP of SMTP server to connect to
219 :param int port: SMTP port to connect to
220 :param user: optional username to authenticate with
221 :param password: optional password to authenticate with
222 :param string encryption: optional, ``'ssl'`` | ``'starttls'``
223 :param bool smtp_debug: toggle debugging of SMTP sessions (all i/o
224 will be output in logs)
226 if encryption == 'ssl':
227 if not 'SMTP_SSL' in smtplib.__all__:
228 raise osv.except_osv(
229 _("SMTP-over-SSL mode unavailable"),
230 _("Your OpenERP Server does not support SMTP-over-SSL. You could use STARTTLS instead."
231 "If SSL is needed, an upgrade to Python 2.6 on the server-side should do the trick."))
232 connection = smtplib.SMTP_SSL(host, port)
234 connection = smtplib.SMTP(host, port)
235 connection.set_debuglevel(smtp_debug)
236 if encryption == 'starttls':
237 # starttls() will perform ehlo() if needed first
238 # and will discard the previous list of services
239 # after successfully performing STARTTLS command,
240 # (as per RFC 3207) so for example any AUTH
241 # capability that appears only on encrypted channels
242 # will be correctly detected for next step
243 connection.starttls()
246 # Attempt authentication - will raise if AUTH service not supported
247 # The user/password must be converted to bytestrings in order to be usable for
248 # certain hashing schemes, like HMAC.
249 # See also bug #597143 and python issue #5285
250 user = tools.ustr(user).encode('utf-8')
251 password = tools.ustr(password).encode('utf-8')
252 connection.login(user, password)
255 def build_email(self, email_from, email_to, subject, body, email_cc=None, email_bcc=None, reply_to=False,
256 attachments=None, message_id=None, references=None, object_id=False, subtype='plain', headers=None,
257 body_alternative=None, subtype_alternative='plain'):
258 """Constructs an RFC2822 email.message.Message object based on the keyword arguments passed, and returns it.
260 :param string email_from: sender email address
261 :param list email_to: list of recipient addresses (to be joined with commas)
262 :param string subject: email subject (no pre-encoding/quoting necessary)
263 :param string body: email body, of the type ``subtype`` (by default, plaintext).
264 If html subtype is used, the message will be automatically converted
265 to plaintext and wrapped in multipart/alternative, unless an explicit
266 ``body_alternative`` version is passed.
267 :param string body_alternative: optional alternative body, of the type specified in ``subtype_alternative``
268 :param string reply_to: optional value of Reply-To header
269 :param string object_id: optional tracking identifier, to be included in the message-id for
270 recognizing replies. Suggested format for object-id is "res_id-model",
271 e.g. "12345-crm.lead".
272 :param string subtype: optional mime subtype for the text body (usually 'plain' or 'html'),
273 must match the format of the ``body`` parameter. Default is 'plain',
274 making the content part of the mail "text/plain".
275 :param string subtype_alternative: optional mime subtype of ``body_alternative`` (usually 'plain'
276 or 'html'). Default is 'plain'.
277 :param list attachments: list of (filename, filecontents) pairs, where filecontents is a string
278 containing the bytes of the attachment
279 :param list email_cc: optional list of string values for CC header (to be joined with commas)
280 :param list email_bcc: optional list of string values for BCC header (to be joined with commas)
281 :param dict headers: optional map of headers to set on the outgoing mail (may override the
282 other headers, including Subject, Reply-To, Message-Id, etc.)
283 :rtype: email.message.Message (usually MIMEMultipart)
284 :return: the new RFC2822 email message
286 email_from = email_from or tools.config.get('email_from')
287 assert email_from, "You must either provide a sender address explicitly or configure "\
288 "a global sender address in the server configuration or with the "\
289 "--email-from startup parameter."
291 # Note: we must force all strings to to 8-bit utf-8 when crafting message,
292 # or use encode_header() for headers, which does it automatically.
294 headers = headers or {} # need valid dict later
296 if not email_cc: email_cc = []
297 if not email_bcc: email_bcc = []
298 if not body: body = u''
300 email_body_utf8 = ustr(body).encode('utf-8')
301 email_text_part = MIMEText(email_body_utf8, _subtype=subtype, _charset='utf-8')
302 msg = MIMEMultipart()
306 message_id = tools.generate_tracking_message_id(object_id)
308 message_id = make_msgid()
309 msg['Message-Id'] = encode_header(message_id)
311 msg['references'] = encode_header(references)
312 msg['Subject'] = encode_header(subject)
313 msg['From'] = encode_rfc2822_address_header(email_from)
316 msg['Reply-To'] = encode_rfc2822_address_header(reply_to)
318 msg['Reply-To'] = msg['From']
319 msg['To'] = encode_rfc2822_address_header(COMMASPACE.join(email_to))
321 msg['Cc'] = encode_rfc2822_address_header(COMMASPACE.join(email_cc))
323 msg['Bcc'] = encode_rfc2822_address_header(COMMASPACE.join(email_bcc))
324 msg['Date'] = formatdate()
325 # Custom headers may override normal headers or provide additional ones
326 for key, value in headers.iteritems():
327 msg[ustr(key).encode('utf-8')] = encode_header(value)
329 if subtype == 'html' and not body_alternative and html2text:
330 # Always provide alternative text body ourselves if possible.
331 text_utf8 = tools.html2text(email_body_utf8.decode('utf-8')).encode('utf-8')
332 alternative_part = MIMEMultipart(_subtype="alternative")
333 alternative_part.attach(MIMEText(text_utf8, _charset='utf-8', _subtype='plain'))
334 alternative_part.attach(email_text_part)
335 msg.attach(alternative_part)
336 elif body_alternative:
337 # Include both alternatives, as specified, within a multipart/alternative part
338 alternative_part = MIMEMultipart(_subtype="alternative")
339 body_alternative_utf8 = ustr(body_alternative).encode('utf-8')
340 alternative_body_part = MIMEText(body_alternative_utf8, _subtype=subtype_alternative, _charset='utf-8')
341 alternative_part.attach(alternative_body_part)
342 alternative_part.attach(email_text_part)
343 msg.attach(alternative_part)
345 msg.attach(email_text_part)
348 for (fname, fcontent) in attachments:
349 filename_rfc2047 = encode_header_param(fname)
350 part = MIMEBase('application', "octet-stream")
352 # The default RFC2231 encoding of Message.add_header() works in Thunderbird but not GMail
353 # so we fix it by using RFC2047 encoding for the filename instead.
354 part.set_param('name', filename_rfc2047)
355 part.add_header('Content-Disposition', 'attachment', filename=filename_rfc2047)
357 part.set_payload(fcontent)
358 Encoders.encode_base64(part)
362 def send_email(self, cr, uid, message, mail_server_id=None, smtp_server=None, smtp_port=None,
363 smtp_user=None, smtp_password=None, smtp_encryption=None, smtp_debug=False,
365 """Sends an email directly (no queuing).
367 No retries are done, the caller should handle MailDeliveryException in order to ensure that
368 the mail is never lost.
370 If the mail_server_id is provided, sends using this mail server, ignoring other smtp_* arguments.
371 If mail_server_id is None and smtp_server is None, use the default mail server (highest priority).
372 If mail_server_id is None and smtp_server is not None, use the provided smtp_* arguments.
373 If both mail_server_id and smtp_server are None, look for an 'smtp_server' value in server config,
374 and fails if not found.
376 :param message: the email.message.Message to send. The envelope sender will be extracted from the
377 ``Return-Path`` or ``From`` headers. The envelope recipients will be
378 extracted from the combined list of ``To``, ``CC`` and ``BCC`` headers.
379 :param mail_server_id: optional id of ir.mail_server to use for sending. overrides other smtp_* arguments.
380 :param smtp_server: optional hostname of SMTP server to use
381 :param smtp_encryption: optional TLS mode, one of 'none', 'starttls' or 'ssl' (see ir.mail_server fields for explanation)
382 :param smtp_port: optional SMTP port, if mail_server_id is not passed
383 :param smtp_user: optional SMTP user, if mail_server_id is not passed
384 :param smtp_password: optional SMTP password to use, if mail_server_id is not passed
385 :param smtp_debug: optional SMTP debug flag, if mail_server_id is not passed
386 :return: the Message-ID of the message that was just sent, if successfully sent, otherwise raises
387 MailDeliveryException and logs root cause.
389 smtp_from = message['Return-Path'] or message['From']
390 assert smtp_from, "The Return-Path or From header is required for any outbound email"
392 # The email's "Envelope From" (Return-Path), and all recipient addresses must only contain ASCII characters.
393 from_rfc2822 = extract_rfc2822_addresses(smtp_from)
394 assert from_rfc2822, ("Malformed 'Return-Path' or 'From' address: %r - "
395 "It should contain one valid plain ASCII email") % smtp_from
396 # use last extracted email, to support rarities like 'Support@MyComp <support@mycompany.com>'
397 smtp_from = from_rfc2822[-1]
398 email_to = message['To']
399 email_cc = message['Cc']
400 email_bcc = message['Bcc']
402 smtp_to_list = filter(None, tools.flatten(map(extract_rfc2822_addresses,[email_to, email_cc, email_bcc])))
403 assert smtp_to_list, "At least one valid recipient address should be specified for outgoing emails (To/Cc/Bcc)"
405 x_forge_to = message['X-Forge-To']
407 # `To:` header forged, e.g. for posting on mail.groups, to avoid confusion
408 del message['X-Forge-To']
409 del message['To'] # avoid multiple To: headers!
410 message['To'] = x_forge_to
412 # Do not actually send emails in testing mode!
413 if getattr(threading.currentThread(), 'testing', False):
414 _test_logger.info("skip sending email in test mode")
415 return message['Message-Id']
417 # Get SMTP Server Details from Mail Server
420 mail_server = self.browse(cr, SUPERUSER_ID, mail_server_id)
421 elif not smtp_server:
422 mail_server_ids = self.search(cr, SUPERUSER_ID, [], order='sequence', limit=1)
424 mail_server = self.browse(cr, SUPERUSER_ID, mail_server_ids[0])
427 smtp_server = mail_server.smtp_host
428 smtp_user = mail_server.smtp_user
429 smtp_password = mail_server.smtp_pass
430 smtp_port = mail_server.smtp_port
431 smtp_encryption = mail_server.smtp_encryption
432 smtp_debug = smtp_debug or mail_server.smtp_debug
434 # we were passed an explicit smtp_server or nothing at all
435 smtp_server = smtp_server or tools.config.get('smtp_server')
436 smtp_port = tools.config.get('smtp_port', 25) if smtp_port is None else smtp_port
437 smtp_user = smtp_user or tools.config.get('smtp_user')
438 smtp_password = smtp_password or tools.config.get('smtp_password')
439 if smtp_encryption is None and tools.config.get('smtp_ssl'):
440 smtp_encryption = 'starttls' # STARTTLS is the new meaning of the smtp_ssl flag as of v7.0
443 raise osv.except_osv(
444 _("Missing SMTP Server"),
445 _("Please define at least one SMTP server, or provide the SMTP parameters explicitly."))
448 message_id = message['Message-Id']
450 # Add email in Maildir if smtp_server contains maildir.
451 if smtp_server.startswith('maildir:/'):
452 from mailbox import Maildir
453 maildir_path = smtp_server[8:]
454 mdir = Maildir(maildir_path, factory=None, create = True)
455 mdir.add(message.as_string(True))
460 smtp = self.connect(smtp_server, smtp_port, smtp_user, smtp_password, smtp_encryption or False, smtp_debug)
461 smtp.sendmail(smtp_from, smtp_to_list, message.as_string())
466 msg = _("Mail delivery failed via SMTP server '%s'.\n%s: %s") % (tools.ustr(smtp_server),
467 e.__class__.__name__,
470 raise MailDeliveryException(_("Mail Delivery Failed"), msg)
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')}
480 result = {'value': {'smtp_port': 25}}
483 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: