Skip to content

Commit 5598c87

Browse files
committed
Backends: identify source of problem in AnymailInvalidAddress message
Include the name of the field with the the unparsable email address in AnymailInvalidAddress error messages. Should help tracking down problems like in #98.
1 parent f0d744a commit 5598c87

File tree

3 files changed

+55
-11
lines changed

3 files changed

+55
-11
lines changed

anymail/backends/base.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,11 @@ def __init__(self, message, defaults, backend):
268268
if converter is not None:
269269
if not callable(converter):
270270
converter = getattr(self, converter)
271-
value = converter(value)
271+
if converter in (parse_address_list, parse_single_address):
272+
# hack to include field name in error message
273+
value = converter(value, field=attr)
274+
else:
275+
value = converter(value)
272276
if value is not UNSET:
273277
if attr == 'body':
274278
setter = self.set_html_body if message.content_subtype == 'html' else self.set_text_body
@@ -296,16 +300,16 @@ def process_extra_headers(self, headers):
296300
# message.extra_headers['Reply-To'] will override message.reply_to
297301
# (because the extra_headers attr is processed after reply_to).
298302
# This matches the behavior of Django's EmailMessage.message().
299-
self.set_reply_to(parse_address_list([reply_to]))
303+
self.set_reply_to(parse_address_list([reply_to], field="extra_headers['Reply-To']"))
300304

301305
if 'From' in headers:
302306
# If message.extra_headers['From'] is supplied, it should override message.from_email,
303307
# but message.from_email should be used as the envelope_sender. See:
304308
# - https://code.djangoproject.com/ticket/9214
305309
# - https://github.com/django/django/blob/1.8/django/core/mail/message.py#L269
306310
# - https://github.com/django/django/blob/1.8/django/core/mail/backends/smtp.py#L118
307-
header_from = parse_address_list(headers.pop('From'))
308-
envelope_sender = parse_single_address(self.message.from_email) # must be single address
311+
header_from = parse_address_list(headers.pop('From'), field="extra_headers['From']")
312+
envelope_sender = parse_single_address(self.message.from_email, field="from_email") # must be single
309313
self.set_from_email_list(header_from)
310314
self.set_envelope_sender(envelope_sender)
311315

anymail/utils.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ def update_deep(dct, other):
116116
# (like dict.update(), no return value)
117117

118118

119-
def parse_address_list(address_list):
119+
def parse_address_list(address_list, field=None):
120120
"""Returns a list of EmailAddress objects from strings in address_list.
121121
122122
Essentially wraps :func:`email.utils.getaddresses` with better error
@@ -127,6 +127,8 @@ def parse_address_list(address_list):
127127
128128
:param list[str]|str|None|list[None] address_list:
129129
the address or addresses to parse
130+
:param str|None field:
131+
optional description of the source of these addresses, for error message
130132
:return list[:class:`EmailAddress`]:
131133
:raises :exc:`AnymailInvalidAddress`:
132134
"""
@@ -151,26 +153,32 @@ def parse_address_list(address_list):
151153
for address in parsed:
152154
if address.username == '' or address.domain == '':
153155
# Django SMTP allows username-only emails, but they're not meaningful with an ESP
154-
errmsg = "Invalid email address '%s' parsed from '%s'." % (
155-
address.addr_spec, ", ".join(address_list_strings))
156+
errmsg = "Invalid email address '{problem}' parsed from '{source}'{where}.".format(
157+
problem=address.addr_spec,
158+
source=", ".join(address_list_strings),
159+
where=" in `%s`" % field if field else "",
160+
)
156161
if len(parsed) > len(address_list):
157162
errmsg += " (Maybe missing quotes around a display-name?)"
158163
raise AnymailInvalidAddress(errmsg)
159164

160165
return parsed
161166

162167

163-
def parse_single_address(address):
168+
def parse_single_address(address, field=None):
164169
"""Parses a single EmailAddress from str address, or raises AnymailInvalidAddress
165170
166171
:param str address: the fully-formatted email str to parse
172+
:param str|None field: optional description of the source of this address, for error message
167173
:return :class:`EmailAddress`: if address contains a single email
168174
:raises :exc:`AnymailInvalidAddress`: if address contains no or multiple emails
169175
"""
170-
parsed = parse_address_list([address])
176+
parsed = parse_address_list([address], field=field)
171177
count = len(parsed)
172178
if count > 1:
173-
raise AnymailInvalidAddress("Only one email address is allowed; found %d in %r" % (count, address))
179+
raise AnymailInvalidAddress(
180+
"Only one email address is allowed; found {count} in '{address}'{where}.".format(
181+
count=count, address=address, where=" in `%s`" % field if field else ""))
174182
else:
175183
return parsed[0]
176184

tests/test_general_backend.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from django.utils.timezone import utc
1212
from django.utils.translation import ugettext_lazy
1313

14-
from anymail.exceptions import AnymailConfigurationError, AnymailUnsupportedFeature
14+
from anymail.exceptions import AnymailConfigurationError, AnymailInvalidAddress, AnymailUnsupportedFeature
1515
from anymail.message import AnymailMessage
1616

1717
from .utils import AnymailTestMixin
@@ -319,6 +319,38 @@ def test_explains_reply_to_must_be_list_lazy(self):
319319
with self.assertRaisesMessage(TypeError, '"reply_to" attribute must be a list or other iterable'):
320320
self.message.send()
321321

322+
def test_identifies_source_of_parsing_errors(self):
323+
"""Errors parsing email addresses should say which field had the problem"""
324+
# Note: General email address parsing tests are in test_utils.ParseAddressListTests.
325+
# This just checks the error includes the field name when parsing for sending a message.
326+
self.message.from_email = ''
327+
with self.assertRaisesMessage(AnymailInvalidAddress,
328+
"Invalid email address '' parsed from '' in `from_email`."):
329+
self.message.send()
330+
self.message.from_email = '[email protected]'
331+
332+
# parse_address_list
333+
self.message.to = ['[email protected]', 'oops']
334+
with self.assertRaisesMessage(AnymailInvalidAddress,
335+
"Invalid email address 'oops' parsed from '[email protected], oops' in `to`."):
336+
self.message.send()
337+
self.message.to = ['[email protected]']
338+
339+
# parse_single_address
340+
self.message.envelope_sender = '[email protected], [email protected]'
341+
with self.assertRaisesMessage(AnymailInvalidAddress,
342+
"Only one email address is allowed; found 2"
343+
" in '[email protected], [email protected]' in `envelope_sender`."):
344+
self.message.send()
345+
delattr(self.message, 'envelope_sender')
346+
347+
# process_extra_headers
348+
self.message.extra_headers['From'] = 'Mail, Inc. <[email protected]>'
349+
with self.assertRaisesMessage(AnymailInvalidAddress,
350+
"Invalid email address 'Mail' parsed from 'Mail, Inc. <[email protected]>'"
351+
" in `extra_headers['From']`. (Maybe missing quotes around a display-name?)"):
352+
self.message.send()
353+
322354

323355
def flatten_emails(emails):
324356
return [str(email) for email in emails]

0 commit comments

Comments
 (0)