diff --git a/anymail/backends/base.py b/anymail/backends/base.py index 86319b9..c5f658a 100644 --- a/anymail/backends/base.py +++ b/anymail/backends/base.py @@ -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_ 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): diff --git a/anymail/backends/mailgun.py b/anymail/backends/mailgun.py index a5492f3..5f8d933 100644 --- a/anymail/backends/mailgun.py +++ b/anymail/backends/mailgun.py @@ -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"] diff --git a/anymail/backends/postmark.py b/anymail/backends/postmark.py index 86f6d22..22b9795 100644 --- a/anymail/backends/postmark.py +++ b/anymail/backends/postmark.py @@ -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"] diff --git a/anymail/backends/sendgrid.py b/anymail/backends/sendgrid.py index 7e4b718..afe6a16 100644 --- a/anymail/backends/sendgrid.py +++ b/anymail/backends/sendgrid.py @@ -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: diff --git a/anymail/backends/sparkpost.py b/anymail/backends/sparkpost.py index 6d1d8f6..b3976b7 100644 --- a/anymail/backends/sparkpost.py +++ b/anymail/backends/sparkpost.py @@ -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: diff --git a/anymail/utils.py b/anymail/utils.py index f9e71ad..d245f66 100644 --- a/anymail/utils.py +++ b/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." ' + :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 diff --git a/tests/test_mailgun_backend.py b/tests/test_mailgun_backend.py index de44e26..54e9bab 100644 --- a/tests/test_mailgun_backend.py +++ b/tests/test_mailgun_backend.py @@ -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 ', 'bcc2@example.com']) msg.send() data = self.get_api_call_data() - self.assertEqual(data['from'], "From Name ") + self.assertEqual(data['from'], ["From Name "]) self.assertEqual(data['to'], ['Recipient #1 ', 'to2@example.com']) self.assertEqual(data['cc'], ['Carbon Copy ', 'cc2@example.com']) self.assertEqual(data['bcc'], ['Blind Copy ', '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 ']) self.assertEqual(data['bcc'], ['bcc1@example.com', 'Also BCC ']) self.assertEqual(data['cc'], ['cc1@example.com', 'Also CC ']) @@ -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" ' + self.message.send() + data = self.get_api_call_data() + self.assertEqual(data['from'], ['first@example.com', + '"From, also" ']) + + # 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 ' + 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) diff --git a/tests/test_mailgun_integration.py b/tests/test_mailgun_integration.py index 61421aa..3d0b982 100644 --- a/tests/test_mailgun_integration.py +++ b/tests/test_mailgun_integration.py @@ -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_email="Test From , also-from@example.com", to=["to1@example.com", "Recipient 2 "], cc=["cc1@example.com", "Copy 2 "], bcc=["bcc1@example.com", "Blind Copy 2 "], @@ -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 ") + self.assertEqual(headers["from"], "Test From , also-from@example.com") self.assertEqual(headers["to"], "to1@example.com, Recipient 2 ") 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, diff --git a/tests/test_postmark_backend.py b/tests/test_postmark_backend.py index 9332f2b..27153fc 100644 --- a/tests/test_postmark_backend.py +++ b/tests/test_postmark_backend.py @@ -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" ' + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data['From'], + 'first@example.com, "From, also" ') + + # 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 ' + 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"): diff --git a/tests/test_postmark_integration.py b/tests/test_postmark_integration.py index 9597c43..433976b 100644 --- a/tests/test_postmark_integration.py +++ b/tests/test_postmark_integration.py @@ -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 ", + # Postmark accepts multiple from_email addresses, but truncates to the first on their end + from_email="Test From , also-from@example.com", to=["to1@example.com", "Recipient 2 "], cc=["cc1@example.com", "Copy 2 "], bcc=["bcc1@example.com", "Blind Copy 2 "], diff --git a/tests/test_sparkpost_backend.py b/tests/test_sparkpost_backend.py index 26bc487..4a70075 100644 --- a/tests/test_sparkpost_backend.py +++ b/tests/test_sparkpost_backend.py @@ -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" ' + self.message.send() + params = self.get_send_params() + self.assertEqual(params['from_email'], + 'first@example.com, "From, also" ') + + # 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 ' + 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"): diff --git a/tests/test_sparkpost_integration.py b/tests/test_sparkpost_integration.py index bb6be2e..13c6a47 100644 --- a/tests/test_sparkpost_integration.py +++ b/tests/test_sparkpost_integration.py @@ -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 ", + from_email="Test From , also-from@test-sp.anymail.info", to=["to1@test.sink.sparkpostmail.com", "Recipient 2 "], cc=["cc1@test.sink.sparkpostmail.com", "Copy 2 "], bcc=["bcc1@test.sink.sparkpostmail.com", "Blind Copy 2 "], diff --git a/tests/test_utils.py b/tests/test_utils.py index f6315ec..4391022 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -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." ', self.ADDRESS_ENCODING) + parsed_list = parse_address_list(['"Display Name, Inc." ']) + 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." ') + 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 ', self.ADDRESS_ENCODING) + parsed_list = parse_address_list(['Display Name ']) + 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 ') def test_unicode_display_name(self): - parsed = ParsedEmail(u'"Unicode \N{HEAVY BLACK HEART}" ', self.ADDRESS_ENCODING) + parsed_list = parse_address_list([u'"Unicode \N{HEAVY BLACK HEART}" ']) + 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=?= ') 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. ', self.ADDRESS_ENCODING) + parse_address_list(['Display Name, Inc. ']) + + 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(['']) + 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"', '']) + + 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." ')]) + 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):