@@ -113,35 +113,116 @@ def update_deep(dct, other):
113
113
# (like dict.update(), no return value)
114
114
115
115
116
- def parse_one_addr (address ):
117
- # This is email.utils.parseaddr, but without silently returning
118
- # partial content if there are commas or parens in the string:
119
- addresses = getaddresses ([address ])
120
- if len (addresses ) > 1 :
121
- raise ValueError ("Multiple email addresses (parses as %r)" % addresses )
122
- elif len (addresses ) == 0 :
123
- return ('' , '' )
124
- return addresses [0 ]
116
+ def parse_address_list (address_list ):
117
+ """Returns a list of ParsedEmail objects from strings in address_list.
118
+
119
+ Essentially wraps :func:`email.utils.getaddresses` with better error
120
+ messaging and more-useful output objects
121
+
122
+ Note that the returned list might be longer than the address_list param,
123
+ if any individual string contains multiple comma-separated addresses.
124
+
125
+ :param list[str]|str|None|list[None] address_list:
126
+ the address or addresses to parse
127
+ :return list[:class:`ParsedEmail`]:
128
+ :raises :exc:`AnymailInvalidAddress`:
129
+ """
130
+ if isinstance (address_list , six .string_types ) or is_lazy (address_list ):
131
+ address_list = [address_list ]
132
+
133
+ if address_list is None or address_list == [None ]:
134
+ return []
135
+
136
+ # For consistency with Django's SMTP backend behavior, extract all addresses
137
+ # from the list -- which may split comma-seperated strings into multiple addresses.
138
+ # (See django.core.mail.message: EmailMessage.message to/cc/bcc/reply_to handling;
139
+ # also logic for ADDRESS_HEADERS in forbid_multi_line_headers.)
140
+ address_list_strings = [force_text (address ) for address in address_list ] # resolve lazy strings
141
+ name_email_pairs = getaddresses (address_list_strings )
142
+ if name_email_pairs == [] and address_list_strings == ["" ]:
143
+ name_email_pairs = [('' , '' )] # getaddresses ignores a single empty string
144
+ parsed = [ParsedEmail (name_email_pair ) for name_email_pair in name_email_pairs ]
145
+
146
+ # Sanity-check, and raise useful errors
147
+ for address in parsed :
148
+ if address .localpart == '' or address .domain == '' :
149
+ # Django SMTP allows localpart-only emails, but they're not meaningful with an ESP
150
+ errmsg = "Invalid email address '%s' parsed from '%s'." % (
151
+ address .email , ", " .join (address_list_strings ))
152
+ if len (parsed ) > len (address_list ):
153
+ errmsg += " (Maybe missing quotes around a display-name?)"
154
+ raise AnymailInvalidAddress (errmsg )
155
+
156
+ return parsed
125
157
126
158
127
159
class ParsedEmail (object ):
128
- """A sanitized, full email address with separate name and email properties."""
160
+ """A sanitized, complete email address with separate name and email properties.
161
+
162
+ (Intended for Anymail internal use.)
163
+
164
+ Instance properties, all read-only:
165
+ :ivar str name:
166
+ the address's display-name portion (unqouted, unescaped),
167
+ e.g., 'Display Name, Inc.'
168
+ :ivar str email:
169
+ the address's addr-spec portion (unquoted, unescaped),
170
+
171
+ :ivar str address:
172
+ the fully-formatted address, with any necessary quoting and escaping,
173
+ e.g., '"Display Name, Inc." <[email protected] >'
174
+ :ivar str localpart:
175
+ the local part (before the '@') of email,
176
+ e.g., 'user'
177
+ :ivar str domain:
178
+ the domain part (after the '@') of email,
179
+ e.g., 'example.com'
180
+ """
129
181
130
- def __init__ (self , address , encoding ):
131
- if address is None :
132
- self .name = self .email = self .address = None
133
- return
182
+ def __init__ (self , name_email_pair ):
183
+ """Construct a ParsedEmail.
184
+
185
+ You generally should use :func:`parse_address_list` rather than creating
186
+ ParsedEmail objects directly.
187
+
188
+ :param tuple(str, str) name_email_pair:
189
+ the display-name and addr-spec (both unquoted) for the address,
190
+ as returned by :func:`email.utils.parseaddr` and
191
+ :func:`email.utils.getaddresses`
192
+ """
193
+ self ._address = None # lazy formatted address
194
+ self .name , self .email = name_email_pair
134
195
try :
135
- self .name , self .email = parse_one_addr (force_text (address ))
136
- if self .email == '' :
137
- # normalize sanitize_address py2/3 behavior:
138
- raise ValueError ('No email found' )
139
- # Django's sanitize_address is like email.utils.formataddr, but also
140
- # escapes as needed for use in email message headers:
141
- self .address = sanitize_address ((self .name , self .email ), encoding )
142
- except (IndexError , TypeError , ValueError ) as err :
143
- raise AnymailInvalidAddress ("Invalid email address format %r: %s"
144
- % (address , str (err )))
196
+ self .localpart , self .domain = self .email .split ("@" , 1 )
197
+ except ValueError :
198
+ self .localpart = self .email
199
+ self .domain = ''
200
+
201
+ @property
202
+ def address (self ):
203
+ if self ._address is None :
204
+ # (you might be tempted to use `encoding=settings.DEFAULT_CHARSET` here,
205
+ # but that always forces the display-name to quoted-printable/base64,
206
+ # even when simple ascii would work fine--and be more readable)
207
+ self ._address = self .formataddr ()
208
+ return self ._address
209
+
210
+ def formataddr (self , encoding = None ):
211
+ """Return a fully-formatted email address, using encoding.
212
+
213
+ This is essentially the same as :func:`email.utils.formataddr`
214
+ on the ParsedEmail's name and email properties, but uses
215
+ Django's :func:`~django.core.mail.message.sanitize_address`
216
+ for improved PY2/3 compatibility, consistent handling of
217
+ encoding (a.k.a. charset), and proper handling of IDN
218
+ domain portions.
219
+
220
+ :param str|None encoding:
221
+ the charset to use for the display-name portion;
222
+ default None uses ascii if possible, else 'utf-8'
223
+ (quoted-printable utf-8/base64)
224
+ """
225
+ return sanitize_address ((self .name , self .email ), encoding )
145
226
146
227
def __str__ (self ):
147
228
return self .address
0 commit comments