Skip to content

Commit

Permalink
Raise error for invalidly-formatted email addresses.
Browse files Browse the repository at this point in the history
A message's `from_email` and each address in its `to`, `cc`, and `bcc` lists must contain exactly one email address. Previous code would silently ignore additional addresses, leading to unusual behavior. Now, raises new `AnymailInvalidAddress` exception.

Example: `from_email='Widgets, Inc. <widgets@example.com>'` is invalid: it needs double-quotes around the "Widgets, Inc." display-name portion. In earlier versions, this probably would have sent the message from something like "From: Widgets <@localhost>". Now, it will raise an exception.

**Potentially-breaking change:** If your code is using an unquoted display-name containing a comma in an email address, it will now raise an error. In earlier versions, this may have appeared to succeed, but was almost certainly not doing what you intended.

Fixes #44.
  • Loading branch information
medmunds committed Dec 15, 2016
1 parent 4ca39a9 commit d0596d1
Show file tree
Hide file tree
Showing 4 changed files with 116 additions and 20 deletions.
4 changes: 4 additions & 0 deletions anymail/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ def __init__(self, message=None, *args, **kwargs):
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.
Expand Down
49 changes: 29 additions & 20 deletions anymail/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@
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

Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions docs/sending/exceptions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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. <widgets@example.com>', ...)
# must use double quotes around display-name containing comma:
send_mail(from_email='"Widgets, Inc." <widgets@example.com>', ...)
.. exception:: AnymailSerializationError

The send call will raise a :exc:`!AnymailSerializationError`
Expand Down
63 changes: 63 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -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." <test@example.com>', self.ADDRESS_ENCODING)
self.assertEqual(parsed.email, "test@example.com")
self.assertEqual(parsed.name, "Display Name, Inc.")
self.assertEqual(parsed.address, '"Display Name, Inc." <test@example.com>')

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 <test@example.com>', self.ADDRESS_ENCODING)
self.assertEqual(parsed.email, "test@example.com")
self.assertEqual(parsed.name, "Display Name")
self.assertEqual(parsed.address, 'Display Name <test@example.com>')

def test_unicode_display_name(self):
parsed = ParsedEmail(u'"Unicode \N{HEAVY BLACK HEART}" <test@example.com>', 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=?= <test@example.com>')

def test_invalid_display_name(self):
with self.assertRaises(AnymailInvalidAddress):
# this parses as multiple email addresses, because of the comma:
ParsedEmail('Display Name, Inc. <test@example.com>', 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)

0 comments on commit d0596d1

Please sign in to comment.