diff --git a/anymail/exceptions.py b/anymail/exceptions.py index 8e55532..c0af427 100644 --- a/anymail/exceptions.py +++ b/anymail/exceptions.py @@ -101,6 +101,10 @@ class AnymailRecipientsRefused(AnymailError): super(AnymailRecipientsRefused, self).__init__(message, *args, **kwargs) +class AnymailInvalidAddress(AnymailError, ValueError): + """Exception when using an invalidly-formatted email address""" + + class AnymailUnsupportedFeature(AnymailError, ValueError): """Exception for Anymail features that the ESP doesn't support. diff --git a/anymail/utils.py b/anymail/utils.py index 5e84ba2..8f54802 100644 --- a/anymail/utils.py +++ b/anymail/utils.py @@ -2,15 +2,16 @@ import mimetypes from base64 import b64encode from datetime import datetime from email.mime.base import MIMEBase -from email.utils import formatdate, parseaddr, unquote +from email.utils import formatdate, getaddresses, unquote from time import mktime import six from django.conf import settings from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_TYPE +from django.utils.encoding import force_text from django.utils.timezone import utc -from .exceptions import AnymailConfigurationError +from .exceptions import AnymailConfigurationError, AnymailInvalidAddress UNSET = object() # Used as non-None default value @@ -93,31 +94,39 @@ def getfirst(dct, keys, default=UNSET): return default +def parse_one_addr(address): + # This is email.utils.parseaddr, but without silently returning + # partial content if there are commas or parens in the string: + addresses = getaddresses([address]) + if len(addresses) > 1: + raise ValueError("Multiple email addresses (parses as %r)" % addresses) + elif len(addresses) == 0: + return ('', '') + return addresses[0] + + class ParsedEmail(object): - """A sanitized, full email address with separate name and email properties""" + """A sanitized, full email address with separate name and email properties.""" def __init__(self, address, encoding): - self.address = sanitize_address(address, encoding) - self._name = None - self._email = None - - def _parse(self): - if self._email is None: - self._name, self._email = parseaddr(self.address) + if address is None: + self.name = self.email = self.address = None + return + try: + self.name, self.email = parse_one_addr(force_text(address)) + if self.email == '': + # normalize sanitize_address py2/3 behavior: + raise ValueError('No email found') + # Django's sanitize_address is like email.utils.formataddr, but also + # escapes as needed for use in email message headers: + self.address = sanitize_address((self.name, self.email), encoding) + except (IndexError, TypeError, ValueError) as err: + raise AnymailInvalidAddress("Invalid email address format %r: %s" + % (address, str(err))) def __str__(self): return self.address - @property - def name(self): - self._parse() - return self._name - - @property - def email(self): - self._parse() - return self._email - class Attachment(object): """A normalized EmailMessage.attachments item with additional functionality diff --git a/docs/sending/exceptions.rst b/docs/sending/exceptions.rst index a4fe548..6a01b96 100644 --- a/docs/sending/exceptions.rst +++ b/docs/sending/exceptions.rst @@ -36,6 +36,26 @@ Exceptions your ESP's dashboard. See :ref:`troubleshooting`.) +.. exception:: AnymailInvalidAddress + + .. versionadded:: 0.7 + + The send call will raise a :exc:`!AnymailInvalidAddress` error if you + attempt to send a message with invalidly-formatted email addresses in + the :attr:`from_email` or recipient lists. + + One source of this error can be using a display-name ("real name") containing + commas or parentheses. Per :rfc:`5322`, you should use double quotes around + the display-name portion of an email address: + + .. code-block:: python + + # won't work: + send_mail(from_email='Widgets, Inc. ', ...) + # must use double quotes around display-name containing comma: + send_mail(from_email='"Widgets, Inc." ', ...) + + .. exception:: AnymailSerializationError The send call will raise a :exc:`!AnymailSerializationError` diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..6d7d94b --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,63 @@ +# Tests for the anymail/utils.py module +# (not to be confused with utilities for testing found in in tests/utils.py) + +from django.test import SimpleTestCase + +from anymail.exceptions import AnymailInvalidAddress +from anymail.utils import ParsedEmail + + +class ParsedEmailTests(SimpleTestCase): + """Test utils.ParsedEmail""" + + # Anymail (and Djrill) have always used EmailMessage.encoding, which defaults to None. + # (Django substitutes settings.DEFAULT_ENCODING='utf-8' when converting to a mime message, + # but Anymail has never used that code.) + ADDRESS_ENCODING = None + + def test_simple_email(self): + parsed = ParsedEmail("test@example.com", self.ADDRESS_ENCODING) + self.assertEqual(parsed.email, "test@example.com") + self.assertEqual(parsed.name, "") + self.assertEqual(parsed.address, "test@example.com") + + def test_display_name(self): + parsed = ParsedEmail('"Display Name, Inc." ', self.ADDRESS_ENCODING) + self.assertEqual(parsed.email, "test@example.com") + self.assertEqual(parsed.name, "Display Name, Inc.") + self.assertEqual(parsed.address, '"Display Name, Inc." ') + + def test_obsolete_display_name(self): + # you can get away without the quotes if there are no commas or parens + # (but it's not recommended) + parsed = ParsedEmail('Display Name ', self.ADDRESS_ENCODING) + self.assertEqual(parsed.email, "test@example.com") + self.assertEqual(parsed.name, "Display Name") + self.assertEqual(parsed.address, 'Display Name ') + + def test_unicode_display_name(self): + parsed = ParsedEmail(u'"Unicode \N{HEAVY BLACK HEART}" ', self.ADDRESS_ENCODING) + self.assertEqual(parsed.email, "test@example.com") + self.assertEqual(parsed.name, u"Unicode \N{HEAVY BLACK HEART}") + # display-name automatically shifts to quoted-printable/base64 for non-ascii chars: + self.assertEqual(parsed.address, '=?utf-8?b?VW5pY29kZSDinaQ=?= ') + + def test_invalid_display_name(self): + with self.assertRaises(AnymailInvalidAddress): + # this parses as multiple email addresses, because of the comma: + ParsedEmail('Display Name, Inc. ', self.ADDRESS_ENCODING) + + def test_none_address(self): + # used for, e.g., telling Mandrill to use template default from_email + parsed = ParsedEmail(None, self.ADDRESS_ENCODING) + self.assertEqual(parsed.email, None) + self.assertEqual(parsed.name, None) + self.assertEqual(parsed.address, None) + + def test_empty_address(self): + with self.assertRaises(AnymailInvalidAddress): + ParsedEmail('', self.ADDRESS_ENCODING) + + def test_whitespace_only_address(self): + with self.assertRaises(AnymailInvalidAddress): + ParsedEmail(' ', self.ADDRESS_ENCODING)