mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 11:51:05 -05:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
63
tests/test_utils.py
Normal file
63
tests/test_utils.py
Normal 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)
|
||||
Reference in New Issue
Block a user