Raise error for invalidly-formatted email addresses.

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.
This commit is contained in:
medmunds
2016-12-15 13:57:49 -08:00
parent 4ca39a976f
commit d0596d100b
4 changed files with 116 additions and 20 deletions

View File

@@ -101,6 +101,10 @@ class AnymailRecipientsRefused(AnymailError):
super(AnymailRecipientsRefused, self).__init__(message, *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): class AnymailUnsupportedFeature(AnymailError, ValueError):
"""Exception for Anymail features that the ESP doesn't support. """Exception for Anymail features that the ESP doesn't support.

View File

@@ -2,15 +2,16 @@ import mimetypes
from base64 import b64encode from base64 import b64encode
from datetime import datetime from datetime import datetime
from email.mime.base import MIMEBase from email.mime.base import MIMEBase
from email.utils import formatdate, parseaddr, unquote from email.utils import formatdate, getaddresses, unquote
from time import mktime from time import mktime
import six import six
from django.conf import settings from django.conf import settings
from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_TYPE 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 django.utils.timezone import utc
from .exceptions import AnymailConfigurationError from .exceptions import AnymailConfigurationError, AnymailInvalidAddress
UNSET = object() # Used as non-None default value UNSET = object() # Used as non-None default value
@@ -93,31 +94,39 @@ def getfirst(dct, keys, default=UNSET):
return default 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): 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): def __init__(self, address, encoding):
self.address = sanitize_address(address, encoding) if address is None:
self._name = None self.name = self.email = self.address = None
self._email = None return
try:
def _parse(self): self.name, self.email = parse_one_addr(force_text(address))
if self._email is None: if self.email == '':
self._name, self._email = parseaddr(self.address) # 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): def __str__(self):
return self.address return self.address
@property
def name(self):
self._parse()
return self._name
@property
def email(self):
self._parse()
return self._email
class Attachment(object): class Attachment(object):
"""A normalized EmailMessage.attachments item with additional functionality """A normalized EmailMessage.attachments item with additional functionality

View File

@@ -36,6 +36,26 @@ Exceptions
your ESP's dashboard. See :ref:`troubleshooting`.) 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 .. exception:: AnymailSerializationError
The send call will raise a :exc:`!AnymailSerializationError` The send call will raise a :exc:`!AnymailSerializationError`

63
tests/test_utils.py Normal file
View File

@@ -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)