Drop Python 2 and Django 1.11 support

Minimum supported versions are now Django 2.0, Python 3.5.

This touches a lot of code, to:
* Remove obsolete portability code and workarounds
  (six, backports of email parsers, test utils, etc.)
* Use Python 3 syntax (class defs, raise ... from, etc.)
* Correct inheritance for mixin classes
* Fix outdated docs content and links
* Suppress Python 3 "unclosed SSLSocket" ResourceWarnings
  that are beyond our control (in integration tests due to boto3, 
  python-sparkpost)
This commit is contained in:
Mike Edmunds
2020-08-01 14:53:10 -07:00
committed by GitHub
parent c803108481
commit 85cec5e9dc
87 changed files with 672 additions and 1278 deletions

View File

@@ -1,139 +0,0 @@
# Work around bugs in older versions of email.parser.Parser
#
# This module implements two classes:
# EmailParser
# EmailBytesParser
# which can be used like the Python 3.3+ email.parser.Parser
# and email.parser.BytesParser (with email.policy.default).
#
# On Python 2.7, they attempt to work around some bugs/limitations
# in email.parser.Parser, without trying to back-port the whole
# Python 3 email package.
__all__ = ['EmailParser', 'EmailBytesParser']
from email.parser import Parser
try:
# With Python 3.3+ (email6) package, using `policy=email.policy.default`
# avoids earlier bugs. (Note that Parser defaults to policy=compat32,
# which *preserves* earlier bugs.)
from email.policy import default
from email.parser import BytesParser
class EmailParser(Parser):
def __init__(self, _class=None, policy=default): # don't default to compat32 policy
super(EmailParser, self).__init__(_class, policy=policy)
class EmailBytesParser(BytesParser):
def __init__(self, _class=None, policy=default): # don't default to compat32 policy
super(EmailBytesParser, self).__init__(_class, policy=policy)
except ImportError:
# Pre-Python 3.3 email package: try to work around some bugs
from email.header import decode_header
from collections import deque
class EmailParser(Parser):
def parse(self, fp, headersonly=False):
# Older Parser doesn't correctly unfold headers (RFC5322 section 2.2.3).
# Help it out by pre-unfolding the headers for it.
fp = HeaderUnfoldingWrapper(fp)
message = Parser.parse(self, fp, headersonly=headersonly)
# Older Parser doesn't decode RFC2047 headers, so fix them up here.
# (Since messsage is fully parsed, can decode headers in all MIME subparts.)
for part in message.walk():
part._headers = [ # doesn't seem to be a public API to easily replace all headers
(name, _decode_rfc2047(value))
for name, value in part._headers]
return message
class EmailBytesParser(EmailParser):
def parsebytes(self, text, headersonly=False):
# In Python 2, bytes is str, and Parser.parsestr uses bytes-friendly cStringIO.StringIO.
return self.parsestr(text, headersonly)
class HeaderUnfoldingWrapper:
"""
A wrapper for file-like objects passed to email.parser.Parser.parse which works
around older Parser bugs with folded email headers by pre-unfolding them.
This only works for headers at the message root, not ones within a MIME subpart.
(Accurately recognizing subpart headers would require parsing mixed-content boundaries.)
"""
def __init__(self, fp):
self.fp = fp
self._in_headers = True
self._pushback = deque()
def _readline(self, limit=-1):
try:
line = self._pushback.popleft()
except IndexError:
line = self.fp.readline(limit)
# cStringIO.readline doesn't recognize universal newlines; splitlines does
lines = line.splitlines(True)
if len(lines) > 1:
line = lines[0]
self._pushback.extend(lines[1:])
return line
def _peekline(self, limit=-1):
try:
line = self._pushback[0]
except IndexError:
line = self._readline(limit)
self._pushback.appendleft(line)
return line
def readline(self, limit=-1):
line = self._readline(limit)
if self._in_headers:
line_without_end = line.rstrip("\r\n") # CRLF, CR, or LF -- "universal newlines"
if len(line_without_end) == 0:
# RFC5322 section 2.1: "The body ... is separated from the header section
# by an empty line (i.e., a line with nothing preceding the CRLF)."
self._in_headers = False
else:
# Is this header line folded? Need to check next line...
# RFC5322 section 2.2.3: "Unfolding is accomplished by simply removing any CRLF
# that is immediately followed by WSP." (WSP is space or tab)
next_line = self._peekline(limit)
if next_line.startswith((' ', '\t')):
line = line_without_end
return line
def read(self, size):
if self._in_headers:
# For simplicity, just read a line at a time while in the header section.
# (This works because we know email.parser.Parser doesn't really care if it reads
# more or less data than it asked for -- it just pushes it into FeedParser either way.)
return self.readline(size)
elif len(self._pushback):
buf = ''.join(self._pushback)
self._pushback.clear()
return buf
else:
return self.fp.read(size)
def _decode_rfc2047(value):
result = value
decoded_segments = decode_header(value)
if any(charset is not None for raw, charset in decoded_segments):
# At least one segment is an RFC2047 encoded-word.
# Reassemble the segments into a single decoded string.
unicode_segments = []
prev_charset = None
for raw, charset in decoded_segments:
if (charset is None or prev_charset is None) and unicode_segments:
# Transitioning to, from, or between *non*-encoded segments:
# add back inter-segment whitespace that decode_header consumed
unicode_segments.append(u" ")
decoded = raw.decode(charset, 'replace') if charset is not None else raw
unicode_segments.append(decoded)
prev_charset = charset
result = u"".join(unicode_segments)
return result

View File

@@ -1,3 +1,3 @@
VERSION = (7, 2)
VERSION = (8, 0, 0, 'dev0')
__version__ = '.'.join([str(x) for x in VERSION]) # major.minor.patch or major.minor.devN
__minor_version__ = '.'.join([str(x) for x in VERSION[:2]]) # Sphinx's X.Y "version"

View File

@@ -1,10 +1,6 @@
from email.charset import Charset, QP
from email.header import Header
from email.mime.base import MIMEBase
from email.mime.text import MIMEText
from django.core.mail import BadHeaderError
from .base import AnymailBaseBackend, BasePayload
from .._version import __version__
from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled
@@ -15,42 +11,14 @@ try:
import boto3
from botocore.client import Config
from botocore.exceptions import BotoCoreError, ClientError, ConnectionError
except ImportError:
raise AnymailImproperlyInstalled(missing_package='boto3', backend='amazon_ses')
except ImportError as err:
raise AnymailImproperlyInstalled(missing_package='boto3', backend='amazon_ses') from err
# boto3 has several root exception classes; this is meant to cover all of them
BOTO_BASE_ERRORS = (BotoCoreError, ClientError, ConnectionError)
# Work around Python 2 bug in email.message.Message.to_string, where long headers
# containing commas or semicolons get an extra space inserted after every ',' or ';'
# not already followed by a space. https://bugs.python.org/issue25257
if Header("test,Python2,header,comma,bug", maxlinelen=20).encode() == "test,Python2,header,comma,bug":
# no workaround needed
HeaderBugWorkaround = None
def add_header(message, name, val):
message[name] = val
else:
# workaround: custom Header subclass that won't consider ',' and ';' as folding candidates
class HeaderBugWorkaround(Header):
def encode(self, splitchars=' ', **kwargs): # only split on spaces, rather than splitchars=';, '
return Header.encode(self, splitchars, **kwargs)
def add_header(message, name, val):
# Must bypass Django's SafeMIMEMessage.__set_item__, because its call to
# forbid_multi_line_headers converts the val back to a str, undoing this
# workaround. That makes this code responsible for sanitizing val:
if '\n' in val or '\r' in val:
raise BadHeaderError("Header values can't contain newlines (got %r for header %r)" % (val, name))
val = HeaderBugWorkaround(val, header_name=name)
assert isinstance(message, MIMEBase)
MIMEBase.__setitem__(message, name, val)
class EmailBackend(AnymailBaseBackend):
"""
Amazon SES Email Backend (using boto3)
@@ -60,7 +28,7 @@ class EmailBackend(AnymailBaseBackend):
def __init__(self, **kwargs):
"""Init options from Django settings"""
super(EmailBackend, self).__init__(**kwargs)
super().__init__(**kwargs)
# AMAZON_SES_CLIENT_PARAMS is optional - boto3 can find credentials several other ways
self.session_params, self.client_params = _get_anymail_boto3_params(kwargs=kwargs)
self.configuration_set_name = get_anymail_setting("configuration_set_name", esp_name=self.esp_name,
@@ -77,6 +45,8 @@ class EmailBackend(AnymailBaseBackend):
except BOTO_BASE_ERRORS:
if not self.fail_silently:
raise
else:
return True # created client
def close(self):
if self.client is None:
@@ -98,7 +68,7 @@ class EmailBackend(AnymailBaseBackend):
except BOTO_BASE_ERRORS as err:
# ClientError has a response attr with parsed json error response (other errors don't)
raise AnymailAPIError(str(err), backend=self, email_message=message, payload=payload,
response=getattr(err, 'response', None), raised_from=err)
response=getattr(err, 'response', None)) from err
return response
def parse_recipient_status(self, response, payload, message):
@@ -125,12 +95,9 @@ class AmazonSESBasePayload(BasePayload):
class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
def init_payload(self):
super(AmazonSESSendRawEmailPayload, self).init_payload()
super().init_payload()
self.all_recipients = []
self.mime_message = self.message.message()
if HeaderBugWorkaround and "Subject" in self.mime_message:
# (message.message() will have already checked subject for BadHeaderError)
self.mime_message.replace_header("Subject", HeaderBugWorkaround(self.message.subject))
# Work around an Amazon SES bug where, if all of:
# - the message body (text or html) contains non-ASCII characters
@@ -165,7 +132,7 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
except (KeyError, TypeError) as err:
raise AnymailAPIError(
"%s parsing Amazon SES send result %r" % (str(err), response),
backend=self.backend, email_message=self.message, payload=self)
backend=self.backend, email_message=self.message, payload=self) from None
recipient_status = AnymailRecipientStatus(message_id=message_id, status="queued")
return {recipient.addr_spec: recipient_status for recipient in self.all_recipients}
@@ -248,14 +215,14 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
# (See "How do message tags work?" in https://aws.amazon.com/blogs/ses/introducing-sending-metrics/
# and https://forums.aws.amazon.com/thread.jspa?messageID=782922.)
# To support reliable retrieval in webhooks, just use custom headers for metadata.
add_header(self.mime_message, "X-Metadata", self.serialize_json(metadata))
self.mime_message["X-Metadata"] = self.serialize_json(metadata)
def set_tags(self, tags):
# See note about Amazon SES Message Tags and custom headers in set_metadata above.
# To support reliable retrieval in webhooks, use custom headers for tags.
# (There are no restrictions on number or content for custom header tags.)
for tag in tags:
add_header(self.mime_message, "X-Tag", tag) # creates multiple X-Tag headers, one per tag
self.mime_message.add_header("X-Tag", tag) # creates multiple X-Tag headers, one per tag
# Also *optionally* pass a single Message Tag if the AMAZON_SES_MESSAGE_TAG_NAME
# Anymail setting is set (default no). The AWS API restricts tag content in this case.
@@ -278,7 +245,7 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload):
def init_payload(self):
super(AmazonSESSendBulkTemplatedEmailPayload, self).init_payload()
super().init_payload()
# late-bind recipients and merge_data in call_send_api
self.recipients = {"to": [], "cc": [], "bcc": []}
self.merge_data = {}
@@ -311,7 +278,7 @@ class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload):
except (KeyError, TypeError) as err:
raise AnymailAPIError(
"%s parsing Amazon SES send result %r" % (str(err), response),
backend=self.backend, email_message=self.message, payload=self)
backend=self.backend, email_message=self.message, payload=self) from None
to_addrs = [to.addr_spec for to in self.recipients["to"]]
if len(anymail_statuses) != len(to_addrs):

View File

@@ -1,7 +1,6 @@
import json
from datetime import date, datetime
import six
from django.conf import settings
from django.core.mail.backends.base import BaseEmailBackend
from django.utils.timezone import is_naive, get_current_timezone, make_aware, utc
@@ -23,7 +22,7 @@ class AnymailBaseBackend(BaseEmailBackend):
"""
def __init__(self, *args, **kwargs):
super(AnymailBaseBackend, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self.ignore_unsupported_features = get_anymail_setting('ignore_unsupported_features',
kwargs=kwargs, default=False)
@@ -207,7 +206,7 @@ class AnymailBaseBackend(BaseEmailBackend):
(self.__class__.__module__, self.__class__.__name__))
class BasePayload(object):
class BasePayload:
# Listing of EmailMessage/EmailMultiAlternatives attributes
# to process into Payload. Each item is in the form:
# (attr, combiner, converter)
@@ -365,7 +364,7 @@ class BasePayload(object):
# TypeError: must be str, not list
# TypeError: can only concatenate list (not "str") to list
# TypeError: Can't convert 'list' object to str implicitly
if isinstance(value, six.string_types) or is_lazy(value):
if isinstance(value, str) or is_lazy(value):
raise TypeError('"{attr}" attribute must be a list or other iterable'.format(attr=attr))
#
@@ -538,7 +537,7 @@ class BasePayload(object):
except TypeError as err:
# Add some context to the "not JSON serializable" message
raise AnymailSerializationError(orig_err=err, email_message=self.message,
backend=self.backend, payload=self)
backend=self.backend, payload=self) from None
@staticmethod
def _json_default(o):

View File

@@ -1,13 +1,11 @@
from __future__ import print_function
from urllib.parse import urljoin
import requests
import six
from six.moves.urllib.parse import urljoin
from anymail.utils import get_anymail_setting
from .base import AnymailBaseBackend, BasePayload
from ..exceptions import AnymailRequestsAPIError
from .._version import __version__
from ..exceptions import AnymailRequestsAPIError
class AnymailRequestsBackend(AnymailBaseBackend):
@@ -19,7 +17,7 @@ class AnymailRequestsBackend(AnymailBaseBackend):
"""Init options from Django settings"""
self.api_url = api_url
self.timeout = get_anymail_setting('requests_timeout', kwargs=kwargs, default=30)
super(AnymailRequestsBackend, self).__init__(**kwargs)
super().__init__(**kwargs)
self.session = None
def open(self):
@@ -57,7 +55,7 @@ class AnymailRequestsBackend(AnymailBaseBackend):
"Session has not been opened in {class_name}._send. "
"(This is either an implementation error in {class_name}, "
"or you are incorrectly calling _send directly.)".format(class_name=class_name))
return super(AnymailRequestsBackend, self)._send(message)
return super()._send(message)
def post_to_esp(self, payload, message):
"""Post payload to ESP send API endpoint, and return the raw response.
@@ -78,7 +76,7 @@ class AnymailRequestsBackend(AnymailBaseBackend):
exc_class = type('AnymailRequestsAPIError', (AnymailRequestsAPIError, type(err)), {})
raise exc_class(
"Error posting to %s:" % params.get('url', '<missing url>'),
raised_from=err, email_message=message, payload=payload)
email_message=message, payload=payload) from err
self.raise_for_status(response, payload, message)
return response
@@ -100,10 +98,10 @@ class AnymailRequestsBackend(AnymailBaseBackend):
"""
try:
return response.json()
except ValueError:
except ValueError as err:
raise AnymailRequestsAPIError("Invalid JSON in %s API response" % self.esp_name,
email_message=message, payload=payload, response=response,
backend=self)
backend=self) from err
@staticmethod
def _dump_api_request(response, **kwargs):
@@ -113,22 +111,22 @@ class AnymailRequestsBackend(AnymailBaseBackend):
# If you need the raw bytes, configure HTTPConnection logging as shown
# in http://docs.python-requests.org/en/v3.0.0/api/#api-changes)
request = response.request # a PreparedRequest
print(u"\n===== Anymail API request")
print(u"{method} {url}\n{headers}".format(
print("\n===== Anymail API request")
print("{method} {url}\n{headers}".format(
method=request.method, url=request.url,
headers=u"".join(u"{header}: {value}\n".format(header=header, value=value)
for (header, value) in request.headers.items()),
headers="".join("{header}: {value}\n".format(header=header, value=value)
for (header, value) in request.headers.items()),
))
if request.body is not None:
body_text = (request.body if isinstance(request.body, six.text_type)
body_text = (request.body if isinstance(request.body, str)
else request.body.decode("utf-8", errors="replace")
).replace("\r\n", "\n")
print(body_text)
print(u"\n----- Response")
print(u"HTTP {status} {reason}\n{headers}\n{body}".format(
print("\n----- Response")
print("HTTP {status} {reason}\n{headers}\n{body}".format(
status=response.status_code, reason=response.reason,
headers=u"".join(u"{header}: {value}\n".format(header=header, value=value)
for (header, value) in response.headers.items()),
headers="".join("{header}: {value}\n".format(header=header, value=value)
for (header, value) in response.headers.items()),
body=response.text, # Let Requests decode body content for us
))
@@ -145,7 +143,7 @@ class RequestsPayload(BasePayload):
self.headers = headers
self.files = files
self.auth = auth
super(RequestsPayload, self).__init__(message, defaults, backend)
super().__init__(message, defaults, backend)
def get_request_params(self, api_url):
"""Returns a dict of requests.request params that will send payload to the ESP.

View File

@@ -1,15 +1,14 @@
from datetime import datetime
from email.utils import encode_rfc2231
from six.moves.urllib.parse import quote
from urllib.parse import quote
from requests import Request
from ..exceptions import AnymailRequestsAPIError, AnymailError
from .base_requests import AnymailRequestsBackend, RequestsPayload
from ..exceptions import AnymailError, AnymailRequestsAPIError
from ..message import AnymailRecipientStatus
from ..utils import get_anymail_setting, rfc2822date
from .base_requests import AnymailRequestsBackend, RequestsPayload
# Feature-detect whether requests (urllib3) correctly uses RFC 7578 encoding for non-
# ASCII filenames in Content-Disposition headers. (This was fixed in urllib3 v1.25.)
@@ -17,7 +16,7 @@ from .base_requests import AnymailRequestsBackend, RequestsPayload
# (Note: when this workaround is removed, please also remove the "old_urllib3" tox envs.)
def is_requests_rfc_5758_compliant():
request = Request(method='POST', url='https://www.example.com',
files=[('attachment', (u'\N{NOT SIGN}.txt', 'test', 'text/plain'))])
files=[('attachment', ('\N{NOT SIGN}.txt', 'test', 'text/plain'))])
prepared = request.prepare()
form_data = prepared.body # bytes
return b'filename*=' not in form_data
@@ -43,7 +42,7 @@ class EmailBackend(AnymailRequestsBackend):
default="https://api.mailgun.net/v3")
if not api_url.endswith("/"):
api_url += "/"
super(EmailBackend, self).__init__(api_url, **kwargs)
super().__init__(api_url, **kwargs)
def build_message_payload(self, message, defaults):
return MailgunPayload(message, defaults, self)
@@ -62,10 +61,10 @@ class EmailBackend(AnymailRequestsBackend):
try:
message_id = parsed_response["id"]
mailgun_message = parsed_response["message"]
except (KeyError, TypeError):
except (KeyError, TypeError) as err:
raise AnymailRequestsAPIError("Invalid Mailgun API response format",
email_message=message, payload=payload, response=response,
backend=self)
backend=self) from err
if not mailgun_message.startswith("Queued"):
raise AnymailRequestsAPIError("Unrecognized Mailgun API message '%s'" % mailgun_message,
email_message=message, payload=payload, response=response,
@@ -89,7 +88,7 @@ class MailgunPayload(RequestsPayload):
self.merge_metadata = {}
self.to_emails = []
super(MailgunPayload, self).__init__(message, defaults, backend, auth=auth, *args, **kwargs)
super().__init__(message, defaults, backend, auth=auth, *args, **kwargs)
def get_api_endpoint(self):
if self.sender_domain is None:
@@ -105,7 +104,7 @@ class MailgunPayload(RequestsPayload):
return "%s/messages" % quote(self.sender_domain, safe='')
def get_request_params(self, api_url):
params = super(MailgunPayload, self).get_request_params(api_url)
params = super().get_request_params(api_url)
non_ascii_filenames = [filename
for (field, (filename, content, mimetype)) in params["files"]
if filename is not None and not isascii(filename)]
@@ -122,9 +121,7 @@ class MailgunPayload(RequestsPayload):
prepared = Request(**params).prepare()
form_data = prepared.body # bytes
for filename in non_ascii_filenames: # text
rfc2231_filename = encode_rfc2231( # wants a str (text in PY3, bytes in PY2)
filename if isinstance(filename, str) else filename.encode("utf-8"),
charset="utf-8")
rfc2231_filename = encode_rfc2231(filename, charset="utf-8")
form_data = form_data.replace(
b'filename*=' + rfc2231_filename.encode("utf-8"),
b'filename="' + filename.encode("utf-8") + b'"')

View File

@@ -1,12 +1,10 @@
from email.header import Header
from six.moves.urllib.parse import quote
from ..exceptions import AnymailRequestsAPIError
from ..message import AnymailRecipientStatus, ANYMAIL_STATUSES
from ..utils import get_anymail_setting, EmailAddress, parse_address_list
from urllib.parse import quote
from .base_requests import AnymailRequestsBackend, RequestsPayload
from ..exceptions import AnymailRequestsAPIError
from ..message import ANYMAIL_STATUSES, AnymailRecipientStatus
from ..utils import EmailAddress, get_anymail_setting, parse_address_list
class EmailBackend(AnymailRequestsBackend):
@@ -25,7 +23,7 @@ class EmailBackend(AnymailRequestsBackend):
default="https://api.mailjet.com/v3")
if not api_url.endswith("/"):
api_url += "/"
super(EmailBackend, self).__init__(api_url, **kwargs)
super().__init__(api_url, **kwargs)
def build_message_payload(self, message, defaults):
return MailjetPayload(message, defaults, self)
@@ -36,7 +34,7 @@ class EmailBackend(AnymailRequestsBackend):
raise AnymailRequestsAPIError(
"Invalid Mailjet API key or secret",
email_message=message, payload=payload, response=response, backend=self)
super(EmailBackend, self).raise_for_status(response, payload, message)
super().raise_for_status(response, payload, message)
def parse_recipient_status(self, response, payload, message):
# Mailjet's (v3.0) transactional send API is not covered in their reference docs.
@@ -61,10 +59,10 @@ class EmailBackend(AnymailRequestsBackend):
message_id = str(item['MessageID'])
email = item['Email']
recipient_status[email] = AnymailRecipientStatus(message_id=message_id, status=status)
except (KeyError, TypeError):
except (KeyError, TypeError) as err:
raise AnymailRequestsAPIError("Invalid Mailjet API response format",
email_message=message, payload=payload, response=response,
backend=self)
backend=self) from err
# Make sure we ended up with a status for every original recipient
# (Mailjet only communicates "Sent")
for recipients in payload.recipients.values():
@@ -88,8 +86,7 @@ class MailjetPayload(RequestsPayload):
self.metadata = None
self.merge_data = {}
self.merge_metadata = {}
super(MailjetPayload, self).__init__(message, defaults, backend,
auth=auth, headers=http_headers, *args, **kwargs)
super().__init__(message, defaults, backend, auth=auth, headers=http_headers, *args, **kwargs)
def get_api_endpoint(self):
return "send"
@@ -153,9 +150,10 @@ class MailjetPayload(RequestsPayload):
parsed.addr_spec)
else:
parsed = EmailAddress(headers["SenderName"], headers["SenderEmail"])
except KeyError:
except KeyError as err:
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) from err
self.set_from_email(parsed)
def _format_email_for_mailjet(self, email):

View File

@@ -23,7 +23,7 @@ class EmailBackend(AnymailRequestsBackend):
default="https://mandrillapp.com/api/1.0")
if not api_url.endswith("/"):
api_url += "/"
super(EmailBackend, self).__init__(api_url, **kwargs)
super().__init__(api_url, **kwargs)
def build_message_payload(self, message, defaults):
return MandrillPayload(message, defaults, self)
@@ -40,10 +40,10 @@ class EmailBackend(AnymailRequestsBackend):
status = 'unknown'
message_id = item.get('_id', None) # can be missing for invalid/rejected recipients
recipient_status[email] = AnymailRecipientStatus(message_id=message_id, status=status)
except (KeyError, TypeError):
except (KeyError, TypeError) as err:
raise AnymailRequestsAPIError("Invalid Mandrill API response format",
email_message=message, payload=payload, response=response,
backend=self)
backend=self) from err
return recipient_status
@@ -69,7 +69,7 @@ class MandrillPayload(RequestsPayload):
def __init__(self, *args, **kwargs):
self.esp_extra = {} # late-bound in serialize_data
super(MandrillPayload, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
def get_api_endpoint(self):
if 'template_name' in self.data:

View File

@@ -22,7 +22,7 @@ class EmailBackend(AnymailRequestsBackend):
default="https://api.postmarkapp.com/")
if not api_url.endswith("/"):
api_url += "/"
super(EmailBackend, self).__init__(api_url, **kwargs)
super().__init__(api_url, **kwargs)
def build_message_payload(self, message, defaults):
return PostmarkPayload(message, defaults, self)
@@ -30,7 +30,7 @@ class EmailBackend(AnymailRequestsBackend):
def raise_for_status(self, response, payload, message):
# We need to handle 422 responses in parse_recipient_status
if response.status_code != 422:
super(EmailBackend, self).raise_for_status(response, payload, message)
super().raise_for_status(response, payload, message)
def parse_recipient_status(self, response, payload, message):
# Default to "unknown" status for each recipient, unless/until we find otherwise.
@@ -51,19 +51,19 @@ class EmailBackend(AnymailRequestsBackend):
# these fields should always be present
error_code = one_response["ErrorCode"]
msg = one_response["Message"]
except (KeyError, TypeError):
except (KeyError, TypeError) as err:
raise AnymailRequestsAPIError("Invalid Postmark API response format",
email_message=message, payload=payload, response=response,
backend=self)
backend=self) from err
if error_code == 0:
# At least partial success, and (some) email was sent.
try:
message_id = one_response["MessageID"]
except KeyError:
except KeyError as err:
raise AnymailRequestsAPIError("Invalid Postmark API success response format",
email_message=message, payload=payload,
response=response, backend=self)
response=response, backend=self) from err
# Assume all To recipients are "sent" unless proven otherwise below.
# (Must use "To" from API response to get correct individual MessageIDs in batch send.)
@@ -157,7 +157,7 @@ class PostmarkPayload(RequestsPayload):
self.cc_and_bcc_emails = [] # need to track (separately) for parse_recipient_status
self.merge_data = None
self.merge_metadata = None
super(PostmarkPayload, self).__init__(message, defaults, backend, headers=headers, *args, **kwargs)
super().__init__(message, defaults, backend, headers=headers, *args, **kwargs)
def get_api_endpoint(self):
batch_send = self.is_batch() and len(self.to_emails) > 1
@@ -174,7 +174,7 @@ class PostmarkPayload(RequestsPayload):
return "email"
def get_request_params(self, api_url):
params = super(PostmarkPayload, self).get_request_params(api_url)
params = super().get_request_params(api_url)
params['headers']['X-Postmark-Server-Token'] = self.server_token
return params

View File

@@ -7,7 +7,7 @@ from requests.structures import CaseInsensitiveDict
from .base_requests import AnymailRequestsBackend, RequestsPayload
from ..exceptions import AnymailConfigurationError, AnymailRequestsAPIError, AnymailWarning
from ..message import AnymailRecipientStatus
from ..utils import BASIC_NUMERIC_TYPES, Mapping, get_anymail_setting, timestamp, update_deep
from ..utils import BASIC_NUMERIC_TYPES, Mapping, get_anymail_setting, update_deep
class EmailBackend(AnymailRequestsBackend):
@@ -47,7 +47,7 @@ class EmailBackend(AnymailRequestsBackend):
default="https://api.sendgrid.com/v3/")
if not api_url.endswith("/"):
api_url += "/"
super(EmailBackend, self).__init__(api_url, **kwargs)
super().__init__(api_url, **kwargs)
def build_message_payload(self, message, defaults):
return SendGridPayload(message, defaults, self)
@@ -84,9 +84,7 @@ class SendGridPayload(RequestsPayload):
http_headers['Authorization'] = 'Bearer %s' % backend.api_key
http_headers['Content-Type'] = 'application/json'
http_headers['Accept'] = 'application/json'
super(SendGridPayload, self).__init__(message, defaults, backend,
headers=http_headers,
*args, **kwargs)
super().__init__(message, defaults, backend, headers=http_headers, *args, **kwargs)
def get_api_endpoint(self):
return "mail/send"
@@ -294,7 +292,7 @@ class SendGridPayload(RequestsPayload):
def set_send_at(self, send_at):
# Backend has converted pretty much everything to
# a datetime by here; SendGrid expects unix timestamp
self.data["send_at"] = int(timestamp(send_at)) # strip microseconds
self.data["send_at"] = int(send_at.timestamp()) # strip microseconds
def set_tags(self, tags):
self.data["categories"] = tags

View File

@@ -30,7 +30,7 @@ class EmailBackend(AnymailRequestsBackend):
)
if not api_url.endswith("/"):
api_url += "/"
super(EmailBackend, self).__init__(api_url, **kwargs)
super().__init__(api_url, **kwargs)
def build_message_payload(self, message, defaults):
return SendinBluePayload(message, defaults, self)
@@ -53,10 +53,10 @@ class EmailBackend(AnymailRequestsBackend):
parsed_response = self.deserialize_json_response(response, payload, message)
try:
message_id = parsed_response['messageId']
except (KeyError, TypeError):
except (KeyError, TypeError) as err:
raise AnymailRequestsAPIError("Invalid SendinBlue API response format",
email_message=message, payload=payload, response=response,
backend=self)
backend=self) from err
status = AnymailRecipientStatus(message_id=message_id, status="queued")
return {recipient.addr_spec: status for recipient in payload.all_recipients}
@@ -71,7 +71,7 @@ class SendinBluePayload(RequestsPayload):
http_headers['api-key'] = backend.api_key
http_headers['Content-Type'] = 'application/json'
super(SendinBluePayload, self).__init__(message, defaults, backend, headers=http_headers, *args, **kwargs)
super().__init__(message, defaults, backend, headers=http_headers, *args, **kwargs)
def get_api_endpoint(self):
return "smtp/email"

View File

@@ -1,5 +1,3 @@
from __future__ import absolute_import # we want the sparkpost package, not our own module
from .base import AnymailBaseBackend, BasePayload
from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled, AnymailConfigurationError
from ..message import AnymailRecipientStatus
@@ -7,8 +5,8 @@ from ..utils import get_anymail_setting
try:
from sparkpost import SparkPost, SparkPostException
except ImportError:
raise AnymailImproperlyInstalled(missing_package='sparkpost', backend='sparkpost')
except ImportError as err:
raise AnymailImproperlyInstalled(missing_package='sparkpost', backend='sparkpost') from err
class EmailBackend(AnymailBaseBackend):
@@ -20,7 +18,7 @@ class EmailBackend(AnymailBaseBackend):
def __init__(self, **kwargs):
"""Init options from Django settings"""
super(EmailBackend, self).__init__(**kwargs)
super().__init__(**kwargs)
# SPARKPOST_API_KEY is optional - library reads from env by default
self.api_key = get_anymail_setting('api_key', esp_name=self.esp_name,
kwargs=kwargs, allow_bare=True, default=None)
@@ -43,7 +41,7 @@ class EmailBackend(AnymailBaseBackend):
"You may need to set ANYMAIL = {'SPARKPOST_API_KEY': ...} "
"or ANYMAIL_SPARKPOST_API_KEY in your Django settings, "
"or SPARKPOST_API_KEY in your environment." % str(err)
)
) from err
# Note: SparkPost python API doesn't expose requests session sharing
# (so there's no need to implement open/close connection management here)
@@ -60,7 +58,7 @@ class EmailBackend(AnymailBaseBackend):
str(err), backend=self, email_message=message, payload=payload,
response=getattr(err, 'response', None), # SparkPostAPIException requests.Response
status_code=getattr(err, 'status', None), # SparkPostAPIException HTTP status_code
)
) from err
return response
def parse_recipient_status(self, response, payload, message):
@@ -72,7 +70,7 @@ class EmailBackend(AnymailBaseBackend):
raise AnymailAPIError(
"%s in SparkPost.transmissions.send result %r" % (str(err), response),
backend=self, email_message=message, payload=payload,
)
) from err
# SparkPost doesn't (yet*) tell us *which* recipients were accepted or rejected.
# (* looks like undocumented 'rcpt_to_errors' might provide this info.)

View File

@@ -25,7 +25,7 @@ class EmailBackend(AnymailBaseBackend):
# Allow replacing the payload, for testing.
# (Real backends would generally not implement this option.)
self._payload_class = kwargs.pop('payload_class', TestPayload)
super(EmailBackend, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
if not hasattr(mail, 'outbox'):
mail.outbox = [] # see django.core.mail.backends.locmem
@@ -60,8 +60,8 @@ class EmailBackend(AnymailBaseBackend):
def parse_recipient_status(self, response, payload, message):
try:
return response['recipient_status']
except KeyError:
raise AnymailAPIError('Unparsable test response')
except KeyError as err:
raise AnymailAPIError('Unparsable test response') from err
class TestPayload(BasePayload):

View File

@@ -1,9 +1,6 @@
from __future__ import unicode_literals
import json
from traceback import format_exception_only
import six
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
from requests import HTTPError
@@ -23,14 +20,12 @@ class AnymailError(Exception):
backend: the backend instance involved
payload: data arg (*not* json-stringified) for the ESP send call
response: requests.Response from the send call
raised_from: original/wrapped Exception
esp_name: what to call the ESP (read from backend if provided)
"""
self.backend = kwargs.pop('backend', None)
self.email_message = kwargs.pop('email_message', None)
self.payload = kwargs.pop('payload', None)
self.status_code = kwargs.pop('status_code', None)
self.raised_from = kwargs.pop('raised_from', None)
self.esp_name = kwargs.pop('esp_name',
self.backend.esp_name if self.backend else None)
if isinstance(self, HTTPError):
@@ -38,12 +33,12 @@ class AnymailError(Exception):
self.response = kwargs.get('response', None)
else:
self.response = kwargs.pop('response', None)
super(AnymailError, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
def __str__(self):
parts = [
" ".join([six.text_type(arg) for arg in self.args]),
self.describe_raised_from(),
" ".join([str(arg) for arg in self.args]),
self.describe_cause(),
self.describe_send(),
self.describe_response(),
]
@@ -71,7 +66,7 @@ class AnymailError(Exception):
# Decode response.reason to text -- borrowed from requests.Response.raise_for_status:
reason = self.response.reason
if isinstance(reason, six.binary_type):
if isinstance(reason, bytes):
try:
reason = reason.decode('utf-8')
except UnicodeDecodeError:
@@ -88,11 +83,11 @@ class AnymailError(Exception):
pass
return description
def describe_raised_from(self):
"""Return the original exception"""
if self.raised_from is None:
def describe_cause(self):
"""Describe the original exception"""
if self.__cause__ is None:
return None
return ''.join(format_exception_only(type(self.raised_from), self.raised_from)).strip()
return ''.join(format_exception_only(type(self.__cause__), self.__cause__)).strip()
class AnymailAPIError(AnymailError):
@@ -103,7 +98,7 @@ class AnymailRequestsAPIError(AnymailAPIError, HTTPError):
"""Exception for unsuccessful response from a requests API."""
def __init__(self, *args, **kwargs):
super(AnymailRequestsAPIError, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
if self.response is not None:
self.status_code = self.response.status_code
@@ -114,7 +109,7 @@ class AnymailRecipientsRefused(AnymailError):
def __init__(self, message=None, *args, **kwargs):
if message is None:
message = "All message recipients were rejected or invalid"
super(AnymailRecipientsRefused, self).__init__(message, *args, **kwargs)
super().__init__(message, *args, **kwargs)
class AnymailInvalidAddress(AnymailError, ValueError):
@@ -154,7 +149,7 @@ class AnymailSerializationError(AnymailError, TypeError):
"Try converting it to a string or number first." % esp_name
if orig_err is not None:
message += "\n%s" % str(orig_err)
super(AnymailSerializationError, self).__init__(message, *args, **kwargs)
super().__init__(message, *args, **kwargs)
class AnymailCancelSend(AnymailError):
@@ -182,7 +177,7 @@ class AnymailImproperlyInstalled(AnymailConfigurationError, ImportError):
message = "The %s package is required to use this ESP, but isn't installed.\n" \
"(Be sure to use `pip install django-anymail[%s]` " \
"with your desired ESPs.)" % (missing_package, backend)
super(AnymailImproperlyInstalled, self).__init__(message)
super().__init__(message)
# Warnings
@@ -201,7 +196,7 @@ class AnymailDeprecationWarning(AnymailWarning, DeprecationWarning):
# Helpers
class _LazyError(object):
class _LazyError:
"""An object that sits inert unless/until used, then raises an error"""
def __init__(self, error):
self._error = error

View File

@@ -1,15 +1,15 @@
from base64 import b64decode
from email.message import Message
from email.parser import BytesParser, Parser
from email.policy import default as default_policy
from email.utils import unquote
import six
from django.core.files.uploadedfile import SimpleUploadedFile
from ._email_compat import EmailParser, EmailBytesParser
from .utils import angle_wrap, get_content_disposition, parse_address_list, parse_rfc2822date
from .utils import angle_wrap, parse_address_list, parse_rfc2822date
class AnymailInboundMessage(Message, object): # `object` ensures new-style class in Python 2)
class AnymailInboundMessage(Message):
"""
A normalized, parsed inbound email message.
@@ -31,7 +31,7 @@ class AnymailInboundMessage(Message, object): # `object` ensures new-style clas
def __init__(self, *args, **kwargs):
# Note: this must accept zero arguments, for use with message_from_string (email.parser)
super(AnymailInboundMessage, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
# Additional attrs provided by some ESPs:
self.envelope_sender = None
@@ -125,14 +125,7 @@ class AnymailInboundMessage(Message, object): # `object` ensures new-style clas
return part.get_content_text()
return None
# Backport from Python 3.5 email.message.Message
def get_content_disposition(self):
try:
return super(AnymailInboundMessage, self).get_content_disposition()
except AttributeError:
return get_content_disposition(self)
# Backport from Python 3.4.2 email.message.MIMEPart
# Hoisted from email.message.MIMEPart
def is_attachment(self):
return self.get_content_disposition() == 'attachment'
@@ -148,10 +141,7 @@ class AnymailInboundMessage(Message, object): # `object` ensures new-style clas
# (Note that self.is_multipart() misleadingly returns True in this case.)
payload = self.get_payload()
assert len(payload) == 1 # should be exactly one message
try:
return payload[0].as_bytes() # Python 3
except AttributeError:
return payload[0].as_string().encode('utf-8')
return payload[0].as_bytes()
elif maintype == 'multipart':
# The attachment itself is multipart; the payload is a list of parts,
# and it's not clear which one is the "content".
@@ -199,24 +189,24 @@ class AnymailInboundMessage(Message, object): # `object` ensures new-style clas
@classmethod
def parse_raw_mime(cls, s):
"""Returns a new AnymailInboundMessage parsed from str s"""
if isinstance(s, six.text_type):
if isinstance(s, str):
# Avoid Python 3.x issue https://bugs.python.org/issue18271
# (See test_inbound: test_parse_raw_mime_8bit_utf8)
return cls.parse_raw_mime_bytes(s.encode('utf-8'))
return EmailParser(cls).parsestr(s)
return Parser(cls, policy=default_policy).parsestr(s)
@classmethod
def parse_raw_mime_bytes(cls, b):
"""Returns a new AnymailInboundMessage parsed from bytes b"""
return EmailBytesParser(cls).parsebytes(b)
return BytesParser(cls, policy=default_policy).parsebytes(b)
@classmethod
def parse_raw_mime_file(cls, fp):
"""Returns a new AnymailInboundMessage parsed from file-like object fp"""
if isinstance(fp.read(0), six.binary_type):
return EmailBytesParser(cls).parse(fp)
if isinstance(fp.read(0), bytes):
return BytesParser(cls, policy=default_policy).parse(fp)
else:
return EmailParser(cls).parse(fp)
return Parser(cls, policy=default_policy).parse(fp)
@classmethod
def construct(cls, raw_headers=None, from_email=None, to=None, cc=None, subject=None, headers=None,
@@ -242,7 +232,7 @@ class AnymailInboundMessage(Message, object): # `object` ensures new-style clas
:return: {AnymailInboundMessage}
"""
if raw_headers is not None:
msg = EmailParser(cls).parsestr(raw_headers, headersonly=True)
msg = Parser(cls, policy=default_policy).parsestr(raw_headers, headersonly=True)
msg.set_payload(None) # headersonly forces an empty string payload, which breaks things later
else:
msg = cls()
@@ -336,7 +326,7 @@ class AnymailInboundMessage(Message, object): # `object` ensures new-style clas
if part.get_content_maintype() == 'message':
# email.Message parses message/rfc822 parts as a "multipart" (list) payload
# whose single item is the recursively-parsed message attachment
if isinstance(content, six.binary_type):
if isinstance(content, bytes):
content = content.decode()
payload = [cls.parse_raw_mime(content)]
charset = None

View File

@@ -7,7 +7,7 @@ from django.core.mail import EmailMessage, EmailMultiAlternatives, make_msgid
from .utils import UNSET
class AnymailMessageMixin(object):
class AnymailMessageMixin(EmailMessage):
"""Mixin for EmailMessage that exposes Anymail features.
Use of this mixin is optional. You can always just set Anymail
@@ -32,8 +32,7 @@ class AnymailMessageMixin(object):
self.merge_metadata = kwargs.pop('merge_metadata', UNSET)
self.anymail_status = AnymailStatus()
# noinspection PyArgumentList
super(AnymailMessageMixin, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
def attach_inline_image_file(self, path, subtype=None, idstring="img", domain=None):
"""Add inline image from file path to an EmailMessage, and return its content id"""
@@ -82,7 +81,7 @@ ANYMAIL_STATUSES = [
]
class AnymailRecipientStatus(object):
class AnymailRecipientStatus:
"""Information about an EmailMessage's send status for a single recipient"""
def __init__(self, message_id, status):
@@ -90,7 +89,7 @@ class AnymailRecipientStatus(object):
self.status = status # one of ANYMAIL_STATUSES, or None for not yet sent to ESP
class AnymailStatus(object):
class AnymailStatus:
"""Information about an EmailMessage's send status for all recipients"""
def __init__(self):

View File

@@ -2,19 +2,23 @@ from django.dispatch import Signal
# Outbound message, before sending
pre_send = Signal(providing_args=['message', 'esp_name'])
# provides args: message, esp_name
pre_send = Signal()
# Outbound message, after sending
post_send = Signal(providing_args=['message', 'status', 'esp_name'])
# provides args: message, status, esp_name
post_send = Signal()
# Delivery and tracking events for sent messages
tracking = Signal(providing_args=['event', 'esp_name'])
# provides args: event, esp_name
tracking = Signal()
# Event for receiving inbound messages
inbound = Signal(providing_args=['event', 'esp_name'])
# provides args: event, esp_name
inbound = Signal()
class AnymailEvent(object):
class AnymailEvent:
"""Base class for normalized Anymail webhook events"""
def __init__(self, event_type, timestamp=None, event_id=None, esp_event=None, **kwargs):
@@ -28,7 +32,7 @@ class AnymailTrackingEvent(AnymailEvent):
"""Normalized delivery and tracking event for sent messages"""
def __init__(self, **kwargs):
super(AnymailTrackingEvent, self).__init__(**kwargs)
super().__init__(**kwargs)
self.click_url = kwargs.pop('click_url', None) # str
self.description = kwargs.pop('description', None) # str, usually human-readable, not normalized
self.message_id = kwargs.pop('message_id', None) # str, format may vary
@@ -44,7 +48,7 @@ class AnymailInboundEvent(AnymailEvent):
"""Normalized inbound message event"""
def __init__(self, **kwargs):
super(AnymailInboundEvent, self).__init__(**kwargs)
super().__init__(**kwargs)
self.message = kwargs.pop('message', None) # anymail.inbound.AnymailInboundMessage

View File

@@ -1,4 +1,4 @@
from django.conf.urls import url
from django.urls import re_path
from .webhooks.amazon_ses import AmazonSESInboundWebhookView, AmazonSESTrackingWebhookView
from .webhooks.mailgun import MailgunInboundWebhookView, MailgunTrackingWebhookView
@@ -12,23 +12,23 @@ from .webhooks.sparkpost import SparkPostInboundWebhookView, SparkPostTrackingWe
app_name = 'anymail'
urlpatterns = [
url(r'^amazon_ses/inbound/$', AmazonSESInboundWebhookView.as_view(), name='amazon_ses_inbound_webhook'),
url(r'^mailgun/inbound(_mime)?/$', MailgunInboundWebhookView.as_view(), name='mailgun_inbound_webhook'),
url(r'^mailjet/inbound/$', MailjetInboundWebhookView.as_view(), name='mailjet_inbound_webhook'),
url(r'^postmark/inbound/$', PostmarkInboundWebhookView.as_view(), name='postmark_inbound_webhook'),
url(r'^sendgrid/inbound/$', SendGridInboundWebhookView.as_view(), name='sendgrid_inbound_webhook'),
url(r'^sparkpost/inbound/$', SparkPostInboundWebhookView.as_view(), name='sparkpost_inbound_webhook'),
re_path(r'^amazon_ses/inbound/$', AmazonSESInboundWebhookView.as_view(), name='amazon_ses_inbound_webhook'),
re_path(r'^mailgun/inbound(_mime)?/$', MailgunInboundWebhookView.as_view(), name='mailgun_inbound_webhook'),
re_path(r'^mailjet/inbound/$', MailjetInboundWebhookView.as_view(), name='mailjet_inbound_webhook'),
re_path(r'^postmark/inbound/$', PostmarkInboundWebhookView.as_view(), name='postmark_inbound_webhook'),
re_path(r'^sendgrid/inbound/$', SendGridInboundWebhookView.as_view(), name='sendgrid_inbound_webhook'),
re_path(r'^sparkpost/inbound/$', SparkPostInboundWebhookView.as_view(), name='sparkpost_inbound_webhook'),
url(r'^amazon_ses/tracking/$', AmazonSESTrackingWebhookView.as_view(), name='amazon_ses_tracking_webhook'),
url(r'^mailgun/tracking/$', MailgunTrackingWebhookView.as_view(), name='mailgun_tracking_webhook'),
url(r'^mailjet/tracking/$', MailjetTrackingWebhookView.as_view(), name='mailjet_tracking_webhook'),
url(r'^postmark/tracking/$', PostmarkTrackingWebhookView.as_view(), name='postmark_tracking_webhook'),
url(r'^sendgrid/tracking/$', SendGridTrackingWebhookView.as_view(), name='sendgrid_tracking_webhook'),
url(r'^sendinblue/tracking/$', SendinBlueTrackingWebhookView.as_view(), name='sendinblue_tracking_webhook'),
url(r'^sparkpost/tracking/$', SparkPostTrackingWebhookView.as_view(), name='sparkpost_tracking_webhook'),
re_path(r'^amazon_ses/tracking/$', AmazonSESTrackingWebhookView.as_view(), name='amazon_ses_tracking_webhook'),
re_path(r'^mailgun/tracking/$', MailgunTrackingWebhookView.as_view(), name='mailgun_tracking_webhook'),
re_path(r'^mailjet/tracking/$', MailjetTrackingWebhookView.as_view(), name='mailjet_tracking_webhook'),
re_path(r'^postmark/tracking/$', PostmarkTrackingWebhookView.as_view(), name='postmark_tracking_webhook'),
re_path(r'^sendgrid/tracking/$', SendGridTrackingWebhookView.as_view(), name='sendgrid_tracking_webhook'),
re_path(r'^sendinblue/tracking/$', SendinBlueTrackingWebhookView.as_view(), name='sendinblue_tracking_webhook'),
re_path(r'^sparkpost/tracking/$', SparkPostTrackingWebhookView.as_view(), name='sparkpost_tracking_webhook'),
# Anymail uses a combined Mandrill webhook endpoint, to simplify Mandrill's key-validation scheme:
url(r'^mandrill/$', MandrillCombinedWebhookView.as_view(), name='mandrill_webhook'),
re_path(r'^mandrill/$', MandrillCombinedWebhookView.as_view(), name='mandrill_webhook'),
# This url is maintained for backwards compatibility with earlier Anymail releases:
url(r'^mandrill/tracking/$', MandrillCombinedWebhookView.as_view(), name='mandrill_tracking_webhook'),
re_path(r'^mandrill/tracking/$', MandrillCombinedWebhookView.as_view(), name='mandrill_tracking_webhook'),
]

View File

@@ -1,33 +1,20 @@
import base64
import mimetypes
from base64 import b64encode
from datetime import datetime
from collections.abc import Mapping, MutableMapping
from email.mime.base import MIMEBase
from email.utils import formatdate, getaddresses, unquote
from time import mktime
from email.utils import formatdate, getaddresses, parsedate_to_datetime, unquote
from urllib.parse import urlsplit, urlunsplit
import six
from django.conf import settings
from django.core.mail.message import DEFAULT_ATTACHMENT_MIME_TYPE, sanitize_address
from django.utils.encoding import force_str
from django.utils.functional import Promise
from django.utils.timezone import get_fixed_timezone, utc
from requests.structures import CaseInsensitiveDict
from six.moves.urllib.parse import urlsplit, urlunsplit
from .exceptions import AnymailConfigurationError, AnymailInvalidAddress
if six.PY2:
from django.utils.encoding import force_text as force_str
else:
from django.utils.encoding import force_str
try:
from collections.abc import Mapping, MutableMapping # Python 3.3+
except ImportError:
from collections import Mapping, MutableMapping
BASIC_NUMERIC_TYPES = six.integer_types + (float,) # int, float, and (on Python 2) long
BASIC_NUMERIC_TYPES = (int, float)
UNSET = type('UNSET', (object,), {}) # Used as non-None default value
@@ -141,7 +128,7 @@ def parse_address_list(address_list, field=None):
:return list[:class:`EmailAddress`]:
:raises :exc:`AnymailInvalidAddress`:
"""
if isinstance(address_list, six.string_types) or is_lazy(address_list):
if isinstance(address_list, str) or is_lazy(address_list):
address_list = [address_list]
if address_list is None or address_list == [None]:
@@ -162,13 +149,13 @@ def parse_address_list(address_list, field=None):
for address in parsed:
if address.username == '' or address.domain == '':
# Django SMTP allows username-only emails, but they're not meaningful with an ESP
errmsg = u"Invalid email address '{problem}' parsed from '{source}'{where}.".format(
errmsg = "Invalid email address '{problem}' parsed from '{source}'{where}.".format(
problem=address.addr_spec,
source=u", ".join(address_list_strings),
where=u" in `%s`" % field if field else "",
source=", ".join(address_list_strings),
where=" in `%s`" % field if field else "",
)
if len(parsed) > len(address_list):
errmsg += u" (Maybe missing quotes around a display-name?)"
errmsg += " (Maybe missing quotes around a display-name?)"
raise AnymailInvalidAddress(errmsg)
return parsed
@@ -192,7 +179,7 @@ def parse_single_address(address, field=None):
return parsed[0]
class EmailAddress(object):
class EmailAddress:
"""A sanitized, complete email address with easy access
to display-name, addr-spec (email), etc.
@@ -249,9 +236,8 @@ class EmailAddress(object):
This is essentially the same as :func:`email.utils.formataddr`
on the EmailAddress'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.
for 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;
@@ -264,7 +250,7 @@ class EmailAddress(object):
return self.address
class Attachment(object):
class Attachment:
"""A normalized EmailMessage.attachments item with additional functionality
Normalized to have these properties:
@@ -289,14 +275,10 @@ class Attachment(object):
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.content = attachment.as_bytes()
self.mimetype = attachment.get_content_type()
content_disposition = get_content_disposition(attachment)
content_disposition = attachment.get_content_disposition()
if content_disposition == 'inline' or (not content_disposition and 'Content-ID' in attachment):
self.inline = True
self.content_id = attachment["Content-ID"] # probably including the <...>
@@ -319,23 +301,11 @@ class Attachment(object):
def b64content(self):
"""Content encoded as a base64 ascii string"""
content = self.content
if isinstance(content, six.text_type):
if isinstance(content, str):
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.
@@ -388,7 +358,7 @@ def get_anymail_setting(name, default=UNSET, esp_name=None, kwargs=None, allow_b
if allow_bare:
message += " or %s" % setting
message += " in your Django settings"
raise AnymailConfigurationError(message)
raise AnymailConfigurationError(message) from None
else:
return default
@@ -442,26 +412,11 @@ def querydict_getfirst(qdict, field, default=UNSET):
return qdict[field] # raise appropriate KeyError
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,
# This is almost the equivalent of Python's email.utils.format_datetime,
# but treats naive datetimes as local rather than "UTC with no information ..."
timeval = timestamp(dt)
timeval = dt.timestamp()
return formatdate(timeval, usegmt=True)
@@ -480,7 +435,7 @@ def angle_wrap(s):
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)`.)
# to checking for `not isinstance(obj, str)`.)
return isinstance(obj, Promise)
@@ -490,7 +445,7 @@ def force_non_lazy(obj):
(Similar to django.utils.encoding.force_text, but doesn't alter non-text objects.)
"""
if is_lazy(obj):
return six.text_type(obj)
return str(obj)
return obj
@@ -541,27 +496,6 @@ def get_request_uri(request):
return url
try:
from email.utils import parsedate_to_datetime # Python 3.3+
except ImportError:
from email.utils import parsedate_tz
# Backport Python 3.3+ email.utils.parsedate_to_datetime
def parsedate_to_datetime(s):
# *dtuple, tz = _parsedate_tz(data)
dtuple = parsedate_tz(s)
tz = dtuple[-1]
# if tz is None: # parsedate_tz returns 0 for "-0000"
if tz is None or (tz == 0 and "-0000" in s):
# "... indicates that the date-time contains no information
# about the local time zone" (RFC 2822 #3.3)
return datetime(*dtuple[:6])
else:
# tzinfo = datetime.timezone(datetime.timedelta(seconds=tz)) # Python 3.2+ only
tzinfo = get_fixed_timezone(tz // 60) # don't use timedelta (avoid Django bug #28739)
return datetime(*dtuple[:6], tzinfo=tzinfo)
def parse_rfc2822date(s):
"""Parses an RFC-2822 formatted date string into a datetime.datetime

View File

@@ -11,7 +11,7 @@ from ..exceptions import (
_LazyError)
from ..inbound import AnymailInboundMessage
from ..signals import AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason, inbound, tracking
from ..utils import combine, get_anymail_setting, getfirst
from ..utils import get_anymail_setting, getfirst
try:
import boto3
@@ -37,7 +37,7 @@ class AmazonSESBaseWebhookView(AnymailBaseWebhookView):
"auto_confirm_sns_subscriptions", esp_name=self.esp_name, kwargs=kwargs, default=True)
# boto3 params for connecting to S3 (inbound downloads) and SNS (auto-confirm subscriptions):
self.session_params, self.client_params = _get_anymail_boto3_params(kwargs=kwargs)
super(AmazonSESBaseWebhookView, self).__init__(**kwargs)
super().__init__(**kwargs)
@staticmethod
def _parse_sns_message(request):
@@ -47,7 +47,7 @@ class AmazonSESBaseWebhookView(AnymailBaseWebhookView):
body = request.body.decode(request.encoding or 'utf-8')
request._sns_message = json.loads(body)
except (TypeError, ValueError, UnicodeDecodeError) as err:
raise AnymailAPIError("Malformed SNS message body %r" % request.body, raised_from=err)
raise AnymailAPIError("Malformed SNS message body %r" % request.body) from err
return request._sns_message
def validate_request(self, request):
@@ -80,7 +80,7 @@ class AmazonSESBaseWebhookView(AnymailBaseWebhookView):
response = HttpResponse(status=401)
response["WWW-Authenticate"] = 'Basic realm="Anymail WEBHOOK_SECRET"'
return response
return super(AmazonSESBaseWebhookView, self).post(request, *args, **kwargs)
return super().post(request, *args, **kwargs)
def parse_events(self, request):
# request *has* been validated by now
@@ -91,11 +91,11 @@ class AmazonSESBaseWebhookView(AnymailBaseWebhookView):
message_string = sns_message.get("Message")
try:
ses_event = json.loads(message_string)
except (TypeError, ValueError):
except (TypeError, ValueError) as err:
if message_string == "Successfully validated SNS topic for Amazon SES event publishing.":
pass # this Notification is generated after SubscriptionConfirmation
else:
raise AnymailAPIError("Unparsable SNS Message %r" % message_string)
raise AnymailAPIError("Unparsable SNS Message %r" % message_string) from err
else:
events = self.esp_to_anymail_events(ses_event, sns_message)
elif sns_type == "SubscriptionConfirmation":
@@ -258,8 +258,7 @@ class AmazonSESTrackingWebhookView(AmazonSESBaseWebhookView):
)
return [
# AnymailTrackingEvent(**common_props, **recipient_props) # Python 3.5+ (PEP-448 syntax)
AnymailTrackingEvent(**combine(common_props, recipient_props))
AnymailTrackingEvent(**common_props, **recipient_props)
for recipient_props in per_recipient_props
]
@@ -306,7 +305,7 @@ class AmazonSESInboundWebhookView(AmazonSESBaseWebhookView):
raise AnymailBotoClientAPIError(
"Anymail AmazonSESInboundWebhookView couldn't download S3 object '{bucket_name}:{object_key}'"
"".format(bucket_name=bucket_name, object_key=object_key),
raised_from=err)
client_error=err) from err
finally:
content.close()
else:
@@ -341,13 +340,9 @@ class AmazonSESInboundWebhookView(AmazonSESBaseWebhookView):
class AnymailBotoClientAPIError(AnymailAPIError, ClientError):
"""An AnymailAPIError that is also a Boto ClientError"""
def __init__(self, *args, **kwargs):
raised_from = kwargs.pop('raised_from')
assert isinstance(raised_from, ClientError)
assert len(kwargs) == 0 # can't support other kwargs
def __init__(self, *args, client_error):
assert isinstance(client_error, ClientError)
# init self as boto ClientError (which doesn't cooperatively subclass):
super(AnymailBotoClientAPIError, self).__init__(
error_response=raised_from.response, operation_name=raised_from.operation_name)
super().__init__(error_response=client_error.response, operation_name=client_error.operation_name)
# emulate AnymailError init:
self.args = args
self.raised_from = raised_from

View File

@@ -1,6 +1,5 @@
import warnings
import six
from django.http import HttpResponse
from django.utils.crypto import constant_time_compare
from django.utils.decorators import method_decorator
@@ -11,62 +10,21 @@ from ..exceptions import AnymailInsecureWebhookWarning, AnymailWebhookValidation
from ..utils import get_anymail_setting, collect_all_methods, get_request_basic_auth
class AnymailBasicAuthMixin(object):
"""Implements webhook basic auth as mixin to AnymailBaseWebhookView."""
# Whether to warn if basic auth is not configured.
# For most ESPs, basic auth is the only webhook security,
# so the default is True. Subclasses can set False if
# they enforce other security (like signed webhooks).
warn_if_no_basic_auth = True
# List of allowable HTTP basic-auth 'user:pass' strings.
basic_auth = None # (Declaring class attr allows override by kwargs in View.as_view.)
def __init__(self, **kwargs):
self.basic_auth = get_anymail_setting('webhook_secret', default=[],
kwargs=kwargs) # no esp_name -- auth is shared between ESPs
# Allow a single string:
if isinstance(self.basic_auth, six.string_types):
self.basic_auth = [self.basic_auth]
if self.warn_if_no_basic_auth and len(self.basic_auth) < 1:
warnings.warn(
"Your Anymail webhooks are insecure and open to anyone on the web. "
"You should set WEBHOOK_SECRET in your ANYMAIL settings. "
"See 'Securing webhooks' in the Anymail docs.",
AnymailInsecureWebhookWarning)
# noinspection PyArgumentList
super(AnymailBasicAuthMixin, self).__init__(**kwargs)
def validate_request(self, request):
"""If configured for webhook basic auth, validate request has correct auth."""
if self.basic_auth:
request_auth = get_request_basic_auth(request)
# Use constant_time_compare to avoid timing attack on basic auth. (It's OK that any()
# can terminate early: we're not trying to protect how many auth strings are allowed,
# just the contents of each individual auth string.)
auth_ok = any(constant_time_compare(request_auth, allowed_auth)
for allowed_auth in self.basic_auth)
if not auth_ok:
# noinspection PyUnresolvedReferences
raise AnymailWebhookValidationFailure(
"Missing or invalid basic auth in Anymail %s webhook" % self.esp_name)
# Mixin note: Django's View.__init__ doesn't cooperate with chaining,
# so all mixins that need __init__ must appear before View in MRO.
class AnymailBaseWebhookView(AnymailBasicAuthMixin, View):
"""Base view for processing ESP event webhooks
class AnymailCoreWebhookView(View):
"""Common view for processing ESP event webhooks
ESP-specific implementations should subclass
and implement parse_events. They may also
want to implement validate_request
ESP-specific implementations will need to implement parse_events.
ESP-specific implementations should generally subclass
AnymailBaseWebhookView instead, to pick up basic auth.
They may also want to implement validate_request
if additional security is available.
"""
def __init__(self, **kwargs):
super(AnymailBaseWebhookView, self).__init__(**kwargs)
super().__init__(**kwargs)
self.validators = collect_all_methods(self.__class__, 'validate_request')
# Subclass implementation:
@@ -106,7 +64,7 @@ class AnymailBaseWebhookView(AnymailBasicAuthMixin, View):
@method_decorator(csrf_exempt)
def dispatch(self, request, *args, **kwargs):
return super(AnymailBaseWebhookView, self).dispatch(request, *args, **kwargs)
return super().dispatch(request, *args, **kwargs)
def head(self, request, *args, **kwargs):
# Some ESPs verify the webhook with a HEAD request at configuration time
@@ -143,3 +101,51 @@ class AnymailBaseWebhookView(AnymailBasicAuthMixin, View):
"""
raise NotImplementedError("%s.%s must declare esp_name class attr" %
(self.__class__.__module__, self.__class__.__name__))
class AnymailBasicAuthMixin(AnymailCoreWebhookView):
"""Implements webhook basic auth as mixin to AnymailCoreWebhookView."""
# Whether to warn if basic auth is not configured.
# For most ESPs, basic auth is the only webhook security,
# so the default is True. Subclasses can set False if
# they enforce other security (like signed webhooks).
warn_if_no_basic_auth = True
# List of allowable HTTP basic-auth 'user:pass' strings.
basic_auth = None # (Declaring class attr allows override by kwargs in View.as_view.)
def __init__(self, **kwargs):
self.basic_auth = get_anymail_setting('webhook_secret', default=[],
kwargs=kwargs) # no esp_name -- auth is shared between ESPs
# Allow a single string:
if isinstance(self.basic_auth, str):
self.basic_auth = [self.basic_auth]
if self.warn_if_no_basic_auth and len(self.basic_auth) < 1:
warnings.warn(
"Your Anymail webhooks are insecure and open to anyone on the web. "
"You should set WEBHOOK_SECRET in your ANYMAIL settings. "
"See 'Securing webhooks' in the Anymail docs.",
AnymailInsecureWebhookWarning)
super().__init__(**kwargs)
def validate_request(self, request):
"""If configured for webhook basic auth, validate request has correct auth."""
if self.basic_auth:
request_auth = get_request_basic_auth(request)
# Use constant_time_compare to avoid timing attack on basic auth. (It's OK that any()
# can terminate early: we're not trying to protect how many auth strings are allowed,
# just the contents of each individual auth string.)
auth_ok = any(constant_time_compare(request_auth, allowed_auth)
for allowed_auth in self.basic_auth)
if not auth_ok:
raise AnymailWebhookValidationFailure(
"Missing or invalid basic auth in Anymail %s webhook" % self.esp_name)
class AnymailBaseWebhookView(AnymailBasicAuthMixin, AnymailCoreWebhookView):
"""
Abstract base class for most webhook views, enforcing HTTP basic auth security
"""
pass

View File

@@ -30,11 +30,11 @@ class MailgunBaseWebhookView(AnymailBaseWebhookView):
kwargs=kwargs, allow_bare=True, default=None)
webhook_signing_key = get_anymail_setting('webhook_signing_key', esp_name=self.esp_name,
kwargs=kwargs, default=UNSET if api_key is None else api_key)
self.webhook_signing_key = webhook_signing_key.encode('ascii') # hmac.new requires bytes key in python 3
super(MailgunBaseWebhookView, self).__init__(**kwargs)
self.webhook_signing_key = webhook_signing_key.encode('ascii') # hmac.new requires bytes key
super().__init__(**kwargs)
def validate_request(self, request):
super(MailgunBaseWebhookView, self).validate_request(request) # first check basic auth if enabled
super().validate_request(request) # first check basic auth if enabled
if request.content_type == "application/json":
# New-style webhook: json payload with separate signature block
try:
@@ -45,8 +45,7 @@ class MailgunBaseWebhookView(AnymailBaseWebhookView):
signature = signature_block['signature']
except (KeyError, ValueError, UnicodeDecodeError) as err:
raise AnymailWebhookValidationFailure(
"Mailgun webhook called with invalid payload format",
raised_from=err)
"Mailgun webhook called with invalid payload format") from err
else:
# Legacy webhook: signature fields are interspersed with other POST data
try:
@@ -54,9 +53,10 @@ class MailgunBaseWebhookView(AnymailBaseWebhookView):
# (Fortunately, Django QueryDict is specced to return the last value.)
token = request.POST['token']
timestamp = request.POST['timestamp']
signature = str(request.POST['signature']) # force to same type as hexdigest() (for python2)
except KeyError:
raise AnymailWebhookValidationFailure("Mailgun webhook called without required security fields")
signature = request.POST['signature']
except KeyError as err:
raise AnymailWebhookValidationFailure(
"Mailgun webhook called without required security fields") from err
expected_signature = hmac.new(key=self.webhook_signing_key, msg='{}{}'.format(timestamp, token).encode('ascii'),
digestmod=hashlib.sha256).hexdigest()

View File

@@ -7,14 +7,14 @@ from base64 import b64encode
from django.utils.crypto import constant_time_compare
from django.utils.timezone import utc
from .base import AnymailBaseWebhookView
from .base import AnymailBaseWebhookView, AnymailCoreWebhookView
from ..exceptions import AnymailWebhookValidationFailure
from ..inbound import AnymailInboundMessage
from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType
from ..utils import get_anymail_setting, getfirst, get_request_uri
class MandrillSignatureMixin(object):
class MandrillSignatureMixin(AnymailCoreWebhookView):
"""Validates Mandrill webhook signature"""
# These can be set from kwargs in View.as_view, or pulled from settings in init:
@@ -22,29 +22,26 @@ class MandrillSignatureMixin(object):
webhook_url = None # optional; defaults to actual url used
def __init__(self, **kwargs):
# noinspection PyUnresolvedReferences
esp_name = self.esp_name
# webhook_key is required for POST, but not for HEAD when Mandrill validates webhook url.
# Defer "missing setting" error until we actually try to use it in the POST...
webhook_key = get_anymail_setting('webhook_key', esp_name=esp_name, default=None,
kwargs=kwargs, allow_bare=True)
if webhook_key is not None:
self.webhook_key = webhook_key.encode('ascii') # hmac.new requires bytes key in python 3
self.webhook_key = webhook_key.encode('ascii') # hmac.new requires bytes key
self.webhook_url = get_anymail_setting('webhook_url', esp_name=esp_name, default=None,
kwargs=kwargs, allow_bare=True)
# noinspection PyArgumentList
super(MandrillSignatureMixin, self).__init__(**kwargs)
super().__init__(**kwargs)
def validate_request(self, request):
if self.webhook_key is None:
# issue deferred "missing setting" error (re-call get-setting without a default)
# noinspection PyUnresolvedReferences
get_anymail_setting('webhook_key', esp_name=self.esp_name, allow_bare=True)
try:
signature = request.META["HTTP_X_MANDRILL_SIGNATURE"]
except KeyError:
raise AnymailWebhookValidationFailure("X-Mandrill-Signature header missing from webhook POST")
raise AnymailWebhookValidationFailure("X-Mandrill-Signature header missing from webhook POST") from None
# Mandrill signs the exact URL (including basic auth, if used) plus the sorted POST params:
url = self.webhook_url or get_request_uri(request)

View File

@@ -1,12 +1,13 @@
import json
from datetime import datetime
from email.parser import BytesParser
from email.policy import default as default_policy
from django.utils.timezone import utc
from .base import AnymailBaseWebhookView
from .._email_compat import EmailBytesParser
from ..inbound import AnymailInboundMessage
from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason
from ..signals import AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason, inbound, tracking
class SendGridTrackingWebhookView(AnymailBaseWebhookView):
@@ -204,7 +205,7 @@ class SendGridInboundWebhookView(AnymailBaseWebhookView):
b"\r\n\r\n",
request.body
])
parsed_parts = EmailBytesParser().parsebytes(raw_data).get_payload()
parsed_parts = BytesParser(policy=default_policy).parsebytes(raw_data).get_payload()
for part in parsed_parts:
name = part.get_param('name', header='content-disposition')
if name == 'text':

View File

@@ -40,7 +40,8 @@ class SparkPostBaseWebhookView(AnymailBaseWebhookView):
# Empty event (SparkPost sometimes sends as a "ping")
event_class = event = None
else:
raise TypeError("Invalid SparkPost webhook event has multiple event classes: %r" % raw_event)
raise TypeError(
"Invalid SparkPost webhook event has multiple event classes: %r" % raw_event) from None
return event_class, event, raw_event
def esp_to_anymail_event(self, event_class, event, raw_event):