mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 03:41:05 -05:00
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:
@@ -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):
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
129
anymail/utils.py
129
anymail/utils.py
@@ -113,35 +113,116 @@ def update_deep(dct, other):
|
||||
# (like dict.update(), no return value)
|
||||
|
||||
|
||||
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]
|
||||
def parse_address_list(address_list):
|
||||
"""Returns a list of ParsedEmail objects from strings in address_list.
|
||||
|
||||
Essentially wraps :func:`email.utils.getaddresses` with better error
|
||||
messaging and more-useful output objects
|
||||
|
||||
Note that the returned list might be longer than the address_list param,
|
||||
if any individual string contains multiple comma-separated addresses.
|
||||
|
||||
:param list[str]|str|None|list[None] address_list:
|
||||
the address or addresses to parse
|
||||
:return list[:class:`ParsedEmail`]:
|
||||
:raises :exc:`AnymailInvalidAddress`:
|
||||
"""
|
||||
if isinstance(address_list, six.string_types) or is_lazy(address_list):
|
||||
address_list = [address_list]
|
||||
|
||||
if address_list is None or address_list == [None]:
|
||||
return []
|
||||
|
||||
# For consistency with Django's SMTP backend behavior, extract all addresses
|
||||
# from the list -- which may split comma-seperated strings into multiple addresses.
|
||||
# (See django.core.mail.message: EmailMessage.message to/cc/bcc/reply_to handling;
|
||||
# also logic for ADDRESS_HEADERS in forbid_multi_line_headers.)
|
||||
address_list_strings = [force_text(address) for address in address_list] # resolve lazy strings
|
||||
name_email_pairs = getaddresses(address_list_strings)
|
||||
if name_email_pairs == [] and address_list_strings == [""]:
|
||||
name_email_pairs = [('', '')] # getaddresses ignores a single empty string
|
||||
parsed = [ParsedEmail(name_email_pair) for name_email_pair in name_email_pairs]
|
||||
|
||||
# Sanity-check, and raise useful errors
|
||||
for address in parsed:
|
||||
if address.localpart == '' or address.domain == '':
|
||||
# Django SMTP allows localpart-only emails, but they're not meaningful with an ESP
|
||||
errmsg = "Invalid email address '%s' parsed from '%s'." % (
|
||||
address.email, ", ".join(address_list_strings))
|
||||
if len(parsed) > len(address_list):
|
||||
errmsg += " (Maybe missing quotes around a display-name?)"
|
||||
raise AnymailInvalidAddress(errmsg)
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
class ParsedEmail(object):
|
||||
"""A sanitized, full email address with separate name and email properties."""
|
||||
"""A sanitized, complete email address with separate name and email properties.
|
||||
|
||||
def __init__(self, address, encoding):
|
||||
if address is None:
|
||||
self.name = self.email = self.address = None
|
||||
return
|
||||
(Intended for Anymail internal use.)
|
||||
|
||||
Instance properties, all read-only:
|
||||
:ivar str name:
|
||||
the address's display-name portion (unqouted, unescaped),
|
||||
e.g., 'Display Name, Inc.'
|
||||
:ivar str email:
|
||||
the address's addr-spec portion (unquoted, unescaped),
|
||||
e.g., 'user@example.com'
|
||||
:ivar str address:
|
||||
the fully-formatted address, with any necessary quoting and escaping,
|
||||
e.g., '"Display Name, Inc." <user@example.com>'
|
||||
:ivar str localpart:
|
||||
the local part (before the '@') of email,
|
||||
e.g., 'user'
|
||||
:ivar str domain:
|
||||
the domain part (after the '@') of email,
|
||||
e.g., 'example.com'
|
||||
"""
|
||||
|
||||
def __init__(self, name_email_pair):
|
||||
"""Construct a ParsedEmail.
|
||||
|
||||
You generally should use :func:`parse_address_list` rather than creating
|
||||
ParsedEmail objects directly.
|
||||
|
||||
:param tuple(str, str) name_email_pair:
|
||||
the display-name and addr-spec (both unquoted) for the address,
|
||||
as returned by :func:`email.utils.parseaddr` and
|
||||
:func:`email.utils.getaddresses`
|
||||
"""
|
||||
self._address = None # lazy formatted address
|
||||
self.name, self.email = name_email_pair
|
||||
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)))
|
||||
self.localpart, self.domain = self.email.split("@", 1)
|
||||
except ValueError:
|
||||
self.localpart = self.email
|
||||
self.domain = ''
|
||||
|
||||
@property
|
||||
def address(self):
|
||||
if self._address is None:
|
||||
# (you might be tempted to use `encoding=settings.DEFAULT_CHARSET` here,
|
||||
# but that always forces the display-name to quoted-printable/base64,
|
||||
# even when simple ascii would work fine--and be more readable)
|
||||
self._address = self.formataddr()
|
||||
return self._address
|
||||
|
||||
def formataddr(self, encoding=None):
|
||||
"""Return a fully-formatted email address, using encoding.
|
||||
|
||||
This is essentially the same as :func:`email.utils.formataddr`
|
||||
on the ParsedEmail's name and email properties, but uses
|
||||
Django's :func:`~django.core.mail.message.sanitize_address`
|
||||
for improved PY2/3 compatibility, consistent handling of
|
||||
encoding (a.k.a. charset), and proper handling of IDN
|
||||
domain portions.
|
||||
|
||||
:param str|None encoding:
|
||||
the charset to use for the display-name portion;
|
||||
default None uses ascii if possible, else 'utf-8'
|
||||
(quoted-printable utf-8/base64)
|
||||
"""
|
||||
return sanitize_address((self.name, self.email), encoding)
|
||||
|
||||
def __str__(self):
|
||||
return self.address
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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>"],
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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>"],
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user