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

@@ -8,7 +8,7 @@ from django.utils.timezone import is_naive, get_current_timezone, make_aware, ut
from ..exceptions import AnymailCancelSend, AnymailError, AnymailUnsupportedFeature, AnymailRecipientsRefused
from ..message import AnymailStatus
from ..signals import pre_send, post_send
from ..utils import (Attachment, ParsedEmail, UNSET, combine, last, get_anymail_setting,
from ..utils import (Attachment, UNSET, combine, last, get_anymail_setting, parse_address_list,
force_non_lazy, force_non_lazy_list, force_non_lazy_dict, is_lazy)
@@ -216,12 +216,12 @@ class BasePayload(object):
# the combined/converted results for each attr.
base_message_attrs = (
# Standard EmailMessage/EmailMultiAlternatives props
('from_email', last, 'parsed_email'),
('to', combine, 'parsed_emails'),
('cc', combine, 'parsed_emails'),
('bcc', combine, 'parsed_emails'),
('from_email', last, parse_address_list), # multiple from_emails are allowed
('to', combine, parse_address_list),
('cc', combine, parse_address_list),
('bcc', combine, parse_address_list),
('subject', last, force_non_lazy),
('reply_to', combine, 'parsed_emails'),
('reply_to', combine, parse_address_list),
('extra_headers', combine, force_non_lazy_dict),
('body', last, force_non_lazy), # special handling below checks message.content_subtype
('alternatives', combine, 'prepped_alternatives'),
@@ -266,6 +266,8 @@ class BasePayload(object):
if value is not UNSET:
if attr == 'body':
setter = self.set_html_body if message.content_subtype == 'html' else self.set_text_body
elif attr == 'from_email':
setter = self.set_from_email_list
else:
# AttributeError here? Your Payload subclass is missing a set_<attr> implementation
setter = getattr(self, 'set_%s' % attr)
@@ -300,14 +302,6 @@ class BasePayload(object):
# Attribute converters
#
def parsed_email(self, address):
return ParsedEmail(address, self.message.encoding) # (handles lazy address)
def parsed_emails(self, addresses):
encoding = self.message.encoding
return [ParsedEmail(address, encoding) # (handles lazy address)
for address in addresses]
def prepped_alternatives(self, alternatives):
return [(force_non_lazy(content), mimetype)
for (content, mimetype) in alternatives]
@@ -348,8 +342,17 @@ class BasePayload(object):
raise NotImplementedError("%s.%s must implement init_payload" %
(self.__class__.__module__, self.__class__.__name__))
def set_from_email_list(self, emails):
# If your backend supports multiple from emails, override this to handle the whole list;
# otherwise just implement set_from_email
if len(emails) > 1:
self.unsupported_feature("multiple from emails")
# fall through if ignoring unsupported features
if len(emails) > 0:
self.set_from_email(emails[0])
def set_from_email(self, email):
raise NotImplementedError("%s.%s must implement set_from_email" %
raise NotImplementedError("%s.%s must implement set_from_email or set_from_email_list" %
(self.__class__.__module__, self.__class__.__name__))
def set_to(self, emails):

View File

@@ -124,15 +124,12 @@ class MailgunPayload(RequestsPayload):
self.data = {} # {field: [multiple, values]}
self.files = [] # [(field, multiple), (field, values)]
def set_from_email(self, email):
self.data["from"] = str(email)
if self.sender_domain is None:
# try to intuit sender_domain from from_email
try:
_, domain = email.email.split('@')
self.sender_domain = domain
except ValueError:
pass
def set_from_email_list(self, emails):
# Mailgun supports multiple From email addresses
self.data["from"] = [email.address for email in emails]
if self.sender_domain is None and len(emails) > 0:
# try to intuit sender_domain from first from_email
self.sender_domain = emails[0].domain or None
def set_recipients(self, recipient_type, emails):
assert recipient_type in ["to", "cc", "bcc"]

View File

@@ -138,8 +138,10 @@ class PostmarkPayload(RequestsPayload):
def init_payload(self):
self.data = {} # becomes json
def set_from_email(self, email):
self.data["From"] = email.address
def set_from_email_list(self, emails):
# Postmark accepts multiple From email addresses
# (though truncates to just the first, on their end, as of 4/2017)
self.data["From"] = ", ".join([email.address for email in emails])
def set_recipients(self, recipient_type, emails):
assert recipient_type in ["to", "cc", "bcc"]

View File

@@ -7,7 +7,7 @@ from requests.structures import CaseInsensitiveDict
from .base_requests import AnymailRequestsBackend, RequestsPayload
from ..exceptions import AnymailConfigurationError, AnymailRequestsAPIError, AnymailWarning, AnymailDeprecationWarning
from ..message import AnymailRecipientStatus
from ..utils import get_anymail_setting, timestamp, update_deep
from ..utils import get_anymail_setting, timestamp, update_deep, parse_address_list
class EmailBackend(AnymailRequestsBackend):
@@ -115,7 +115,7 @@ class SendGridPayload(RequestsPayload):
if "Reply-To" in headers:
# Reply-To must be in its own param
reply_to = headers.pop('Reply-To')
self.set_reply_to([self.parsed_email(reply_to)])
self.set_reply_to(parse_address_list([reply_to]))
if len(headers) > 0:
self.data["headers"] = dict(headers) # flatten to normal dict for json serialization
else:

View File

@@ -129,8 +129,10 @@ class SparkPostPayload(BasePayload):
return self.params
def set_from_email(self, email):
self.params['from_email'] = email.address
def set_from_email_list(self, emails):
# SparkPost supports multiple From email addresses,
# as a single comma-separated string
self.params['from_email'] = ", ".join([email.address for email in emails])
def set_to(self, emails):
if emails: