mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-23 13:11:04 -05:00
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:
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'"')
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
110
anymail/utils.py
110
anymail/utils.py
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user