Skip to content

Commit

Permalink
Merge pull request #35 from IMIO/send_email_bcc_improvement
Browse files Browse the repository at this point in the history
Send email bcc improvement
  • Loading branch information
sgeulette authored Sep 4, 2024
2 parents eb0c27e + 95c3bac commit fa8f07b
Show file tree
Hide file tree
Showing 3 changed files with 96 additions and 25 deletions.
2 changes: 2 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 54 additions & 17 deletions src/imio/helpers/emailer.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 <email>' or 'email (real name)'")
Expand Down
48 changes: 40 additions & 8 deletions src/imio/helpers/tests/test_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -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', '<noreply@from.org>', ['dest@to.org', 'Stéphan Geulette <seg@to.org>'])
if six.PY3:
self.assertIn(b'To: dest@to.org, =?utf-8?q?St=C3=A9phan_Geulette?= <seg@to.org>\n', mail_host.messages[0])
self.assertIn(b'To: dest@to.org, =?utf-8?q?St=C3=A9phan_Geulette?= <seg@to.org>', mail_host.messages[0])
else:
self.assertIn('To: dest@to.org, =?utf-8?q?St=C3=A9phan_Geulette?= <seg@to.org>\n', mail_host.messages[0])
# self.assertIn('To: dest@to.org, =?utf-8?q?St=C3=A9phan_Geulette?= <seg@to.org>\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', '<noreply@from.org>',
Expand All @@ -102,6 +107,33 @@ def test_send_email(self):
# not ok if in list
self.assertRaises(UnicodeEncodeError, send_email, eml, u'Email subject', '<noreply@from.org>',
['dest@to.org', u'Stéphan Geulette <seg@to.org>'])
# 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'))
Expand Down

0 comments on commit fa8f07b

Please sign in to comment.