mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 03:41:05 -05:00
[RFC-5322 allows](https://tools.ietf.org/html/rfc5322#section-3.6.2) multiple addresses in the From header. Django's SMTP backend supports this, as a single comma-separated string (*not* a list of strings like the recipient params): from_email='one@example.com, two@example.com' to=['one@example.com', 'two@example.com'] Both Mailgun and SparkPost support multiple From addresses (and Postmark accepts them, though truncates to the first one on their end). For compatibility with Django -- and because Anymail attempts to support all ESP features -- Anymail now allows multiple From addresses, too, for ESPs that support it. Note: as a practical matter, deliverability with multiple From addresses is pretty bad. (Google outright rejects them.) This change also reworks Anymail's internal ParsedEmail object, and approach to parsing addresses, for better consistency with Django's SMTP backend and improved error messaging. In particular, Django (and now Anymail) allows multiple email addresses in a single recipient string: to=['one@example.com', 'two@example.com, three@example.com'] len(to) == 2 # but there will be three recipients Fixes #60
465 lines
16 KiB
Python
465 lines
16 KiB
Python
import base64
|
|
import mimetypes
|
|
from base64 import b64encode
|
|
from collections import Mapping, MutableMapping
|
|
from datetime import datetime
|
|
from email.mime.base import MIMEBase
|
|
from email.utils import formatdate, getaddresses, unquote
|
|
from time import mktime
|
|
|
|
import six
|
|
from django.conf import settings
|
|
from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_TYPE
|
|
from django.utils.encoding import force_text
|
|
from django.utils.functional import Promise
|
|
from django.utils.timezone import utc
|
|
# noinspection PyUnresolvedReferences
|
|
from six.moves.urllib.parse import urlsplit, urlunsplit
|
|
|
|
from .exceptions import AnymailConfigurationError, AnymailInvalidAddress
|
|
|
|
UNSET = object() # Used as non-None default value
|
|
|
|
|
|
def combine(*args):
|
|
"""
|
|
Combines all non-UNSET args, by shallow merging mappings and concatenating sequences
|
|
|
|
>>> combine({'a': 1, 'b': 2}, UNSET, {'b': 3, 'c': 4}, UNSET)
|
|
{'a': 1, 'b': 3, 'c': 4}
|
|
>>> combine([1, 2], UNSET, [3, 4], UNSET)
|
|
[1, 2, 3, 4]
|
|
>>> combine({'a': 1}, None, {'b': 2}) # None suppresses earlier args
|
|
{'b': 2}
|
|
>>> combine()
|
|
UNSET
|
|
|
|
"""
|
|
result = UNSET
|
|
for value in args:
|
|
if value is None:
|
|
# None is a request to suppress any earlier values
|
|
result = UNSET
|
|
elif value is not UNSET:
|
|
if result is UNSET:
|
|
try:
|
|
result = value.copy() # will shallow merge if dict-like
|
|
except AttributeError:
|
|
result = value # will concatenate if sequence-like
|
|
else:
|
|
try:
|
|
result.update(value) # shallow merge if dict-like
|
|
except AttributeError:
|
|
result = result + value # concatenate if sequence-like
|
|
return result
|
|
|
|
|
|
def last(*args):
|
|
"""Returns the last of its args which is not UNSET.
|
|
|
|
(Essentially `combine` without the merge behavior)
|
|
|
|
>>> last(1, 2, UNSET, 3, UNSET, UNSET)
|
|
3
|
|
>>> last(1, 2, None, UNSET) # None suppresses earlier args
|
|
UNSET
|
|
>>> last()
|
|
UNSET
|
|
|
|
"""
|
|
for value in reversed(args):
|
|
if value is None:
|
|
# None is a request to suppress any earlier values
|
|
return UNSET
|
|
elif value is not UNSET:
|
|
return value
|
|
return UNSET
|
|
|
|
|
|
def getfirst(dct, keys, default=UNSET):
|
|
"""Returns the value of the first of keys found in dict dct.
|
|
|
|
>>> getfirst({'a': 1, 'b': 2}, ['c', 'a'])
|
|
1
|
|
>>> getfirst({'a': 1, 'b': 2}, ['b', 'a'])
|
|
2
|
|
>>> getfirst({'a': 1, 'b': 2}, ['c'])
|
|
KeyError
|
|
>>> getfirst({'a': 1, 'b': 2}, ['c'], None)
|
|
None
|
|
"""
|
|
for key in keys:
|
|
try:
|
|
return dct[key]
|
|
except KeyError:
|
|
pass
|
|
if default is UNSET:
|
|
raise KeyError("None of %s found in dict" % ', '.join(keys))
|
|
else:
|
|
return default
|
|
|
|
|
|
def update_deep(dct, other):
|
|
"""Merge (recursively) keys and values from dict other into dict dct
|
|
|
|
Works with dict-like objects: dct (and descendants) can be any MutableMapping,
|
|
and other can be any Mapping
|
|
"""
|
|
for key, value in other.items():
|
|
if key in dct and isinstance(dct[key], MutableMapping) and isinstance(value, Mapping):
|
|
update_deep(dct[key], value)
|
|
else:
|
|
dct[key] = value
|
|
# (like dict.update(), no return value)
|
|
|
|
|
|
def parse_address_list(address_list):
|
|
"""Returns a list of ParsedEmail objects from strings in address_list.
|
|
|
|
Essentially wraps :func:`email.utils.getaddresses` with better error
|
|
messaging and more-useful output objects
|
|
|
|
Note that the returned list might be longer than the address_list param,
|
|
if any individual string contains multiple comma-separated addresses.
|
|
|
|
:param list[str]|str|None|list[None] address_list:
|
|
the address or addresses to parse
|
|
:return list[:class:`ParsedEmail`]:
|
|
:raises :exc:`AnymailInvalidAddress`:
|
|
"""
|
|
if isinstance(address_list, six.string_types) or is_lazy(address_list):
|
|
address_list = [address_list]
|
|
|
|
if address_list is None or address_list == [None]:
|
|
return []
|
|
|
|
# For consistency with Django's SMTP backend behavior, extract all addresses
|
|
# from the list -- which may split comma-seperated strings into multiple addresses.
|
|
# (See django.core.mail.message: EmailMessage.message to/cc/bcc/reply_to handling;
|
|
# also logic for ADDRESS_HEADERS in forbid_multi_line_headers.)
|
|
address_list_strings = [force_text(address) for address in address_list] # resolve lazy strings
|
|
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]
|
|
|
|
# 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
|
|
errmsg = "Invalid email address '%s' parsed from '%s'." % (
|
|
address.email, ", ".join(address_list_strings))
|
|
if len(parsed) > len(address_list):
|
|
errmsg += " (Maybe missing quotes around a display-name?)"
|
|
raise AnymailInvalidAddress(errmsg)
|
|
|
|
return parsed
|
|
|
|
|
|
class ParsedEmail(object):
|
|
"""A sanitized, complete email address with separate name and email properties.
|
|
|
|
(Intended for Anymail internal use.)
|
|
|
|
Instance properties, all read-only:
|
|
:ivar str name:
|
|
the address's display-name portion (unqouted, unescaped),
|
|
e.g., 'Display Name, Inc.'
|
|
:ivar str email:
|
|
the address's addr-spec portion (unquoted, unescaped),
|
|
e.g., 'user@example.com'
|
|
:ivar str address:
|
|
the fully-formatted address, with any necessary quoting and escaping,
|
|
e.g., '"Display Name, Inc." <user@example.com>'
|
|
: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'
|
|
"""
|
|
|
|
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`
|
|
"""
|
|
self._address = None # lazy formatted address
|
|
self.name, self.email = name_email_pair
|
|
try:
|
|
self.localpart, self.domain = self.email.split("@", 1)
|
|
except ValueError:
|
|
self.localpart = self.email
|
|
self.domain = ''
|
|
|
|
@property
|
|
def address(self):
|
|
if self._address is None:
|
|
# (you might be tempted to use `encoding=settings.DEFAULT_CHARSET` here,
|
|
# but that always forces the display-name to quoted-printable/base64,
|
|
# even when simple ascii would work fine--and be more readable)
|
|
self._address = self.formataddr()
|
|
return self._address
|
|
|
|
def formataddr(self, encoding=None):
|
|
"""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
|
|
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
|
|
domain portions.
|
|
|
|
:param str|None encoding:
|
|
the charset to use for the display-name portion;
|
|
default None uses ascii if possible, else 'utf-8'
|
|
(quoted-printable utf-8/base64)
|
|
"""
|
|
return sanitize_address((self.name, self.email), encoding)
|
|
|
|
def __str__(self):
|
|
return self.address
|
|
|
|
|
|
class Attachment(object):
|
|
"""A normalized EmailMessage.attachments item with additional functionality
|
|
|
|
Normalized to have these properties:
|
|
name: attachment filename; may be None
|
|
content: bytestream
|
|
mimetype: the content type; guessed if not explicit
|
|
inline: bool, True if attachment has a Content-ID header
|
|
content_id: for inline, the Content-ID (*with* <>); may be None
|
|
cid: for inline, the Content-ID *without* <>; may be empty string
|
|
"""
|
|
|
|
def __init__(self, attachment, encoding):
|
|
# Note that an attachment can be either a tuple of (filename, content, mimetype)
|
|
# or a MIMEBase object. (Also, both filename and mimetype may be missing.)
|
|
self._attachment = attachment
|
|
self.encoding = encoding # should we be checking attachment["Content-Encoding"] ???
|
|
self.inline = False
|
|
self.content_id = None
|
|
self.cid = ""
|
|
|
|
if isinstance(attachment, MIMEBase):
|
|
self.name = attachment.get_filename()
|
|
self.content = attachment.get_payload(decode=True)
|
|
if self.content is None:
|
|
if hasattr(attachment, 'as_bytes'):
|
|
self.content = attachment.as_bytes()
|
|
else:
|
|
# Python 2.7 fallback
|
|
self.content = attachment.as_string().encode(self.encoding)
|
|
self.mimetype = attachment.get_content_type()
|
|
|
|
if get_content_disposition(attachment) == 'inline':
|
|
self.inline = True
|
|
self.content_id = attachment["Content-ID"] # probably including the <...>
|
|
if self.content_id is not None:
|
|
self.cid = unquote(self.content_id) # without the <, >
|
|
else:
|
|
(self.name, self.content, self.mimetype) = attachment
|
|
|
|
self.name = force_non_lazy(self.name)
|
|
self.content = force_non_lazy(self.content)
|
|
|
|
# Guess missing mimetype from filename, borrowed from
|
|
# django.core.mail.EmailMessage._create_attachment()
|
|
if self.mimetype is None and self.name is not None:
|
|
self.mimetype, _ = mimetypes.guess_type(self.name)
|
|
if self.mimetype is None:
|
|
self.mimetype = DEFAULT_ATTACHMENT_MIME_TYPE
|
|
|
|
@property
|
|
def b64content(self):
|
|
"""Content encoded as a base64 ascii string"""
|
|
content = self.content
|
|
if isinstance(content, six.text_type):
|
|
content = content.encode(self.encoding)
|
|
return b64encode(content).decode("ascii")
|
|
|
|
|
|
def get_content_disposition(mimeobj):
|
|
"""Return the message's content-disposition if it exists, or None.
|
|
|
|
Backport of py3.5 :func:`~email.message.Message.get_content_disposition`
|
|
"""
|
|
value = mimeobj.get('content-disposition')
|
|
if value is None:
|
|
return None
|
|
# _splitparam(value)[0].lower() :
|
|
return str(value).partition(';')[0].strip().lower()
|
|
|
|
|
|
def get_anymail_setting(name, default=UNSET, esp_name=None, kwargs=None, allow_bare=False):
|
|
"""Returns an Anymail option from kwargs or Django settings.
|
|
|
|
Returns first of:
|
|
- kwargs[name] -- e.g., kwargs['api_key'] -- and name key will be popped from kwargs
|
|
- settings.ANYMAIL['<ESP_NAME>_<NAME>'] -- e.g., settings.ANYMAIL['MAILGUN_API_KEY']
|
|
- settings.ANYMAIL_<ESP_NAME>_<NAME> -- e.g., settings.ANYMAIL_MAILGUN_API_KEY
|
|
- settings.<ESP_NAME>_<NAME> (only if allow_bare) -- e.g., settings.MAILGUN_API_KEY
|
|
- default if provided; else raises AnymailConfigurationError
|
|
|
|
If allow_bare, allows settings.<ESP_NAME>_<NAME> without the ANYMAIL_ prefix:
|
|
ANYMAIL = { "MAILGUN_API_KEY": "xyz", ... }
|
|
ANYMAIL_MAILGUN_API_KEY = "xyz"
|
|
MAILGUN_API_KEY = "xyz"
|
|
"""
|
|
|
|
try:
|
|
value = kwargs.pop(name)
|
|
if name in ['username', 'password']:
|
|
# Work around a problem in django.core.mail.send_mail, which calls
|
|
# get_connection(... username=None, password=None) by default.
|
|
# We need to ignore those None defaults (else settings like
|
|
# 'SENDGRID_USERNAME' get unintentionally overridden from kwargs).
|
|
if value is not None:
|
|
return value
|
|
else:
|
|
return value
|
|
except (AttributeError, KeyError):
|
|
pass
|
|
|
|
if esp_name is not None:
|
|
setting = "{}_{}".format(esp_name.upper(), name.upper())
|
|
else:
|
|
setting = name.upper()
|
|
anymail_setting = "ANYMAIL_%s" % setting
|
|
|
|
try:
|
|
return settings.ANYMAIL[setting]
|
|
except (AttributeError, KeyError):
|
|
try:
|
|
return getattr(settings, anymail_setting)
|
|
except AttributeError:
|
|
if allow_bare:
|
|
try:
|
|
return getattr(settings, setting)
|
|
except AttributeError:
|
|
pass
|
|
if default is UNSET:
|
|
message = "You must set %s or ANYMAIL = {'%s': ...}" % (anymail_setting, setting)
|
|
if allow_bare:
|
|
message += " or %s" % setting
|
|
message += " in your Django settings"
|
|
raise AnymailConfigurationError(message)
|
|
else:
|
|
return default
|
|
|
|
|
|
def collect_all_methods(cls, method_name):
|
|
"""Return list of all `method_name` methods for cls and its superclass chain.
|
|
|
|
List is in MRO order, with no duplicates. Methods are unbound.
|
|
|
|
(This is used to simplify mixins and subclasses that contribute to a method set,
|
|
without requiring superclass chaining, and without requiring cooperating
|
|
superclasses.)
|
|
"""
|
|
methods = []
|
|
for ancestor in cls.__mro__:
|
|
try:
|
|
validator = getattr(ancestor, method_name)
|
|
except AttributeError:
|
|
pass
|
|
else:
|
|
if validator not in methods:
|
|
methods.append(validator)
|
|
return methods
|
|
|
|
|
|
EPOCH = datetime(1970, 1, 1, tzinfo=utc)
|
|
|
|
|
|
def timestamp(dt):
|
|
"""Return the unix timestamp (seconds past the epoch) for datetime dt"""
|
|
# This is the equivalent of Python 3.3's datetime.timestamp
|
|
try:
|
|
return dt.timestamp()
|
|
except AttributeError:
|
|
if dt.tzinfo is None:
|
|
return mktime(dt.timetuple())
|
|
else:
|
|
return (dt - EPOCH).total_seconds()
|
|
|
|
|
|
def rfc2822date(dt):
|
|
"""Turn a datetime into a date string as specified in RFC 2822."""
|
|
# This is almost the equivalent of Python 3.3's email.utils.format_datetime,
|
|
# but treats naive datetimes as local rather than "UTC with no information ..."
|
|
timeval = timestamp(dt)
|
|
return formatdate(timeval, usegmt=True)
|
|
|
|
|
|
def is_lazy(obj):
|
|
"""Return True if obj is a Django lazy object."""
|
|
# See django.utils.functional.lazy. (This appears to be preferred
|
|
# to checking for `not isinstance(obj, six.text_type)`.)
|
|
return isinstance(obj, Promise)
|
|
|
|
|
|
def force_non_lazy(obj):
|
|
"""If obj is a Django lazy object, return it coerced to text; otherwise return it unchanged.
|
|
|
|
(Similar to django.utils.encoding.force_text, but doesn't alter non-text objects.)
|
|
"""
|
|
if is_lazy(obj):
|
|
return six.text_type(obj)
|
|
|
|
return obj
|
|
|
|
|
|
def force_non_lazy_list(obj):
|
|
"""Return a (shallow) copy of sequence obj, with all values forced non-lazy."""
|
|
try:
|
|
return [force_non_lazy(item) for item in obj]
|
|
except (AttributeError, TypeError):
|
|
return force_non_lazy(obj)
|
|
|
|
|
|
def force_non_lazy_dict(obj):
|
|
"""Return a (deep) copy of dict obj, with all values forced non-lazy."""
|
|
try:
|
|
return {key: force_non_lazy_dict(value) for key, value in obj.items()}
|
|
except (AttributeError, TypeError):
|
|
return force_non_lazy(obj)
|
|
|
|
|
|
def get_request_basic_auth(request):
|
|
"""Returns HTTP basic auth string sent with request, or None.
|
|
|
|
If request includes basic auth, result is string 'username:password'.
|
|
"""
|
|
try:
|
|
authtype, authdata = request.META['HTTP_AUTHORIZATION'].split()
|
|
if authtype.lower() == "basic":
|
|
return base64.b64decode(authdata).decode('utf-8')
|
|
except (IndexError, KeyError, TypeError, ValueError):
|
|
pass
|
|
return None
|
|
|
|
|
|
def get_request_uri(request):
|
|
"""Returns the "exact" url used to call request.
|
|
|
|
Like :func:`django.http.request.HTTPRequest.build_absolute_uri`,
|
|
but also inlines HTTP basic auth, if present.
|
|
"""
|
|
url = request.build_absolute_uri()
|
|
basic_auth = get_request_basic_auth(request)
|
|
if basic_auth is not None:
|
|
# must reassemble url with auth
|
|
parts = urlsplit(url)
|
|
url = urlunsplit((parts.scheme, basic_auth + '@' + parts.netloc,
|
|
parts.path, parts.query, parts.fragment))
|
|
return url
|