Skip to content

Commit 3866689

Browse files
committed
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.)
1 parent fe097ce commit 3866689

File tree

3 files changed

+79
-62
lines changed

3 files changed

+79
-62
lines changed

anymail/backends/mailjet.py

+6-6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from ..exceptions import AnymailRequestsAPIError
22
from ..message import AnymailRecipientStatus, ANYMAIL_STATUSES
3-
from ..utils import get_anymail_setting, ParsedEmail, parse_address_list
3+
from ..utils import get_anymail_setting, EmailAddress, parse_address_list
44

55
from .base_requests import AnymailRequestsBackend, RequestsPayload
66

@@ -127,11 +127,11 @@ def _populate_sender_from_template(self):
127127
# if there's a comma in the template's From display-name:
128128
from_email = headers["From"].replace(",", "||COMMA||")
129129
parsed = parse_address_list([from_email])[0]
130-
if parsed.name:
131-
parsed.name = parsed.name.replace("||COMMA||", ",")
130+
if parsed.display_name:
131+
parsed = EmailAddress(parsed.display_name.replace("||COMMA||", ","),
132+
parsed.addr_spec)
132133
else:
133-
name_addr = (headers["SenderName"], headers["SenderEmail"])
134-
parsed = ParsedEmail(name_addr)
134+
parsed = EmailAddress(headers["SenderName"], headers["SenderEmail"])
135135
except KeyError:
136136
raise AnymailRequestsAPIError("Invalid Mailjet template API response",
137137
email_message=self.message, response=response, backend=self.backend)
@@ -165,7 +165,7 @@ def _finish_recipients_single(self):
165165
formatted_emails = [
166166
email.address if "," not in email.name
167167
# else name has a comma, so force it into MIME encoded-word utf-8 syntax:
168-
else ParsedEmail((email.name.encode('utf-8'), email.email)).formataddr('utf-8')
168+
else EmailAddress(email.name.encode('utf-8'), email.email).formataddr('utf-8')
169169
for email in emails
170170
]
171171
self.data[recipient_type.capitalize()] = ", ".join(formatted_emails)

anymail/utils.py

+50-33
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ def update_deep(dct, other):
118118

119119

120120
def parse_address_list(address_list):
121-
"""Returns a list of ParsedEmail objects from strings in address_list.
121+
"""Returns a list of EmailAddress objects from strings in address_list.
122122
123123
Essentially wraps :func:`email.utils.getaddresses` with better error
124124
messaging and more-useful output objects
@@ -128,7 +128,7 @@ def parse_address_list(address_list):
128128
129129
:param list[str]|str|None|list[None] address_list:
130130
the address or addresses to parse
131-
:return list[:class:`ParsedEmail`]:
131+
:return list[:class:`EmailAddress`]:
132132
:raises :exc:`AnymailInvalidAddress`:
133133
"""
134134
if isinstance(address_list, six.string_types) or is_lazy(address_list):
@@ -145,61 +145,62 @@ def parse_address_list(address_list):
145145
name_email_pairs = getaddresses(address_list_strings)
146146
if name_email_pairs == [] and address_list_strings == [""]:
147147
name_email_pairs = [('', '')] # getaddresses ignores a single empty string
148-
parsed = [ParsedEmail(name_email_pair) for name_email_pair in name_email_pairs]
148+
parsed = [EmailAddress(display_name=name, addr_spec=email)
149+
for (name, email) in name_email_pairs]
149150

150151
# Sanity-check, and raise useful errors
151152
for address in parsed:
152-
if address.localpart == '' or address.domain == '':
153-
# Django SMTP allows localpart-only emails, but they're not meaningful with an ESP
153+
if address.username == '' or address.domain == '':
154+
# Django SMTP allows username-only emails, but they're not meaningful with an ESP
154155
errmsg = "Invalid email address '%s' parsed from '%s'." % (
155-
address.email, ", ".join(address_list_strings))
156+
address.addr_spec, ", ".join(address_list_strings))
156157
if len(parsed) > len(address_list):
157158
errmsg += " (Maybe missing quotes around a display-name?)"
158159
raise AnymailInvalidAddress(errmsg)
159160

160161
return parsed
161162

162163

163-
class ParsedEmail(object):
164-
"""A sanitized, complete email address with separate name and email properties.
164+
class EmailAddress(object):
165+
"""A sanitized, complete email address with easy access
166+
to display-name, addr-spec (email), etc.
165167
166-
(Intended for Anymail internal use.)
168+
Similar to Python 3.6+ email.headerregistry.Address
167169
168170
Instance properties, all read-only:
169-
:ivar str name:
171+
:ivar str display_name:
170172
the address's display-name portion (unqouted, unescaped),
171173
e.g., 'Display Name, Inc.'
172-
:ivar str email:
174+
:ivar str addr_spec:
173175
the address's addr-spec portion (unquoted, unescaped),
174176
175-
:ivar str address:
176-
the fully-formatted address, with any necessary quoting and escaping,
177-
e.g., '"Display Name, Inc." <[email protected]>'
178-
:ivar str localpart:
179-
the local part (before the '@') of email,
177+
:ivar str username:
178+
the local part (before the '@') of the addr-spec,
180179
e.g., 'user'
181180
:ivar str domain:
182-
the domain part (after the '@') of email,
181+
the domain part (after the '@') of the addr-spec,
183182
e.g., 'example.com'
184-
"""
185-
186-
def __init__(self, name_email_pair):
187-
"""Construct a ParsedEmail.
188183
189-
You generally should use :func:`parse_address_list` rather than creating
190-
ParsedEmail objects directly.
184+
:ivar str address:
185+
the fully-formatted address, with any necessary quoting and escaping,
186+
e.g., '"Display Name, Inc." <[email protected]>'
187+
(also available as `str(EmailAddress)`)
188+
"""
191189

192-
:param tuple(str, str) name_email_pair:
193-
the display-name and addr-spec (both unquoted) for the address,
194-
as returned by :func:`email.utils.parseaddr` and
195-
:func:`email.utils.getaddresses`
196-
"""
190+
def __init__(self, display_name='', addr_spec=None):
197191
self._address = None # lazy formatted address
198-
self.name, self.email = name_email_pair
192+
if addr_spec is None:
193+
try:
194+
display_name, addr_spec = display_name # unpack (name,addr) tuple
195+
except ValueError:
196+
pass
197+
self.display_name = display_name
198+
self.addr_spec = addr_spec
199199
try:
200-
self.localpart, self.domain = self.email.split("@", 1)
200+
self.username, self.domain = addr_spec.split("@", 1)
201+
# do we need to unquote username?
201202
except ValueError:
202-
self.localpart = self.email
203+
self.username = addr_spec
203204
self.domain = ''
204205

205206
@property
@@ -215,7 +216,7 @@ def formataddr(self, encoding=None):
215216
"""Return a fully-formatted email address, using encoding.
216217
217218
This is essentially the same as :func:`email.utils.formataddr`
218-
on the ParsedEmail's name and email properties, but uses
219+
on the EmailAddress's name and email properties, but uses
219220
Django's :func:`~django.core.mail.message.sanitize_address`
220221
for improved PY2/3 compatibility, consistent handling of
221222
encoding (a.k.a. charset), and proper handling of IDN
@@ -226,11 +227,27 @@ def formataddr(self, encoding=None):
226227
default None uses ascii if possible, else 'utf-8'
227228
(quoted-printable utf-8/base64)
228229
"""
229-
return sanitize_address((self.name, self.email), encoding)
230+
return sanitize_address((self.display_name, self.addr_spec), encoding)
230231

231232
def __str__(self):
232233
return self.address
233234

235+
# Deprecated property names from old ParsedEmail (don't use in new code!)
236+
@property
237+
def name(self):
238+
return self.display_name
239+
240+
@property
241+
def email(self):
242+
return self.addr_spec
243+
244+
@property
245+
def localpart(self):
246+
return self.username
247+
248+
249+
ParsedEmail = EmailAddress # deprecated class name (don't use!)
250+
234251

235252
class Attachment(object):
236253
"""A normalized EmailMessage.attachments item with additional functionality

tests/test_utils.py

+23-23
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
from anymail.exceptions import AnymailInvalidAddress
2121
from anymail.utils import (
22-
parse_address_list, ParsedEmail,
22+
parse_address_list, EmailAddress,
2323
is_lazy, force_non_lazy, force_non_lazy_dict, force_non_lazy_list,
2424
update_deep,
2525
get_request_uri, get_request_basic_auth, parse_rfc2822date)
@@ -32,21 +32,21 @@ def test_simple_email(self):
3232
parsed_list = parse_address_list(["[email protected]"])
3333
self.assertEqual(len(parsed_list), 1)
3434
parsed = parsed_list[0]
35-
self.assertIsInstance(parsed, ParsedEmail)
36-
self.assertEqual(parsed.email, "[email protected]")
37-
self.assertEqual(parsed.name, "")
35+
self.assertIsInstance(parsed, EmailAddress)
36+
self.assertEqual(parsed.addr_spec, "[email protected]")
37+
self.assertEqual(parsed.display_name, "")
3838
self.assertEqual(parsed.address, "[email protected]")
39-
self.assertEqual(parsed.localpart, "test")
39+
self.assertEqual(parsed.username, "test")
4040
self.assertEqual(parsed.domain, "example.com")
4141

4242
def test_display_name(self):
4343
parsed_list = parse_address_list(['"Display Name, Inc." <[email protected]>'])
4444
self.assertEqual(len(parsed_list), 1)
4545
parsed = parsed_list[0]
46-
self.assertEqual(parsed.email, "[email protected]")
47-
self.assertEqual(parsed.name, "Display Name, Inc.")
46+
self.assertEqual(parsed.addr_spec, "[email protected]")
47+
self.assertEqual(parsed.display_name, "Display Name, Inc.")
4848
self.assertEqual(parsed.address, '"Display Name, Inc." <[email protected]>')
49-
self.assertEqual(parsed.localpart, "test")
49+
self.assertEqual(parsed.username, "test")
5050
self.assertEqual(parsed.domain, "example.com")
5151

5252
def test_obsolete_display_name(self):
@@ -55,16 +55,16 @@ def test_obsolete_display_name(self):
5555
parsed_list = parse_address_list(['Display Name <[email protected]>'])
5656
self.assertEqual(len(parsed_list), 1)
5757
parsed = parsed_list[0]
58-
self.assertEqual(parsed.email, "[email protected]")
59-
self.assertEqual(parsed.name, "Display Name")
58+
self.assertEqual(parsed.addr_spec, "[email protected]")
59+
self.assertEqual(parsed.display_name, "Display Name")
6060
self.assertEqual(parsed.address, 'Display Name <[email protected]>')
6161

6262
def test_unicode_display_name(self):
6363
parsed_list = parse_address_list([u'"Unicode \N{HEAVY BLACK HEART}" <[email protected]>'])
6464
self.assertEqual(len(parsed_list), 1)
6565
parsed = parsed_list[0]
66-
self.assertEqual(parsed.email, "[email protected]")
67-
self.assertEqual(parsed.name, u"Unicode \N{HEAVY BLACK HEART}")
66+
self.assertEqual(parsed.addr_spec, "[email protected]")
67+
self.assertEqual(parsed.display_name, u"Unicode \N{HEAVY BLACK HEART}")
6868
# formatted display-name automatically shifts to quoted-printable/base64 for non-ascii chars:
6969
self.assertEqual(parsed.address, '=?utf-8?b?VW5pY29kZSDinaQ=?= <[email protected]>')
7070

@@ -80,9 +80,9 @@ def test_idn(self):
8080
parsed_list = parse_address_list([u"idn@\N{ENVELOPE}.example.com"])
8181
self.assertEqual(len(parsed_list), 1)
8282
parsed = parsed_list[0]
83-
self.assertEqual(parsed.email, u"idn@\N{ENVELOPE}.example.com")
83+
self.assertEqual(parsed.addr_spec, u"idn@\N{ENVELOPE}.example.com")
8484
self.assertEqual(parsed.address, "[email protected]") # punycode-encoded domain
85-
self.assertEqual(parsed.localpart, "idn")
85+
self.assertEqual(parsed.username, "idn")
8686
self.assertEqual(parsed.domain, u"\N{ENVELOPE}.example.com")
8787

8888
def test_none_address(self):
@@ -113,17 +113,17 @@ def test_invalid_address(self):
113113
def test_email_list(self):
114114
parsed_list = parse_address_list(["[email protected]", "[email protected]"])
115115
self.assertEqual(len(parsed_list), 2)
116-
self.assertEqual(parsed_list[0].email, "[email protected]")
117-
self.assertEqual(parsed_list[1].email, "[email protected]")
116+
self.assertEqual(parsed_list[0].addr_spec, "[email protected]")
117+
self.assertEqual(parsed_list[1].addr_spec, "[email protected]")
118118

119119
def test_multiple_emails(self):
120120
# Django's EmailMessage allows multiple, comma-separated emails
121121
# in a single recipient string. (It passes them along to the backend intact.)
122122
# (Depending on this behavior is not recommended.)
123123
parsed_list = parse_address_list(["[email protected], [email protected]"])
124124
self.assertEqual(len(parsed_list), 2)
125-
self.assertEqual(parsed_list[0].email, "[email protected]")
126-
self.assertEqual(parsed_list[1].email, "[email protected]")
125+
self.assertEqual(parsed_list[0].addr_spec, "[email protected]")
126+
self.assertEqual(parsed_list[1].addr_spec, "[email protected]")
127127

128128
def test_invalid_in_list(self):
129129
# Make sure it's not just concatenating list items...
@@ -136,18 +136,18 @@ def test_single_string(self):
136136
# bare strings are used by the from_email parsing in BasePayload
137137
parsed_list = parse_address_list("[email protected]")
138138
self.assertEqual(len(parsed_list), 1)
139-
self.assertEqual(parsed_list[0].email, "[email protected]")
139+
self.assertEqual(parsed_list[0].addr_spec, "[email protected]")
140140

141141
def test_lazy_strings(self):
142142
parsed_list = parse_address_list([ugettext_lazy('"Example, Inc." <[email protected]>')])
143143
self.assertEqual(len(parsed_list), 1)
144-
self.assertEqual(parsed_list[0].name, "Example, Inc.")
145-
self.assertEqual(parsed_list[0].email, "[email protected]")
144+
self.assertEqual(parsed_list[0].display_name, "Example, Inc.")
145+
self.assertEqual(parsed_list[0].addr_spec, "[email protected]")
146146

147147
parsed_list = parse_address_list(ugettext_lazy("[email protected]"))
148148
self.assertEqual(len(parsed_list), 1)
149-
self.assertEqual(parsed_list[0].name, "")
150-
self.assertEqual(parsed_list[0].email, "[email protected]")
149+
self.assertEqual(parsed_list[0].display_name, "")
150+
self.assertEqual(parsed_list[0].addr_spec, "[email protected]")
151151

152152

153153
class LazyCoercionTests(SimpleTestCase):

0 commit comments

Comments
 (0)