From 386668908423d1d4eade90cf7a21a546a1e96514 Mon Sep 17 00:00:00 2001 From: medmunds Date: Tue, 24 Oct 2017 16:21:49 -0700 Subject: [PATCH] Utils: convert internal ParsedEmail to documented EmailAddress Update internal-use ParsedEmail to be more like Python 3.6+ email.headerregistry.Address, and remove "internal use only" recommendation. (Prep for exposing inbound email headers in a convenient form. Old names remain temporarily available for internal use; should clean up at some point.) --- anymail/backends/mailjet.py | 12 +++--- anymail/utils.py | 83 ++++++++++++++++++++++--------------- tests/test_utils.py | 46 ++++++++++---------- 3 files changed, 79 insertions(+), 62 deletions(-) diff --git a/anymail/backends/mailjet.py b/anymail/backends/mailjet.py index 68ef529..f8652ef 100644 --- a/anymail/backends/mailjet.py +++ b/anymail/backends/mailjet.py @@ -1,6 +1,6 @@ from ..exceptions import AnymailRequestsAPIError from ..message import AnymailRecipientStatus, ANYMAIL_STATUSES -from ..utils import get_anymail_setting, ParsedEmail, parse_address_list +from ..utils import get_anymail_setting, EmailAddress, parse_address_list from .base_requests import AnymailRequestsBackend, RequestsPayload @@ -127,11 +127,11 @@ class MailjetPayload(RequestsPayload): # if there's a comma in the template's From display-name: from_email = headers["From"].replace(",", "||COMMA||") parsed = parse_address_list([from_email])[0] - if parsed.name: - parsed.name = parsed.name.replace("||COMMA||", ",") + if parsed.display_name: + parsed = EmailAddress(parsed.display_name.replace("||COMMA||", ","), + parsed.addr_spec) else: - name_addr = (headers["SenderName"], headers["SenderEmail"]) - parsed = ParsedEmail(name_addr) + parsed = EmailAddress(headers["SenderName"], headers["SenderEmail"]) except KeyError: raise AnymailRequestsAPIError("Invalid Mailjet template API response", email_message=self.message, response=response, backend=self.backend) @@ -165,7 +165,7 @@ class MailjetPayload(RequestsPayload): formatted_emails = [ email.address if "," not in email.name # else name has a comma, so force it into MIME encoded-word utf-8 syntax: - else ParsedEmail((email.name.encode('utf-8'), email.email)).formataddr('utf-8') + else EmailAddress(email.name.encode('utf-8'), email.email).formataddr('utf-8') for email in emails ] self.data[recipient_type.capitalize()] = ", ".join(formatted_emails) diff --git a/anymail/utils.py b/anymail/utils.py index 85a389b..a0ccb5e 100644 --- a/anymail/utils.py +++ b/anymail/utils.py @@ -118,7 +118,7 @@ def update_deep(dct, other): def parse_address_list(address_list): - """Returns a list of ParsedEmail objects from strings in address_list. + """Returns a list of EmailAddress objects from strings in address_list. Essentially wraps :func:`email.utils.getaddresses` with better error messaging and more-useful output objects @@ -128,7 +128,7 @@ def parse_address_list(address_list): :param list[str]|str|None|list[None] address_list: the address or addresses to parse - :return list[:class:`ParsedEmail`]: + :return list[:class:`EmailAddress`]: :raises :exc:`AnymailInvalidAddress`: """ if isinstance(address_list, six.string_types) or is_lazy(address_list): @@ -145,14 +145,15 @@ def parse_address_list(address_list): 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] + parsed = [EmailAddress(display_name=name, addr_spec=email) + for (name, email) 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 + if address.username == '' or address.domain == '': + # Django SMTP allows username-only emails, but they're not meaningful with an ESP errmsg = "Invalid email address '%s' parsed from '%s'." % ( - address.email, ", ".join(address_list_strings)) + address.addr_spec, ", ".join(address_list_strings)) if len(parsed) > len(address_list): errmsg += " (Maybe missing quotes around a display-name?)" raise AnymailInvalidAddress(errmsg) @@ -160,46 +161,46 @@ def parse_address_list(address_list): return parsed -class ParsedEmail(object): - """A sanitized, complete email address with separate name and email properties. +class EmailAddress(object): + """A sanitized, complete email address with easy access + to display-name, addr-spec (email), etc. - (Intended for Anymail internal use.) + Similar to Python 3.6+ email.headerregistry.Address Instance properties, all read-only: - :ivar str name: + :ivar str display_name: the address's display-name portion (unqouted, unescaped), e.g., 'Display Name, Inc.' - :ivar str email: + :ivar str addr_spec: the address's addr-spec portion (unquoted, unescaped), e.g., 'user@example.com' + :ivar str username: + the local part (before the '@') of the addr-spec, + e.g., 'user' + :ivar str domain: + the domain part (after the '@') of the addr-spec, + e.g., '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' + (also available as `str(EmailAddress)`) """ - 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` - """ + def __init__(self, display_name='', addr_spec=None): self._address = None # lazy formatted address - self.name, self.email = name_email_pair + if addr_spec is None: + try: + display_name, addr_spec = display_name # unpack (name,addr) tuple + except ValueError: + pass + self.display_name = display_name + self.addr_spec = addr_spec try: - self.localpart, self.domain = self.email.split("@", 1) + self.username, self.domain = addr_spec.split("@", 1) + # do we need to unquote username? except ValueError: - self.localpart = self.email + self.username = addr_spec self.domain = '' @property @@ -215,7 +216,7 @@ class ParsedEmail(object): """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 + on the EmailAddress'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 @@ -226,11 +227,27 @@ class ParsedEmail(object): default None uses ascii if possible, else 'utf-8' (quoted-printable utf-8/base64) """ - return sanitize_address((self.name, self.email), encoding) + return sanitize_address((self.display_name, self.addr_spec), encoding) def __str__(self): return self.address + # Deprecated property names from old ParsedEmail (don't use in new code!) + @property + def name(self): + return self.display_name + + @property + def email(self): + return self.addr_spec + + @property + def localpart(self): + return self.username + + +ParsedEmail = EmailAddress # deprecated class name (don't use!) + class Attachment(object): """A normalized EmailMessage.attachments item with additional functionality diff --git a/tests/test_utils.py b/tests/test_utils.py index 6efee05..b6c9f6e 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -19,7 +19,7 @@ except ImportError: from anymail.exceptions import AnymailInvalidAddress from anymail.utils import ( - parse_address_list, ParsedEmail, + parse_address_list, EmailAddress, is_lazy, force_non_lazy, force_non_lazy_dict, force_non_lazy_list, update_deep, get_request_uri, get_request_basic_auth, parse_rfc2822date) @@ -32,21 +32,21 @@ class ParseAddressListTests(SimpleTestCase): 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.assertIsInstance(parsed, EmailAddress) + self.assertEqual(parsed.addr_spec, "test@example.com") + self.assertEqual(parsed.display_name, "") self.assertEqual(parsed.address, "test@example.com") - self.assertEqual(parsed.localpart, "test") + self.assertEqual(parsed.username, "test") self.assertEqual(parsed.domain, "example.com") def test_display_name(self): 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.addr_spec, "test@example.com") + self.assertEqual(parsed.display_name, "Display Name, Inc.") self.assertEqual(parsed.address, '"Display Name, Inc." ') - self.assertEqual(parsed.localpart, "test") + self.assertEqual(parsed.username, "test") self.assertEqual(parsed.domain, "example.com") def test_obsolete_display_name(self): @@ -55,16 +55,16 @@ class ParseAddressListTests(SimpleTestCase): 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.addr_spec, "test@example.com") + self.assertEqual(parsed.display_name, "Display Name") self.assertEqual(parsed.address, 'Display Name ') def test_unicode_display_name(self): 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}") + self.assertEqual(parsed.addr_spec, "test@example.com") + self.assertEqual(parsed.display_name, u"Unicode \N{HEAVY BLACK HEART}") # formatted display-name automatically shifts to quoted-printable/base64 for non-ascii chars: self.assertEqual(parsed.address, '=?utf-8?b?VW5pY29kZSDinaQ=?= ') @@ -80,9 +80,9 @@ class ParseAddressListTests(SimpleTestCase): 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.addr_spec, 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.username, "idn") self.assertEqual(parsed.domain, u"\N{ENVELOPE}.example.com") def test_none_address(self): @@ -113,8 +113,8 @@ class ParseAddressListTests(SimpleTestCase): 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") + self.assertEqual(parsed_list[0].addr_spec, "first@example.com") + self.assertEqual(parsed_list[1].addr_spec, "second@example.com") def test_multiple_emails(self): # Django's EmailMessage allows multiple, comma-separated emails @@ -122,8 +122,8 @@ class ParseAddressListTests(SimpleTestCase): # (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") + self.assertEqual(parsed_list[0].addr_spec, "first@example.com") + self.assertEqual(parsed_list[1].addr_spec, "second@example.com") def test_invalid_in_list(self): # Make sure it's not just concatenating list items... @@ -136,18 +136,18 @@ class ParseAddressListTests(SimpleTestCase): # 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") + self.assertEqual(parsed_list[0].addr_spec, "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") + self.assertEqual(parsed_list[0].display_name, "Example, Inc.") + self.assertEqual(parsed_list[0].addr_spec, "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") + self.assertEqual(parsed_list[0].display_name, "") + self.assertEqual(parsed_list[0].addr_spec, "one@example.com") class LazyCoercionTests(SimpleTestCase):