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.)
This commit is contained in:
medmunds
2017-10-24 16:21:49 -07:00
parent fe097ce4b4
commit 3866689084
3 changed files with 79 additions and 62 deletions

View File

@@ -1,6 +1,6 @@
from ..exceptions import AnymailRequestsAPIError from ..exceptions import AnymailRequestsAPIError
from ..message import AnymailRecipientStatus, ANYMAIL_STATUSES 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 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: # if there's a comma in the template's From display-name:
from_email = headers["From"].replace(",", "||COMMA||") from_email = headers["From"].replace(",", "||COMMA||")
parsed = parse_address_list([from_email])[0] parsed = parse_address_list([from_email])[0]
if parsed.name: if parsed.display_name:
parsed.name = parsed.name.replace("||COMMA||", ",") parsed = EmailAddress(parsed.display_name.replace("||COMMA||", ","),
parsed.addr_spec)
else: else:
name_addr = (headers["SenderName"], headers["SenderEmail"]) parsed = EmailAddress(headers["SenderName"], headers["SenderEmail"])
parsed = ParsedEmail(name_addr)
except KeyError: except KeyError:
raise AnymailRequestsAPIError("Invalid Mailjet template API response", raise AnymailRequestsAPIError("Invalid Mailjet template API response",
email_message=self.message, response=response, backend=self.backend) email_message=self.message, response=response, backend=self.backend)
@@ -165,7 +165,7 @@ class MailjetPayload(RequestsPayload):
formatted_emails = [ formatted_emails = [
email.address if "," not in email.name email.address if "," not in email.name
# else name has a comma, so force it into MIME encoded-word utf-8 syntax: # 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 for email in emails
] ]
self.data[recipient_type.capitalize()] = ", ".join(formatted_emails) self.data[recipient_type.capitalize()] = ", ".join(formatted_emails)

View File

@@ -118,7 +118,7 @@ def update_deep(dct, other):
def parse_address_list(address_list): 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 Essentially wraps :func:`email.utils.getaddresses` with better error
messaging and more-useful output objects 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: :param list[str]|str|None|list[None] address_list:
the address or addresses to parse the address or addresses to parse
:return list[:class:`ParsedEmail`]: :return list[:class:`EmailAddress`]:
:raises :exc:`AnymailInvalidAddress`: :raises :exc:`AnymailInvalidAddress`:
""" """
if isinstance(address_list, six.string_types) or is_lazy(address_list): 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) name_email_pairs = getaddresses(address_list_strings)
if name_email_pairs == [] and address_list_strings == [""]: if name_email_pairs == [] and address_list_strings == [""]:
name_email_pairs = [('', '')] # getaddresses ignores a single empty string 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 # Sanity-check, and raise useful errors
for address in parsed: for address in parsed:
if address.localpart == '' or address.domain == '': if address.username == '' or address.domain == '':
# Django SMTP allows localpart-only emails, but they're not meaningful with an ESP # Django SMTP allows username-only emails, but they're not meaningful with an ESP
errmsg = "Invalid email address '%s' parsed from '%s'." % ( 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): if len(parsed) > len(address_list):
errmsg += " (Maybe missing quotes around a display-name?)" errmsg += " (Maybe missing quotes around a display-name?)"
raise AnymailInvalidAddress(errmsg) raise AnymailInvalidAddress(errmsg)
@@ -160,46 +161,46 @@ def parse_address_list(address_list):
return parsed return parsed
class ParsedEmail(object): class EmailAddress(object):
"""A sanitized, complete email address with separate name and email properties. """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: Instance properties, all read-only:
:ivar str name: :ivar str display_name:
the address's display-name portion (unqouted, unescaped), the address's display-name portion (unqouted, unescaped),
e.g., 'Display Name, Inc.' e.g., 'Display Name, Inc.'
:ivar str email: :ivar str addr_spec:
the address's addr-spec portion (unquoted, unescaped), the address's addr-spec portion (unquoted, unescaped),
e.g., 'user@example.com' 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: :ivar str address:
the fully-formatted address, with any necessary quoting and escaping, the fully-formatted address, with any necessary quoting and escaping,
e.g., '"Display Name, Inc." <user@example.com>' e.g., '"Display Name, Inc." <user@example.com>'
:ivar str localpart: (also available as `str(EmailAddress)`)
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): def __init__(self, display_name='', addr_spec=None):
"""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._address = None # lazy formatted address
self.name, self.email = name_email_pair if addr_spec is None:
try: try:
self.localpart, self.domain = self.email.split("@", 1) display_name, addr_spec = display_name # unpack (name,addr) tuple
except ValueError: except ValueError:
self.localpart = self.email pass
self.display_name = display_name
self.addr_spec = addr_spec
try:
self.username, self.domain = addr_spec.split("@", 1)
# do we need to unquote username?
except ValueError:
self.username = addr_spec
self.domain = '' self.domain = ''
@property @property
@@ -215,7 +216,7 @@ class ParsedEmail(object):
"""Return a fully-formatted email address, using encoding. """Return a fully-formatted email address, using encoding.
This is essentially the same as :func:`email.utils.formataddr` 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` Django's :func:`~django.core.mail.message.sanitize_address`
for improved PY2/3 compatibility, consistent handling of for improved PY2/3 compatibility, consistent handling of
encoding (a.k.a. charset), and proper handling of IDN 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' default None uses ascii if possible, else 'utf-8'
(quoted-printable utf-8/base64) (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): def __str__(self):
return self.address 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): class Attachment(object):
"""A normalized EmailMessage.attachments item with additional functionality """A normalized EmailMessage.attachments item with additional functionality

View File

@@ -19,7 +19,7 @@ except ImportError:
from anymail.exceptions import AnymailInvalidAddress from anymail.exceptions import AnymailInvalidAddress
from anymail.utils import ( 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, is_lazy, force_non_lazy, force_non_lazy_dict, force_non_lazy_list,
update_deep, update_deep,
get_request_uri, get_request_basic_auth, parse_rfc2822date) get_request_uri, get_request_basic_auth, parse_rfc2822date)
@@ -32,21 +32,21 @@ class ParseAddressListTests(SimpleTestCase):
parsed_list = parse_address_list(["test@example.com"]) parsed_list = parse_address_list(["test@example.com"])
self.assertEqual(len(parsed_list), 1) self.assertEqual(len(parsed_list), 1)
parsed = parsed_list[0] parsed = parsed_list[0]
self.assertIsInstance(parsed, ParsedEmail) self.assertIsInstance(parsed, EmailAddress)
self.assertEqual(parsed.email, "test@example.com") self.assertEqual(parsed.addr_spec, "test@example.com")
self.assertEqual(parsed.name, "") self.assertEqual(parsed.display_name, "")
self.assertEqual(parsed.address, "test@example.com") self.assertEqual(parsed.address, "test@example.com")
self.assertEqual(parsed.localpart, "test") self.assertEqual(parsed.username, "test")
self.assertEqual(parsed.domain, "example.com") self.assertEqual(parsed.domain, "example.com")
def test_display_name(self): def test_display_name(self):
parsed_list = parse_address_list(['"Display Name, Inc." <test@example.com>']) parsed_list = parse_address_list(['"Display Name, Inc." <test@example.com>'])
self.assertEqual(len(parsed_list), 1) self.assertEqual(len(parsed_list), 1)
parsed = parsed_list[0] parsed = parsed_list[0]
self.assertEqual(parsed.email, "test@example.com") self.assertEqual(parsed.addr_spec, "test@example.com")
self.assertEqual(parsed.name, "Display Name, Inc.") self.assertEqual(parsed.display_name, "Display Name, Inc.")
self.assertEqual(parsed.address, '"Display Name, Inc." <test@example.com>') self.assertEqual(parsed.address, '"Display Name, Inc." <test@example.com>')
self.assertEqual(parsed.localpart, "test") self.assertEqual(parsed.username, "test")
self.assertEqual(parsed.domain, "example.com") self.assertEqual(parsed.domain, "example.com")
def test_obsolete_display_name(self): def test_obsolete_display_name(self):
@@ -55,16 +55,16 @@ class ParseAddressListTests(SimpleTestCase):
parsed_list = parse_address_list(['Display Name <test@example.com>']) parsed_list = parse_address_list(['Display Name <test@example.com>'])
self.assertEqual(len(parsed_list), 1) self.assertEqual(len(parsed_list), 1)
parsed = parsed_list[0] parsed = parsed_list[0]
self.assertEqual(parsed.email, "test@example.com") self.assertEqual(parsed.addr_spec, "test@example.com")
self.assertEqual(parsed.name, "Display Name") self.assertEqual(parsed.display_name, "Display Name")
self.assertEqual(parsed.address, 'Display Name <test@example.com>') self.assertEqual(parsed.address, 'Display Name <test@example.com>')
def test_unicode_display_name(self): def test_unicode_display_name(self):
parsed_list = parse_address_list([u'"Unicode \N{HEAVY BLACK HEART}" <test@example.com>']) parsed_list = parse_address_list([u'"Unicode \N{HEAVY BLACK HEART}" <test@example.com>'])
self.assertEqual(len(parsed_list), 1) self.assertEqual(len(parsed_list), 1)
parsed = parsed_list[0] parsed = parsed_list[0]
self.assertEqual(parsed.email, "test@example.com") self.assertEqual(parsed.addr_spec, "test@example.com")
self.assertEqual(parsed.name, u"Unicode \N{HEAVY BLACK HEART}") self.assertEqual(parsed.display_name, u"Unicode \N{HEAVY BLACK HEART}")
# formatted 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>') self.assertEqual(parsed.address, '=?utf-8?b?VW5pY29kZSDinaQ=?= <test@example.com>')
@@ -80,9 +80,9 @@ class ParseAddressListTests(SimpleTestCase):
parsed_list = parse_address_list([u"idn@\N{ENVELOPE}.example.com"]) parsed_list = parse_address_list([u"idn@\N{ENVELOPE}.example.com"])
self.assertEqual(len(parsed_list), 1) self.assertEqual(len(parsed_list), 1)
parsed = parsed_list[0] 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.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") self.assertEqual(parsed.domain, u"\N{ENVELOPE}.example.com")
def test_none_address(self): def test_none_address(self):
@@ -113,8 +113,8 @@ class ParseAddressListTests(SimpleTestCase):
def test_email_list(self): def test_email_list(self):
parsed_list = parse_address_list(["first@example.com", "second@example.com"]) parsed_list = parse_address_list(["first@example.com", "second@example.com"])
self.assertEqual(len(parsed_list), 2) self.assertEqual(len(parsed_list), 2)
self.assertEqual(parsed_list[0].email, "first@example.com") self.assertEqual(parsed_list[0].addr_spec, "first@example.com")
self.assertEqual(parsed_list[1].email, "second@example.com") self.assertEqual(parsed_list[1].addr_spec, "second@example.com")
def test_multiple_emails(self): def test_multiple_emails(self):
# Django's EmailMessage allows multiple, comma-separated emails # Django's EmailMessage allows multiple, comma-separated emails
@@ -122,8 +122,8 @@ class ParseAddressListTests(SimpleTestCase):
# (Depending on this behavior is not recommended.) # (Depending on this behavior is not recommended.)
parsed_list = parse_address_list(["first@example.com, second@example.com"]) parsed_list = parse_address_list(["first@example.com, second@example.com"])
self.assertEqual(len(parsed_list), 2) self.assertEqual(len(parsed_list), 2)
self.assertEqual(parsed_list[0].email, "first@example.com") self.assertEqual(parsed_list[0].addr_spec, "first@example.com")
self.assertEqual(parsed_list[1].email, "second@example.com") self.assertEqual(parsed_list[1].addr_spec, "second@example.com")
def test_invalid_in_list(self): def test_invalid_in_list(self):
# Make sure it's not just concatenating list items... # 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 # bare strings are used by the from_email parsing in BasePayload
parsed_list = parse_address_list("one@example.com") parsed_list = parse_address_list("one@example.com")
self.assertEqual(len(parsed_list), 1) 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): def test_lazy_strings(self):
parsed_list = parse_address_list([ugettext_lazy('"Example, Inc." <one@example.com>')]) parsed_list = parse_address_list([ugettext_lazy('"Example, Inc." <one@example.com>')])
self.assertEqual(len(parsed_list), 1) self.assertEqual(len(parsed_list), 1)
self.assertEqual(parsed_list[0].name, "Example, Inc.") self.assertEqual(parsed_list[0].display_name, "Example, Inc.")
self.assertEqual(parsed_list[0].email, "one@example.com") self.assertEqual(parsed_list[0].addr_spec, "one@example.com")
parsed_list = parse_address_list(ugettext_lazy("one@example.com")) parsed_list = parse_address_list(ugettext_lazy("one@example.com"))
self.assertEqual(len(parsed_list), 1) self.assertEqual(len(parsed_list), 1)
self.assertEqual(parsed_list[0].name, "") self.assertEqual(parsed_list[0].display_name, "")
self.assertEqual(parsed_list[0].email, "one@example.com") self.assertEqual(parsed_list[0].addr_spec, "one@example.com")
class LazyCoercionTests(SimpleTestCase): class LazyCoercionTests(SimpleTestCase):