1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2011 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.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
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
39 # ustr was originally from tools.misc.
40 # it is moved to loglevels until we refactor tools.
41 from openerp.loglevels import ustr
43 _logger = logging.getLogger('ir.mail_server')
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)
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):
57 self.logger.log(self.level, s)
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.
65 If the process fails (because the string
66 contains non-ASCII characters) returns ``None``.
69 string_utf8.decode('ascii')
70 except UnicodeDecodeError:
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.
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
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
97 return header_text_ascii if header_text_ascii\
98 else Header(header_text_utf8, 'utf-8')
100 name_with_email_pattern = re.compile(r'("[^<@>]+")\s*<([^ ,<@]+@[^> ,]+)>')
101 address_pattern = re.compile(r'([^ ,<@]+@[^> ,]+)')
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.
108 if not text: return []
109 candidates = address_pattern.findall(tools.ustr(text).encode('utf-8'))
110 return filter(try_coerce_ascii, candidates)
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.
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-
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,
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))
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"
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)'),
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)"),
170 'smtp_encryption': 'none',
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)
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)]
184 def test_smtp_connection(self, cr, uid, ids, context=None):
185 for smtp_server in self.browse(cr, uid, ids, context=context):
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)
192 raise osv.except_osv(_("Connection test failed!"), _("Here is what we got instead:\n %s") % e)
197 # ignored, just a consequence of the previous exception
199 raise osv.except_osv(_("Connection test succeeded!"), _("Everything seems properly set up!"))
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.
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)
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)
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()
234 # Attempt authentication - will raise if AUTH service not supported
235 connection.login(user, password)
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.
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
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."
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.
272 headers = headers or {} # need valid dict later
274 if not email_cc: email_cc = []
275 if not email_bcc: email_bcc = []
276 if not body: body = u''
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()
284 message_id = tools.generate_tracking_message_id(object_id)
286 message_id = make_msgid()
287 msg['Message-Id'] = encode_header(message_id)
289 msg['references'] = encode_header(references)
290 msg['Subject'] = encode_header(subject)
291 msg['From'] = encode_rfc2822_address_header(email_from)
294 msg['Reply-To'] = encode_rfc2822_address_header(reply_to)
296 msg['Reply-To'] = msg['From']
297 msg['To'] = encode_rfc2822_address_header(COMMASPACE.join(email_to))
299 msg['Cc'] = encode_rfc2822_address_header(COMMASPACE.join(email_cc))
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)
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)
315 msg.attach(email_text_part)
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))
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,
333 """Sends an email directly (no queuing).
335 No retries are done, the caller should handle MailDeliveryException in order to ensure that
336 the mail is never lost.
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.
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.
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"
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)"
371 # Get SMTP Server Details from Mail Server
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)
378 mail_server = self.browse(cr, uid, mail_server_ids[0])
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')
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
395 raise osv.except_osv(
396 _("Missing SMTP Server"),
397 _("Please define at least one SMTP server, or provide the SMTP parameters explicitly."))
400 message_id = message['Message-Id']
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))
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())
415 # Close Connection of SMTP Server
418 # ignored, just a consequence of the previous exception
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)
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')}
433 result = {'value': {'smtp_port': 25}}
436 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: