Mailgun, SparkPost: support multiple from_email addresses

[RFC-5322 allows](https://tools.ietf.org/html/rfc5322#section-3.6.2)
multiple addresses in the From header.

Django's SMTP backend supports this, as a single comma-separated
string (*not* a list of strings like the recipient params):

    from_email='one@example.com, two@example.com'
    to=['one@example.com', 'two@example.com']

Both Mailgun and SparkPost support multiple From addresses
(and Postmark accepts them, though truncates to the first one
on their end). For compatibility with Django -- and because
Anymail attempts to support all ESP features -- Anymail now
allows multiple From addresses, too, for ESPs that support it.

Note: as a practical matter, deliverability with multiple
From addresses is pretty bad. (Google outright rejects them.)

This change also reworks Anymail's internal ParsedEmail object,
and approach to parsing addresses, for better consistency with
Django's SMTP backend and improved error messaging.

In particular, Django (and now Anymail) allows multiple email
addresses in a single recipient string:

    to=['one@example.com', 'two@example.com, three@example.com']
    len(to) == 2  # but there will be three recipients

Fixes #60
This commit is contained in:
medmunds
2017-04-19 12:43:33 -07:00
parent 3c2c0b3a9d
commit 6b6793016e
13 changed files with 302 additions and 95 deletions

View File

@@ -18,7 +18,9 @@ from django.test import SimpleTestCase
from django.test.utils import override_settings
from django.utils.timezone import get_fixed_timezone, override as override_current_timezone
from anymail.exceptions import AnymailAPIError, AnymailRequestsAPIError, AnymailUnsupportedFeature
from anymail.exceptions import (
AnymailAPIError, AnymailInvalidAddress,
AnymailRequestsAPIError, AnymailUnsupportedFeature)
from anymail.message import attach_inline_image_file
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCasesMixin
@@ -53,7 +55,7 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase):
data = self.get_api_call_data()
self.assertEqual(data['subject'], "Subject here")
self.assertEqual(data['text'], "Here is the message.")
self.assertEqual(data['from'], "from@example.com")
self.assertEqual(data['from'], ["from@example.com"])
self.assertEqual(data['to'], ["to@example.com"])
def test_name_addr(self):
@@ -68,7 +70,7 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase):
bcc=['Blind Copy <bcc1@example.com>', 'bcc2@example.com'])
msg.send()
data = self.get_api_call_data()
self.assertEqual(data['from'], "From Name <from@example.com>")
self.assertEqual(data['from'], ["From Name <from@example.com>"])
self.assertEqual(data['to'], ['Recipient #1 <to1@example.com>', 'to2@example.com'])
self.assertEqual(data['cc'], ['Carbon Copy <cc1@example.com>', 'cc2@example.com'])
self.assertEqual(data['bcc'], ['Blind Copy <bcc1@example.com>', 'bcc2@example.com'])
@@ -86,7 +88,7 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase):
data = self.get_api_call_data()
self.assertEqual(data['subject'], "Subject")
self.assertEqual(data['text'], "Body goes here")
self.assertEqual(data['from'], "from@example.com")
self.assertEqual(data['from'], ["from@example.com"])
self.assertEqual(data['to'], ['to1@example.com', 'Also To <to2@example.com>'])
self.assertEqual(data['bcc'], ['bcc1@example.com', 'Also BCC <bcc2@example.com>'])
self.assertEqual(data['cc'], ['cc1@example.com', 'Also CC <cc2@example.com>'])
@@ -249,6 +251,20 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase):
data = self.get_api_call_data()
self.assertNotIn('to', data)
def test_multiple_from_emails(self):
"""Mailgun supports multiple addresses in from_email"""
self.message.from_email = 'first@example.com, "From, also" <second@example.com>'
self.message.send()
data = self.get_api_call_data()
self.assertEqual(data['from'], ['first@example.com',
'"From, also" <second@example.com>'])
# Make sure the far-more-likely scenario of a single from_email
# with an unquoted display-name issues a reasonable error:
self.message.from_email = 'Unquoted, display-name <from@example.com>'
with self.assertRaises(AnymailInvalidAddress):
self.message.send()
def test_api_failure(self):
self.set_mock_response(status_code=400)
with self.assertRaisesMessage(AnymailAPIError, "Mailgun API response 400"):
@@ -488,15 +504,20 @@ class MailgunBackendRecipientsRefusedTests(MailgunBackendMockAPITestCase):
"message": "'to' parameter is not a valid address. please check documentation"
}"""
# NOTE: As of Anymail 0.10, Anymail catches actually-invalid recipient emails
# before attempting to pass them along to the ESP, so the tests below use technically
# valid emails that would actually be accepted by Mailgun. (We're just making sure
# the backend would correctly handle the 400 response if something slipped through.)
def test_invalid_email(self):
self.set_mock_response(status_code=400, raw=self.INVALID_TO_RESPONSE)
msg = mail.EmailMessage('Subject', 'Body', 'from@example.com', to=['not a valid email'])
msg = mail.EmailMessage('Subject', 'Body', 'from@example.com', to=['not-really@invalid'])
with self.assertRaises(AnymailAPIError):
msg.send()
def test_fail_silently(self):
self.set_mock_response(status_code=400, raw=self.INVALID_TO_RESPONSE)
sent = mail.send_mail('Subject', 'Body', 'from@example.com', ['not a valid email'],
sent = mail.send_mail('Subject', 'Body', 'from@example.com', ['not-really@invalid'],
fail_silently=True)
self.assertEqual(sent, 0)

View File

@@ -102,7 +102,7 @@ class MailgunBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
message = AnymailMessage(
subject="Anymail all-options integration test",
body="This is the text body",
from_email="Test From <from@example.com>",
from_email="Test From <from@example.com>, also-from@example.com",
to=["to1@example.com", "Recipient 2 <to2@example.com>"],
cc=["cc1@example.com", "Copy 2 <cc2@example.com>"],
bcc=["bcc1@example.com", "Blind Copy 2 <bcc2@example.com>"],
@@ -141,7 +141,7 @@ class MailgunBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
'cc2@example.com', 'bcc1@example.com', 'bcc2@example.com'])
headers = event["message"]["headers"]
self.assertEqual(headers["from"], "Test From <from@example.com>")
self.assertEqual(headers["from"], "Test From <from@example.com>, also-from@example.com")
self.assertEqual(headers["to"], "to1@example.com, Recipient 2 <to2@example.com>")
self.assertEqual(headers["subject"], "Anymail all-options integration test")
@@ -156,13 +156,15 @@ class MailgunBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
# (We could try fetching the message from event["storage"]["url"]
# to verify content and other headers.)
def test_invalid_from(self):
self.message.from_email = 'webmaster'
with self.assertRaises(AnymailAPIError) as cm:
self.message.send()
err = cm.exception
self.assertEqual(err.status_code, 400)
self.assertIn("'from' parameter is not a valid address", str(err))
# As of Anymail 0.10, this test is no longer possible, because
# Anymail now raises AnymailInvalidAddress without even calling Mailgun
# def test_invalid_from(self):
# self.message.from_email = 'webmaster'
# with self.assertRaises(AnymailAPIError) as cm:
# self.message.send()
# err = cm.exception
# self.assertEqual(err.status_code, 400)
# self.assertIn("'from' parameter is not a valid address", str(err))
@override_settings(ANYMAIL={'MAILGUN_API_KEY': "Hey, that's not an API key",
'MAILGUN_SENDER_DOMAIN': MAILGUN_TEST_DOMAIN,

View File

@@ -10,8 +10,9 @@ from django.core.exceptions import ImproperlyConfigured
from django.test import SimpleTestCase
from django.test.utils import override_settings
from anymail.exceptions import (AnymailAPIError, AnymailSerializationError,
AnymailUnsupportedFeature, AnymailRecipientsRefused)
from anymail.exceptions import (
AnymailAPIError, AnymailSerializationError,
AnymailUnsupportedFeature, AnymailRecipientsRefused, AnymailInvalidAddress)
from anymail.message import attach_inline_image_file
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCasesMixin
@@ -271,6 +272,20 @@ class PostmarkBackendStandardEmailTests(PostmarkBackendMockAPITestCase):
data = self.get_api_call_json()
self.assertNotIn('To', data)
def test_multiple_from_emails(self):
"""Postmark accepts multiple addresses in from_email (though only uses the first)"""
self.message.from_email = 'first@example.com, "From, also" <second@example.com>'
self.message.send()
data = self.get_api_call_json()
self.assertEqual(data['From'],
'first@example.com, "From, also" <second@example.com>')
# Make sure the far-more-likely scenario of a single from_email
# with an unquoted display-name issues a reasonable error:
self.message.from_email = 'Unquoted, display-name <from@example.com>'
with self.assertRaises(AnymailInvalidAddress):
self.message.send()
def test_api_failure(self):
self.set_mock_response(status_code=500)
with self.assertRaisesMessage(AnymailAPIError, "Postmark API response 500"):

View File

@@ -43,7 +43,8 @@ class PostmarkBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
message = AnymailMessage(
subject="Anymail all-options integration test",
body="This is the text body",
from_email="Test From <from@example.com>",
# Postmark accepts multiple from_email addresses, but truncates to the first on their end
from_email="Test From <from@example.com>, also-from@example.com",
to=["to1@example.com", "Recipient 2 <to2@example.com>"],
cc=["cc1@example.com", "Copy 2 <cc2@example.com>"],
bcc=["bcc1@example.com", "Blind Copy 2 <bcc2@example.com>"],

View File

@@ -15,7 +15,7 @@ from mock import patch
from sparkpost.exceptions import SparkPostAPIException
from anymail.exceptions import (AnymailAPIError, AnymailUnsupportedFeature, AnymailRecipientsRefused,
AnymailConfigurationError)
AnymailConfigurationError, AnymailInvalidAddress)
from anymail.message import attach_inline_image_file
from .utils import AnymailTestMixin, decode_att, SAMPLE_IMAGE_FILENAME, sample_image_path, sample_image_content
@@ -288,6 +288,20 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase):
params = self.get_send_params()
self.assertNotIn('recipients', params)
def test_multiple_from_emails(self):
"""SparkPost supports multiple addresses in from_email"""
self.message.from_email = 'first@example.com, "From, also" <second@example.com>'
self.message.send()
params = self.get_send_params()
self.assertEqual(params['from_email'],
'first@example.com, "From, also" <second@example.com>')
# Make sure the far-more-likely scenario of a single from_email
# with an unquoted display-name issues a reasonable error:
self.message.from_email = 'Unquoted, display-name <from@example.com>'
with self.assertRaises(AnymailInvalidAddress):
self.message.send()
def test_api_failure(self):
self.set_mock_failure(status_code=400)
with self.assertRaisesMessage(AnymailAPIError, "SparkPost API response 400"):

View File

@@ -61,7 +61,7 @@ class SparkPostBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
message = AnymailMessage(
subject="Anymail all-options integration test",
body="This is the text body",
from_email="Test From <test@test-sp.anymail.info>",
from_email="Test From <test@test-sp.anymail.info>, also-from@test-sp.anymail.info",
to=["to1@test.sink.sparkpostmail.com", "Recipient 2 <to2@test.sink.sparkpostmail.com>"],
cc=["cc1@test.sink.sparkpostmail.com", "Copy 2 <cc2@test.sink.sparkpostmail.com>"],
bcc=["bcc1@test.sink.sparkpostmail.com", "Blind Copy 2 <bcc2@test.sink.sparkpostmail.com>"],

View File

@@ -7,66 +7,135 @@ from django.test import SimpleTestCase, RequestFactory, override_settings
from django.utils.translation import ugettext_lazy, string_concat
from anymail.exceptions import AnymailInvalidAddress
from anymail.utils import (ParsedEmail,
from anymail.utils import (parse_address_list, ParsedEmail,
is_lazy, force_non_lazy, force_non_lazy_dict, force_non_lazy_list,
update_deep,
get_request_uri, get_request_basic_auth)
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
class ParseAddressListTests(SimpleTestCase):
"""Test utils.parse_address_list"""
def test_simple_email(self):
parsed = ParsedEmail("test@example.com", self.ADDRESS_ENCODING)
parsed_list = parse_address_list(["test@example.com"])
self.assertEqual(len(parsed_list), 1)
parsed = parsed_list[0]
self.assertIsInstance(parsed, ParsedEmail)
self.assertEqual(parsed.email, "test@example.com")
self.assertEqual(parsed.name, "")
self.assertEqual(parsed.address, "test@example.com")
self.assertEqual(parsed.localpart, "test")
self.assertEqual(parsed.domain, "example.com")
def test_display_name(self):
parsed = ParsedEmail('"Display Name, Inc." <test@example.com>', self.ADDRESS_ENCODING)
parsed_list = parse_address_list(['"Display Name, Inc." <test@example.com>'])
self.assertEqual(len(parsed_list), 1)
parsed = parsed_list[0]
self.assertEqual(parsed.email, "test@example.com")
self.assertEqual(parsed.name, "Display Name, Inc.")
self.assertEqual(parsed.address, '"Display Name, Inc." <test@example.com>')
self.assertEqual(parsed.localpart, "test")
self.assertEqual(parsed.domain, "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)
parsed_list = parse_address_list(['Display Name <test@example.com>'])
self.assertEqual(len(parsed_list), 1)
parsed = parsed_list[0]
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)
parsed_list = parse_address_list([u'"Unicode \N{HEAVY BLACK HEART}" <test@example.com>'])
self.assertEqual(len(parsed_list), 1)
parsed = parsed_list[0]
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:
# formatted 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):
with self.assertRaisesMessage(AnymailInvalidAddress, "Invalid email address 'webmaster'"):
parse_address_list(['webmaster'])
with self.assertRaisesMessage(AnymailInvalidAddress, "Maybe missing quotes around a display-name?"):
# this parses as multiple email addresses, because of the comma:
ParsedEmail('Display Name, Inc. <test@example.com>', self.ADDRESS_ENCODING)
parse_address_list(['Display Name, Inc. <test@example.com>'])
def test_idn(self):
parsed_list = parse_address_list([u"idn@\N{ENVELOPE}.example.com"])
self.assertEqual(len(parsed_list), 1)
parsed = parsed_list[0]
self.assertEqual(parsed.email, u"idn@\N{ENVELOPE}.example.com")
self.assertEqual(parsed.address, "idn@xn--4bi.example.com") # punycode-encoded domain
self.assertEqual(parsed.localpart, "idn")
self.assertEqual(parsed.domain, u"\N{ENVELOPE}.example.com")
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)
self.assertEqual(parse_address_list([None]), [])
self.assertEqual(parse_address_list(None), [])
def test_empty_address(self):
with self.assertRaises(AnymailInvalidAddress):
ParsedEmail('', self.ADDRESS_ENCODING)
parse_address_list([''])
def test_whitespace_only_address(self):
with self.assertRaises(AnymailInvalidAddress):
ParsedEmail(' ', self.ADDRESS_ENCODING)
parse_address_list([' '])
def test_invalid_address(self):
with self.assertRaises(AnymailInvalidAddress):
parse_address_list(['localonly'])
with self.assertRaises(AnymailInvalidAddress):
parse_address_list(['localonly@'])
with self.assertRaises(AnymailInvalidAddress):
parse_address_list(['@domainonly'])
with self.assertRaises(AnymailInvalidAddress):
parse_address_list(['<localonly@>'])
with self.assertRaises(AnymailInvalidAddress):
parse_address_list(['<@domainonly>'])
def test_email_list(self):
parsed_list = parse_address_list(["first@example.com", "second@example.com"])
self.assertEqual(len(parsed_list), 2)
self.assertEqual(parsed_list[0].email, "first@example.com")
self.assertEqual(parsed_list[1].email, "second@example.com")
def test_multiple_emails(self):
# Django's EmailMessage allows multiple, comma-separated emails
# in a single recipient string. (It passes them along to the backend intact.)
# (Depending on this behavior is not recommended.)
parsed_list = parse_address_list(["first@example.com, second@example.com"])
self.assertEqual(len(parsed_list), 2)
self.assertEqual(parsed_list[0].email, "first@example.com")
self.assertEqual(parsed_list[1].email, "second@example.com")
def test_invalid_in_list(self):
# Make sure it's not just concatenating list items...
# the bare "Display Name" below should *not* get merged with
# the email in the second item
with self.assertRaisesMessage(AnymailInvalidAddress, "Display Name"):
parse_address_list(['"Display Name"', '<valid@example.com>'])
def test_single_string(self):
# bare strings are used by the from_email parsing in BasePayload
parsed_list = parse_address_list("one@example.com")
self.assertEqual(len(parsed_list), 1)
self.assertEqual(parsed_list[0].email, "one@example.com")
def test_lazy_strings(self):
parsed_list = parse_address_list([ugettext_lazy('"Example, Inc." <one@example.com>')])
self.assertEqual(len(parsed_list), 1)
self.assertEqual(parsed_list[0].name, "Example, Inc.")
self.assertEqual(parsed_list[0].email, "one@example.com")
parsed_list = parse_address_list(ugettext_lazy("one@example.com"))
self.assertEqual(len(parsed_list), 1)
self.assertEqual(parsed_list[0].name, "")
self.assertEqual(parsed_list[0].email, "one@example.com")
class LazyCoercionTests(SimpleTestCase):