diff --git a/CHANGES.rst b/CHANGES.rst index e1577a7..b42e013 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,8 @@ Changelog 1.0.0rc5 (unreleased) --------------------- +- Improved `emailer.send_email` to use send in place of securesend (not using queue). + [sgeulette] - Added `EMPTY_DATETIME` value that corresponds to `01/01/1950 at 12:00`. [gbastien] - Improved batching module diff --git a/src/imio/helpers/emailer.py b/src/imio/helpers/emailer.py index 77a9f7f..6b821e2 100644 --- a/src/imio/helpers/emailer.py +++ b/src/imio/helpers/emailer.py @@ -1,13 +1,19 @@ # -*- coding: utf-8 -*- from email import encoders +from email.header import Header from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText +from email.utils import formataddr +from email.utils import getaddresses from email.utils import parseaddr from imio.helpers import _ from imio.pyutils.utils import safe_encode +from past.types import basestring from plone import api +from Products.MailHost.MailHost import _encode_address_string +from six import iteritems from smtplib import SMTPException from unidecode import unidecode from zope import schema @@ -125,8 +131,8 @@ def add_attachment(eml, filename, filepath=None, content=None): eml.attach(part) -def send_email(eml, subject, mfrom, mto, mcc=None, mbcc=None, replyto=None): - """ Sends an email with MailHost. +def send_email(eml, subject, mfrom, mto, mcc=None, mbcc=None, replyto=None, immediate=False): + """Sends an email with MailHost. :param eml: email instance :param subject: subject string @@ -135,37 +141,68 @@ def send_email(eml, subject, mfrom, mto, mcc=None, mbcc=None, replyto=None): :param mcc: cc string or string list or (name, address) list :param mbcc: bcc string or string list or (name, address) list :param replyto: reply-to string or string list or (name, address) list + :param immediate: send email immediately without queuing (default False) :return: status :rtype: bool """ mail_host = get_mail_host() if mail_host is None: - logger.error('Could not send email: mail host not well defined.') + logger.error('Cannot send email: mail host not well defined.') return False, 'Mail host not well defined' charset = get_email_charset() subject = safe_text(subject, charset) - kwargs = {} - # put only as parameter if defined, so mockmailhost can be used in tests with secureSend as send patch - if mcc is not None: - kwargs['mcc'] = mcc - if mbcc is not None: - kwargs['mbcc'] = mbcc - if replyto is not None: - kwargs['reply-to'] = replyto + # Convert all our address list inputs + addrs = { + 'From': _email_list_to_string(mfrom, charset), + 'To': _email_list_to_string(mto, charset), + 'Cc': _email_list_to_string(mcc, charset), + 'reply-to': _email_list_to_string(replyto, charset), + } + mbcc = _email_list_to_string(mbcc, charset) + # Add extra headers + _addHeaders(eml, Subject=Header(subject, charset), + **dict((k, Header(v, charset)) for k, v in iteritems(addrs) if v)) + # Handle all recipients to add bcc: smtplib sends to all but a recipient not found in header is considered as bcc... + all_recipients = [formataddr(pair) for pair in + getaddresses([addrs['To'], addrs['Cc'], mbcc])] + all_recipients = [a for a in all_recipients if a] try: - # secureSend is protected by permission 'Use mailhost' - # secureSend is deprecated and patched in Products/CMFPlone/patches/securemailhost.py - # send remove from headers bcc !! - # TODO replace securesend by send with headers - mail_host.secureSend(eml, mto, mfrom, subject=subject, charset=charset, **kwargs) + # send is protected by permission 'Use mailhost' + # send remove from headers bcc but if in all_recipients, it is handled as bcc in email... + mail_host.send(eml.as_string(), all_recipients, addrs['From'], immediate=immediate, charset=charset) except (socket.error, SMTPException) as e: - logger.error(u"Could not send email to '{}' with subject '{}': {}".format(mto, subject, e)) + logger.error(u"Cannot send email to '{}' with subject '{}': {}".format(mto, subject, e)) return False, 'Could not send email : {}'.format(e) # sent successfully return True, '' +def _email_list_to_string(addr_list, charset='utf8'): + """SecureMailHost's secureSend can take a list of email addresses + in addition to a simple string. We convert any email input into a + properly encoded string.""" + if addr_list is None: + return '' + if isinstance(addr_list, basestring): + addr_str = addr_list + else: + # if the list item is a string include it, otherwise assume it's a + # (name, address) tuple and turn it into an RFC compliant string + + addresses = (isinstance(a, basestring) and a or formataddr(a) + for a in addr_list) + addr_str = ', '.join(str(_encode_address_string(a, charset)) + for a in addresses) + return addr_str + + +def _addHeaders(message, **kwargs): + for key, value in iteritems(kwargs): + del message[key] + message[key] = value + + class InvalidEmailAddressFormat(schema.ValidationError): """Exception for invalid address format with real name part.""" __doc__ = _(u"Invalid email address format: 'real name ' or 'email (real name)'") diff --git a/src/imio/helpers/tests/test_email.py b/src/imio/helpers/tests/test_email.py index fb00f32..bde3fa1 100644 --- a/src/imio/helpers/tests/test_email.py +++ b/src/imio/helpers/tests/test_email.py @@ -70,28 +70,33 @@ def test_send_email(self): filepath = os.path.join(path, barcode_resource) add_attachment(eml, 'barcode.png', filepath=filepath) mail_host = get_mail_host() - MockMailHost.secureSend = MockMailHost.send mail_host.reset() if six.PY3: # Python 3 raises an error with accented characters in emails # see https://github.com/zopefoundation/Products.MailHost/issues/29 - # and https://stackoverflow.com/questions/52133735/how-do-i-send-email-to-addresses-with-non-ascii-characters-in-python + # and https://stackoverflow.com/questions/52133735/how-do-i-send-email-to-addresses-with-non-ascii- + # characters-in-python send_email(eml, 'Email subject hé hé', 'noreply@from.org', 'dest@to.org') - self.assertIn(b'Subject: =?utf-8?q?Email_subject_h=C3=A9_h=C3=A9?=\n', mail_host.messages[0]) - self.assertIn(b'From: noreply@from.org\n', mail_host.messages[0]) - self.assertIn(b'To: dest@to.org\n', mail_host.messages[0]) + self.assertIn(b'Subject: =?utf-8?q?Email_subject_h=C3=A9_h=C3=A9?=', mail_host.messages[0]) + self.assertIn(b'From: noreply@from.org', mail_host.messages[0]) + self.assertIn(b'To: dest@to.org', mail_host.messages[0]) else: send_email(eml, 'Email subject hé hé', 'noréply@from.org', 'dèst@to.org') self.assertIn('Subject: =?utf-8?q?Email_subject_h=C3=A9_h=C3=A9?=\n', mail_host.messages[0]) self.assertIn('From: nor\xc3\xa9ply@from.org\n', mail_host.messages[0]) - self.assertIn('To: d\xc3\xa8st@to.org\n', mail_host.messages[0]) + # self.assertIn('To: d\xc3\xa8st@to.org\n', mail_host.messages[0]) + self.assertIn('To: =?utf-8?q?d=C3=A8st=40to=2Eorg?=\n', mail_host.messages[0]) mail_host.reset() + # multiple recipients send_email(eml, u'Email subject', '', ['dest@to.org', 'Stéphan Geulette ']) if six.PY3: - self.assertIn(b'To: dest@to.org, =?utf-8?q?St=C3=A9phan_Geulette?= \n', mail_host.messages[0]) + self.assertIn(b'To: dest@to.org, =?utf-8?q?St=C3=A9phan_Geulette?= ', mail_host.messages[0]) else: - self.assertIn('To: dest@to.org, =?utf-8?q?St=C3=A9phan_Geulette?= \n', mail_host.messages[0]) + # self.assertIn('To: dest@to.org, =?utf-8?q?St=C3=A9phan_Geulette?= \n', mail_host.messages[0]) + self.assertIn('To: =?utf-8?q?dest=40to=2Eorg=2C_=3D=3Futf-8=3Fq=3FSt=3DC3=3DA9phan=5FGeulett?=\n', + mail_host.messages[0]) mail_host.reset() + # unicode parameters if six.PY3: self.assertTrue(send_email(eml, u'Email subject hé hé', u'noreply@from.org', u'dest@to.org')) self.assertTrue(send_email(eml, u'Email subject', '', @@ -102,6 +107,33 @@ def test_send_email(self): # not ok if in list self.assertRaises(UnicodeEncodeError, send_email, eml, u'Email subject', '', ['dest@to.org', u'Stéphan Geulette ']) + # cc, bcc and reply_to + mail_host.reset() + call_args = [] + orig_mmh_send = MockMailHost.send + + def mock_send(*args, **kwargs): + call_args.append(args) + orig_mmh_send(*args, **kwargs) + + MockMailHost.send = mock_send + send_email(eml, 'Email subject', 'noreply@from.org', 'dest@to.org', mcc='copy@to.org', mbcc='bcc@to.org', + replyto='reply@to.org') + MockMailHost.send = orig_mmh_send + # all recipients are in mto parameter + self.assertListEqual(call_args[0][2], ['dest@to.org', 'copy@to.org', 'bcc@to.org']) + if six.PY3: + self.assertIn(b'From: noreply@from.org', mail_host.messages[0]) + self.assertIn(b'To: dest@to.org', mail_host.messages[0]) + self.assertIn(b'Cc: =?utf-8?q?copy=40to=2Eorg?=', mail_host.messages[0]) + self.assertIn(b'reply-to: =?utf-8?q?reply=40to=2Eorg?=', mail_host.messages[0]) + self.assertNotIn(b'=?utf-8?q?bcc=40to=2Eorg?=', mail_host.messages[0]) + else: + self.assertIn('From: noreply@from.org', mail_host.messages[0]) + self.assertIn('To: =?utf-8?q?dest=40to=2Eorg?=\n', mail_host.messages[0]) + self.assertIn('Cc: =?utf-8?q?copy=40to=2Eorg?=\n', mail_host.messages[0]) + self.assertIn('reply-to: =?utf-8?q?reply=40to=2Eorg?=\n', mail_host.messages[0]) + self.assertNotIn('=?utf-8?q?bcc=40to=2Eorg?=\n', mail_host.messages[0]) def test_validate_email_address(self): self.assertTupleEqual(validate_email_address('name@domain.org'), (u'', u'name@domain.org'))