Skip to content

Commit 6b67930

Browse files
committed
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='[email protected], [email protected]' to=['[email protected]', '[email protected]'] 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=['[email protected]', '[email protected], [email protected]'] len(to) == 2 # but there will be three recipients Fixes #60
1 parent 3c2c0b3 commit 6b67930

13 files changed

+302
-95
lines changed

anymail/backends/base.py

+18-15
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from ..exceptions import AnymailCancelSend, AnymailError, AnymailUnsupportedFeature, AnymailRecipientsRefused
99
from ..message import AnymailStatus
1010
from ..signals import pre_send, post_send
11-
from ..utils import (Attachment, ParsedEmail, UNSET, combine, last, get_anymail_setting,
11+
from ..utils import (Attachment, UNSET, combine, last, get_anymail_setting, parse_address_list,
1212
force_non_lazy, force_non_lazy_list, force_non_lazy_dict, is_lazy)
1313

1414

@@ -216,12 +216,12 @@ class BasePayload(object):
216216
# the combined/converted results for each attr.
217217
base_message_attrs = (
218218
# Standard EmailMessage/EmailMultiAlternatives props
219-
('from_email', last, 'parsed_email'),
220-
('to', combine, 'parsed_emails'),
221-
('cc', combine, 'parsed_emails'),
222-
('bcc', combine, 'parsed_emails'),
219+
('from_email', last, parse_address_list), # multiple from_emails are allowed
220+
('to', combine, parse_address_list),
221+
('cc', combine, parse_address_list),
222+
('bcc', combine, parse_address_list),
223223
('subject', last, force_non_lazy),
224-
('reply_to', combine, 'parsed_emails'),
224+
('reply_to', combine, parse_address_list),
225225
('extra_headers', combine, force_non_lazy_dict),
226226
('body', last, force_non_lazy), # special handling below checks message.content_subtype
227227
('alternatives', combine, 'prepped_alternatives'),
@@ -266,6 +266,8 @@ def __init__(self, message, defaults, backend):
266266
if value is not UNSET:
267267
if attr == 'body':
268268
setter = self.set_html_body if message.content_subtype == 'html' else self.set_text_body
269+
elif attr == 'from_email':
270+
setter = self.set_from_email_list
269271
else:
270272
# AttributeError here? Your Payload subclass is missing a set_<attr> implementation
271273
setter = getattr(self, 'set_%s' % attr)
@@ -300,14 +302,6 @@ def validate_not_bare_string(self, attr, value):
300302
# Attribute converters
301303
#
302304

303-
def parsed_email(self, address):
304-
return ParsedEmail(address, self.message.encoding) # (handles lazy address)
305-
306-
def parsed_emails(self, addresses):
307-
encoding = self.message.encoding
308-
return [ParsedEmail(address, encoding) # (handles lazy address)
309-
for address in addresses]
310-
311305
def prepped_alternatives(self, alternatives):
312306
return [(force_non_lazy(content), mimetype)
313307
for (content, mimetype) in alternatives]
@@ -348,8 +342,17 @@ def init_payload(self):
348342
raise NotImplementedError("%s.%s must implement init_payload" %
349343
(self.__class__.__module__, self.__class__.__name__))
350344

345+
def set_from_email_list(self, emails):
346+
# If your backend supports multiple from emails, override this to handle the whole list;
347+
# otherwise just implement set_from_email
348+
if len(emails) > 1:
349+
self.unsupported_feature("multiple from emails")
350+
# fall through if ignoring unsupported features
351+
if len(emails) > 0:
352+
self.set_from_email(emails[0])
353+
351354
def set_from_email(self, email):
352-
raise NotImplementedError("%s.%s must implement set_from_email" %
355+
raise NotImplementedError("%s.%s must implement set_from_email or set_from_email_list" %
353356
(self.__class__.__module__, self.__class__.__name__))
354357

355358
def set_to(self, emails):

anymail/backends/mailgun.py

+6-9
Original file line numberDiff line numberDiff line change
@@ -124,15 +124,12 @@ def init_payload(self):
124124
self.data = {} # {field: [multiple, values]}
125125
self.files = [] # [(field, multiple), (field, values)]
126126

127-
def set_from_email(self, email):
128-
self.data["from"] = str(email)
129-
if self.sender_domain is None:
130-
# try to intuit sender_domain from from_email
131-
try:
132-
_, domain = email.email.split('@')
133-
self.sender_domain = domain
134-
except ValueError:
135-
pass
127+
def set_from_email_list(self, emails):
128+
# Mailgun supports multiple From email addresses
129+
self.data["from"] = [email.address for email in emails]
130+
if self.sender_domain is None and len(emails) > 0:
131+
# try to intuit sender_domain from first from_email
132+
self.sender_domain = emails[0].domain or None
136133

137134
def set_recipients(self, recipient_type, emails):
138135
assert recipient_type in ["to", "cc", "bcc"]

anymail/backends/postmark.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,10 @@ def serialize_data(self):
138138
def init_payload(self):
139139
self.data = {} # becomes json
140140

141-
def set_from_email(self, email):
142-
self.data["From"] = email.address
141+
def set_from_email_list(self, emails):
142+
# Postmark accepts multiple From email addresses
143+
# (though truncates to just the first, on their end, as of 4/2017)
144+
self.data["From"] = ", ".join([email.address for email in emails])
143145

144146
def set_recipients(self, recipient_type, emails):
145147
assert recipient_type in ["to", "cc", "bcc"]

anymail/backends/sendgrid.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from .base_requests import AnymailRequestsBackend, RequestsPayload
88
from ..exceptions import AnymailConfigurationError, AnymailRequestsAPIError, AnymailWarning, AnymailDeprecationWarning
99
from ..message import AnymailRecipientStatus
10-
from ..utils import get_anymail_setting, timestamp, update_deep
10+
from ..utils import get_anymail_setting, timestamp, update_deep, parse_address_list
1111

1212

1313
class EmailBackend(AnymailRequestsBackend):
@@ -115,7 +115,7 @@ def serialize_data(self):
115115
if "Reply-To" in headers:
116116
# Reply-To must be in its own param
117117
reply_to = headers.pop('Reply-To')
118-
self.set_reply_to([self.parsed_email(reply_to)])
118+
self.set_reply_to(parse_address_list([reply_to]))
119119
if len(headers) > 0:
120120
self.data["headers"] = dict(headers) # flatten to normal dict for json serialization
121121
else:

anymail/backends/sparkpost.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,10 @@ def get_api_params(self):
129129

130130
return self.params
131131

132-
def set_from_email(self, email):
133-
self.params['from_email'] = email.address
132+
def set_from_email_list(self, emails):
133+
# SparkPost supports multiple From email addresses,
134+
# as a single comma-separated string
135+
self.params['from_email'] = ", ".join([email.address for email in emails])
134136

135137
def set_to(self, emails):
136138
if emails:

anymail/utils.py

+105-24
Original file line numberDiff line numberDiff line change
@@ -113,35 +113,116 @@ def update_deep(dct, other):
113113
# (like dict.update(), no return value)
114114

115115

116-
def parse_one_addr(address):
117-
# This is email.utils.parseaddr, but without silently returning
118-
# partial content if there are commas or parens in the string:
119-
addresses = getaddresses([address])
120-
if len(addresses) > 1:
121-
raise ValueError("Multiple email addresses (parses as %r)" % addresses)
122-
elif len(addresses) == 0:
123-
return ('', '')
124-
return addresses[0]
116+
def parse_address_list(address_list):
117+
"""Returns a list of ParsedEmail objects from strings in address_list.
118+
119+
Essentially wraps :func:`email.utils.getaddresses` with better error
120+
messaging and more-useful output objects
121+
122+
Note that the returned list might be longer than the address_list param,
123+
if any individual string contains multiple comma-separated addresses.
124+
125+
:param list[str]|str|None|list[None] address_list:
126+
the address or addresses to parse
127+
:return list[:class:`ParsedEmail`]:
128+
:raises :exc:`AnymailInvalidAddress`:
129+
"""
130+
if isinstance(address_list, six.string_types) or is_lazy(address_list):
131+
address_list = [address_list]
132+
133+
if address_list is None or address_list == [None]:
134+
return []
135+
136+
# For consistency with Django's SMTP backend behavior, extract all addresses
137+
# from the list -- which may split comma-seperated strings into multiple addresses.
138+
# (See django.core.mail.message: EmailMessage.message to/cc/bcc/reply_to handling;
139+
# also logic for ADDRESS_HEADERS in forbid_multi_line_headers.)
140+
address_list_strings = [force_text(address) for address in address_list] # resolve lazy strings
141+
name_email_pairs = getaddresses(address_list_strings)
142+
if name_email_pairs == [] and address_list_strings == [""]:
143+
name_email_pairs = [('', '')] # getaddresses ignores a single empty string
144+
parsed = [ParsedEmail(name_email_pair) for name_email_pair in name_email_pairs]
145+
146+
# Sanity-check, and raise useful errors
147+
for address in parsed:
148+
if address.localpart == '' or address.domain == '':
149+
# Django SMTP allows localpart-only emails, but they're not meaningful with an ESP
150+
errmsg = "Invalid email address '%s' parsed from '%s'." % (
151+
address.email, ", ".join(address_list_strings))
152+
if len(parsed) > len(address_list):
153+
errmsg += " (Maybe missing quotes around a display-name?)"
154+
raise AnymailInvalidAddress(errmsg)
155+
156+
return parsed
125157

126158

127159
class ParsedEmail(object):
128-
"""A sanitized, full email address with separate name and email properties."""
160+
"""A sanitized, complete email address with separate name and email properties.
161+
162+
(Intended for Anymail internal use.)
163+
164+
Instance properties, all read-only:
165+
:ivar str name:
166+
the address's display-name portion (unqouted, unescaped),
167+
e.g., 'Display Name, Inc.'
168+
:ivar str email:
169+
the address's addr-spec portion (unquoted, unescaped),
170+
171+
:ivar str address:
172+
the fully-formatted address, with any necessary quoting and escaping,
173+
e.g., '"Display Name, Inc." <[email protected]>'
174+
:ivar str localpart:
175+
the local part (before the '@') of email,
176+
e.g., 'user'
177+
:ivar str domain:
178+
the domain part (after the '@') of email,
179+
e.g., 'example.com'
180+
"""
129181

130-
def __init__(self, address, encoding):
131-
if address is None:
132-
self.name = self.email = self.address = None
133-
return
182+
def __init__(self, name_email_pair):
183+
"""Construct a ParsedEmail.
184+
185+
You generally should use :func:`parse_address_list` rather than creating
186+
ParsedEmail objects directly.
187+
188+
:param tuple(str, str) name_email_pair:
189+
the display-name and addr-spec (both unquoted) for the address,
190+
as returned by :func:`email.utils.parseaddr` and
191+
:func:`email.utils.getaddresses`
192+
"""
193+
self._address = None # lazy formatted address
194+
self.name, self.email = name_email_pair
134195
try:
135-
self.name, self.email = parse_one_addr(force_text(address))
136-
if self.email == '':
137-
# normalize sanitize_address py2/3 behavior:
138-
raise ValueError('No email found')
139-
# Django's sanitize_address is like email.utils.formataddr, but also
140-
# escapes as needed for use in email message headers:
141-
self.address = sanitize_address((self.name, self.email), encoding)
142-
except (IndexError, TypeError, ValueError) as err:
143-
raise AnymailInvalidAddress("Invalid email address format %r: %s"
144-
% (address, str(err)))
196+
self.localpart, self.domain = self.email.split("@", 1)
197+
except ValueError:
198+
self.localpart = self.email
199+
self.domain = ''
200+
201+
@property
202+
def address(self):
203+
if self._address is None:
204+
# (you might be tempted to use `encoding=settings.DEFAULT_CHARSET` here,
205+
# but that always forces the display-name to quoted-printable/base64,
206+
# even when simple ascii would work fine--and be more readable)
207+
self._address = self.formataddr()
208+
return self._address
209+
210+
def formataddr(self, encoding=None):
211+
"""Return a fully-formatted email address, using encoding.
212+
213+
This is essentially the same as :func:`email.utils.formataddr`
214+
on the ParsedEmail's name and email properties, but uses
215+
Django's :func:`~django.core.mail.message.sanitize_address`
216+
for improved PY2/3 compatibility, consistent handling of
217+
encoding (a.k.a. charset), and proper handling of IDN
218+
domain portions.
219+
220+
:param str|None encoding:
221+
the charset to use for the display-name portion;
222+
default None uses ascii if possible, else 'utf-8'
223+
(quoted-printable utf-8/base64)
224+
"""
225+
return sanitize_address((self.name, self.email), encoding)
145226

146227
def __str__(self):
147228
return self.address

tests/test_mailgun_backend.py

+27-6
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ def message_from_bytes(s):
1818
from django.test.utils import override_settings
1919
from django.utils.timezone import get_fixed_timezone, override as override_current_timezone
2020

21-
from anymail.exceptions import AnymailAPIError, AnymailRequestsAPIError, AnymailUnsupportedFeature
21+
from anymail.exceptions import (
22+
AnymailAPIError, AnymailInvalidAddress,
23+
AnymailRequestsAPIError, AnymailUnsupportedFeature)
2224
from anymail.message import attach_inline_image_file
2325

2426
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCasesMixin
@@ -53,7 +55,7 @@ def test_send_mail(self):
5355
data = self.get_api_call_data()
5456
self.assertEqual(data['subject'], "Subject here")
5557
self.assertEqual(data['text'], "Here is the message.")
56-
self.assertEqual(data['from'], "[email protected]")
58+
self.assertEqual(data['from'], ["[email protected]"])
5759
self.assertEqual(data['to'], ["[email protected]"])
5860

5961
def test_name_addr(self):
@@ -68,7 +70,7 @@ def test_name_addr(self):
6870
bcc=['Blind Copy <[email protected]>', '[email protected]'])
6971
msg.send()
7072
data = self.get_api_call_data()
71-
self.assertEqual(data['from'], "From Name <[email protected]>")
73+
self.assertEqual(data['from'], ["From Name <[email protected]>"])
7274
self.assertEqual(data['to'], ['Recipient #1 <[email protected]>', '[email protected]'])
7375
self.assertEqual(data['cc'], ['Carbon Copy <[email protected]>', '[email protected]'])
7476
self.assertEqual(data['bcc'], ['Blind Copy <[email protected]>', '[email protected]'])
@@ -86,7 +88,7 @@ def test_email_message(self):
8688
data = self.get_api_call_data()
8789
self.assertEqual(data['subject'], "Subject")
8890
self.assertEqual(data['text'], "Body goes here")
89-
self.assertEqual(data['from'], "[email protected]")
91+
self.assertEqual(data['from'], ["[email protected]"])
9092
self.assertEqual(data['to'], ['[email protected]', 'Also To <[email protected]>'])
9193
self.assertEqual(data['bcc'], ['[email protected]', 'Also BCC <[email protected]>'])
9294
self.assertEqual(data['cc'], ['[email protected]', 'Also CC <[email protected]>'])
@@ -249,6 +251,20 @@ def test_suppress_empty_address_lists(self):
249251
data = self.get_api_call_data()
250252
self.assertNotIn('to', data)
251253

254+
def test_multiple_from_emails(self):
255+
"""Mailgun supports multiple addresses in from_email"""
256+
self.message.from_email = '[email protected], "From, also" <[email protected]>'
257+
self.message.send()
258+
data = self.get_api_call_data()
259+
self.assertEqual(data['from'], ['[email protected]',
260+
'"From, also" <[email protected]>'])
261+
262+
# Make sure the far-more-likely scenario of a single from_email
263+
# with an unquoted display-name issues a reasonable error:
264+
self.message.from_email = 'Unquoted, display-name <[email protected]>'
265+
with self.assertRaises(AnymailInvalidAddress):
266+
self.message.send()
267+
252268
def test_api_failure(self):
253269
self.set_mock_response(status_code=400)
254270
with self.assertRaisesMessage(AnymailAPIError, "Mailgun API response 400"):
@@ -488,15 +504,20 @@ class MailgunBackendRecipientsRefusedTests(MailgunBackendMockAPITestCase):
488504
"message": "'to' parameter is not a valid address. please check documentation"
489505
}"""
490506

507+
# NOTE: As of Anymail 0.10, Anymail catches actually-invalid recipient emails
508+
# before attempting to pass them along to the ESP, so the tests below use technically
509+
# valid emails that would actually be accepted by Mailgun. (We're just making sure
510+
# the backend would correctly handle the 400 response if something slipped through.)
511+
491512
def test_invalid_email(self):
492513
self.set_mock_response(status_code=400, raw=self.INVALID_TO_RESPONSE)
493-
msg = mail.EmailMessage('Subject', 'Body', '[email protected]', to=['not a valid email'])
514+
msg = mail.EmailMessage('Subject', 'Body', '[email protected]', to=['not-really@invalid'])
494515
with self.assertRaises(AnymailAPIError):
495516
msg.send()
496517

497518
def test_fail_silently(self):
498519
self.set_mock_response(status_code=400, raw=self.INVALID_TO_RESPONSE)
499-
sent = mail.send_mail('Subject', 'Body', '[email protected]', ['not a valid email'],
520+
sent = mail.send_mail('Subject', 'Body', '[email protected]', ['not-really@invalid'],
500521
fail_silently=True)
501522
self.assertEqual(sent, 0)
502523

tests/test_mailgun_integration.py

+11-9
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ def test_all_options(self):
102102
message = AnymailMessage(
103103
subject="Anymail all-options integration test",
104104
body="This is the text body",
105-
from_email="Test From <[email protected]>",
105+
from_email="Test From <[email protected]>, [email protected]",
106106
to=["[email protected]", "Recipient 2 <[email protected]>"],
107107
108108
bcc=["[email protected]", "Blind Copy 2 <[email protected]>"],
@@ -141,7 +141,7 @@ def test_all_options(self):
141141
142142

143143
headers = event["message"]["headers"]
144-
self.assertEqual(headers["from"], "Test From <[email protected]>")
144+
self.assertEqual(headers["from"], "Test From <[email protected]>, [email protected]")
145145
self.assertEqual(headers["to"], "[email protected], Recipient 2 <[email protected]>")
146146
self.assertEqual(headers["subject"], "Anymail all-options integration test")
147147

@@ -156,13 +156,15 @@ def test_all_options(self):
156156
# (We could try fetching the message from event["storage"]["url"]
157157
# to verify content and other headers.)
158158

159-
def test_invalid_from(self):
160-
self.message.from_email = 'webmaster'
161-
with self.assertRaises(AnymailAPIError) as cm:
162-
self.message.send()
163-
err = cm.exception
164-
self.assertEqual(err.status_code, 400)
165-
self.assertIn("'from' parameter is not a valid address", str(err))
159+
# As of Anymail 0.10, this test is no longer possible, because
160+
# Anymail now raises AnymailInvalidAddress without even calling Mailgun
161+
# def test_invalid_from(self):
162+
# self.message.from_email = 'webmaster'
163+
# with self.assertRaises(AnymailAPIError) as cm:
164+
# self.message.send()
165+
# err = cm.exception
166+
# self.assertEqual(err.status_code, 400)
167+
# self.assertIn("'from' parameter is not a valid address", str(err))
166168

167169
@override_settings(ANYMAIL={'MAILGUN_API_KEY': "Hey, that's not an API key",
168170
'MAILGUN_SENDER_DOMAIN': MAILGUN_TEST_DOMAIN,

0 commit comments

Comments
 (0)