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,5 +1,5 @@
sudo: false
language: python
os: linux
dist: xenial
branches:
@@ -15,9 +15,9 @@ env:
# Let Travis report failures that tox.ini would normally ignore:
- TOX_FORCE_IGNORE_OUTCOME=false
matrix:
jobs:
include:
- python: 3.6
- python: 3.8
env: TOXENV="lint,docs"
# Anymail supports the same Python versions as Django, plus PyPy.
@@ -26,12 +26,6 @@ matrix:
# Live API integration tests are only run on a few, representative Python/Django version
# combinations, to avoid rapidly consuming the testing accounts' entire send allotments.
# Django 1.11: Python 2.7, 3.4, 3.5, or 3.6
- { env: TOXENV=django111-py27-all RUN_LIVE_TESTS=true, python: 2.7 }
- { env: TOXENV=django111-py34-all, python: 3.4 }
- { env: TOXENV=django111-py35-all, python: 3.5 }
- { env: TOXENV=django111-py36-all, python: 3.6 }
- { env: TOXENV=django111-pypy-all, python: pypy2.7-6.0 }
# Django 2.0: Python 3.5+
- { env: TOXENV=django20-py35-all, python: 3.5 }
- { env: TOXENV=django20-py36-all, python: 3.6 }
@@ -44,7 +38,7 @@ matrix:
# Django 2.2: Python 3.5, 3.6, or 3.7
- { env: TOXENV=django22-py35-all, python: 3.5 }
- { env: TOXENV=django22-py36-all, python: 3.6 }
- { env: TOXENV=django22-py37-all RUN_LIVE_TESTS=true, python: 3.7 }
- { env: TOXENV=django22-py37-all, python: 3.7 }
- { env: TOXENV=django22-pypy3-all, python: pypy3 }
# Django 3.0: Python 3.6, 3.7, or 3.8
- { env: TOXENV=django30-py36-all, python: 3.6 }
@@ -54,16 +48,15 @@ matrix:
# Django 3.1: Python 3.6, 3.7, or 3.8
- { env: TOXENV=django31-py36-all, python: 3.6 }
- { env: TOXENV=django31-py37-all, python: 3.7 }
- { env: TOXENV=django31-py38-all, python: 3.8 }
- { env: TOXENV=django31-py38-all RUN_LIVE_TESTS=true, python: 3.8 }
- { env: TOXENV=django31-pypy3-all, python: pypy3 }
# Django current development (direct from GitHub source main branch):
- { env: TOXENV=djangoDev-py37-all, python: 3.7 }
# Install without optional extras (don't need to cover entire matrix)
- { env: TOXENV=django22-py37-none, python: 3.7 }
- { env: TOXENV=django22-py37-amazon_ses, python: 3.7 }
- { env: TOXENV=django22-py37-sparkpost, python: 3.7 }
- { env: TOXENV=django31-py37-none, python: 3.7 }
- { env: TOXENV=django31-py37-amazon_ses, python: 3.7 }
- { env: TOXENV=django31-py37-sparkpost, python: 3.7 }
# Test some specific older package versions
- { env: TOXENV=django111-py27-all-old_urllib3, python: 3.7 }
- { env: TOXENV=django22-py37-all-old_urllib3, python: 3.7 }
allow_failures:

View File

@@ -25,6 +25,30 @@ Release history
^^^^^^^^^^^^^^^
.. This extra heading level keeps the ToC from becoming unmanageably long
vNext
-----
*Unreleased changes in development*
Breaking changes
~~~~~~~~~~~~~~~~
* Drop support for Django versions older than Django 2.0, and for Python 2.7.
(For compatibility with Django 1.11, stay on the Anymail `v7.2 LTS`_
extended support branch by setting your requirements to `django-anymail~=7.2`.)
* Remove Anymail internal code related to supporting Python 2 and older Django
versions. This does not change the documented API, but may affect you if your
code borrowed from Anymail's undocumented internals. (You should be able to switch
to the Python standard library equivalents, as Anymail has done.)
* AnymailMessageMixin now correctly subclasses Django's EmailMessage. If you use it
as part of your own custom EmailMessage-derived class, and you start getting errors
about "consistent method resolution order," you probably need to change your class's
inheritance. (For some helpful background, see this comment about
`mixin superclass ordering <https://nedbatchelder.com/blog/201210/multiple_inheritance_is_hard.html#comment_13805>`__.)
v7.2 LTS
--------

View File

@@ -10,7 +10,6 @@ name = "pypi"
boto3 = "*"
django = "*"
requests = "*"
six = "*"
sparkpost = "*"
[dev-packages]

View File

@@ -40,9 +40,14 @@ built-in `django.core.mail` package. It includes:
* Inbound message support, to receive email through your ESP's webhooks,
with simplified, portable access to attachments and other inbound content
Anymail is released under the BSD license. It is extensively tested against
Django 1.11--3.0 on all Python versions supported by Django.
Anymail releases follow `semantic versioning <http://semver.org/>`_.
Anymail maintains compatibility with all Django versions that are in mainstream
or extended support, plus (usually) a few older Django versions, and is extensively
tested on all Python versions supported by Django. (Even-older Django versions
may still be covered by an Anymail extended support release; consult the
`changelog <https://anymail.readthedocs.io/en/stable/changelog/>`_ for details.)
Anymail releases follow `semantic versioning <https://semver.org/>`_.
The package is released under the BSD license.
.. END shared-intro
@@ -124,7 +129,7 @@ or SparkPost or any other supported ESP where you see "mailgun":
msg = EmailMultiAlternatives(
subject="Please activate your account",
body="Click to activate your account: http://example.com/activate",
body="Click to activate your account: https://example.com/activate",
from_email="Example <admin@example.com>",
to=["New User <user1@example.com>", "account.manager@example.com"],
reply_to=["Helpdesk <support@example.com>"])
@@ -132,7 +137,7 @@ or SparkPost or any other supported ESP where you see "mailgun":
# Include an inline image in the html:
logo_cid = attach_inline_image_file(msg, "/path/to/logo.jpg")
html = """<img alt="Logo" src="cid:{logo_cid}">
<p>Please <a href="http://example.com/activate">activate</a>
<p>Please <a href="https://example.com/activate">activate</a>
your account</p>""".format(logo_cid=logo_cid)
msg.attach_alternative(html, "text/html")

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,21 +111,21 @@ 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)
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)
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.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):

View File

@@ -1,5 +1,3 @@
# -*- coding: utf-8 -*-
#
# Anymail documentation build configuration file, created by
# sphinx-quickstart
#
@@ -50,9 +48,9 @@ source_suffix = '.rst'
master_doc = 'index'
# General information about the project.
project = u'Anymail'
project = 'Anymail'
# noinspection PyShadowingBuiltins
copyright = u'Anymail contributors (see AUTHORS.txt)'
copyright = 'Anymail contributors (see AUTHORS.txt)'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
@@ -203,8 +201,8 @@ latex_elements = {
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title, author, documentclass [howto/manual]).
latex_documents = [
('index', 'Anymail.tex', u'Anymail Documentation',
u'Anymail contributors (see AUTHORS.txt)', 'manual'),
('index', 'Anymail.tex', 'Anymail Documentation',
'Anymail contributors (see AUTHORS.txt)', 'manual'),
]
# The name of an image file (relative to this directory) to place at the top of
@@ -233,8 +231,8 @@ latex_documents = [
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
('index', 'anymail', u'Anymail Documentation',
[u'Anymail contributors (see AUTHORS.txt)'], 1)
('index', 'anymail', 'Anymail Documentation',
['Anymail contributors (see AUTHORS.txt)'], 1)
]
# If true, show URL addresses after external links.
@@ -247,8 +245,8 @@ man_pages = [
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
('index', 'Anymail', u'Anymail Documentation',
u'Anymail contributors (see AUTHORS.txt)', 'Anymail', 'Multi-ESP transactional email for Django.',
('index', 'Anymail', 'Anymail Documentation',
'Anymail contributors (see AUTHORS.txt)', 'Anymail', 'Multi-ESP transactional email for Django.',
'Miscellaneous'),
]
@@ -270,14 +268,9 @@ extlinks = {
# -- Options for Intersphinx ------------------------------------------------
intersphinx_mapping = {
'python': ('https://docs.python.org/3.6', None),
'python': ('https://docs.python.org/3.7', None),
'django': ('https://docs.djangoproject.com/en/stable/', 'https://docs.djangoproject.com/en/stable/_objects/'),
# Requests docs may be moving (Sep 2019):
# see https://github.com/psf/requests/issues/5212
# and https://github.com/psf/requests/issues/5214
'requests': ('https://docs.python-requests.org/en/latest/',
('https://docs.python-requests.org/en/latest/objects.inv',
'https://requests.kennethreitz.org/en/latest/objects.inv')),
'requests': ('https://requests.readthedocs.io/en/stable/', None),
}

View File

@@ -71,7 +71,7 @@ and Python versions. Tests are run at least once a week, to check whether ESP AP
and other dependencies have changed out from under Anymail.
For local development, the recommended test command is
:shell:`tox -e django22-py37-all,django111-py27-all,lint`, which tests a representative
:shell:`tox -e django31-py38-all,django20-py35-all,lint`, which tests a representative
combination of Python and Django versions. It also runs :pypi:`flake8` and other
code-style checkers. Some other test options are covered below, but using this
tox command catches most problems, and is a good pre-pull-request check.
@@ -98,16 +98,16 @@ Or:
$ python runtests.py tests.test_mailgun_backend tests.test_mailgun_webhooks
Or to test against multiple versions of Python and Django all at once, use :pypi:`tox`.
You'll need at least Python 2.7 and Python 3.6 available. (If your system doesn't come
with those, `pyenv`_ is a helpful way to install and manage multiple Python versions.)
You'll need some version of Python 3 available. (If your system doesn't come
with that, `pyenv`_ is a helpful way to install and manage multiple Python versions.)
.. code-block:: console
$ pip install tox # (if you haven't already)
$ tox -e django21-py36-all,django111-py27-all,lint # test recommended environments
$ tox -e django31-py38-all,django20-py35-all,lint # test recommended environments
## you can also run just some test cases, e.g.:
$ tox -e django21-py36-all,django111-py27-all tests.test_mailgun_backend tests.test_utils
$ tox -e django31-py38-all,django20-py35-all tests.test_mailgun_backend tests.test_utils
## to test more Python/Django versions:
$ tox --parallel auto # ALL 20+ envs! (in parallel if possible)
@@ -121,7 +121,7 @@ API keys or other settings. For example:
$ export MAILGUN_TEST_API_KEY='your-Mailgun-API-key'
$ export MAILGUN_TEST_DOMAIN='mail.example.com' # sending domain for that API key
$ tox -e django21-py36-all tests.test_mailgun_integration
$ tox -e django31-py38-all tests.test_mailgun_integration
Check the ``*_integration_tests.py`` files in the `tests source`_ to see which variables
are required for each ESP. Depending on the supported features, the integration tests for
@@ -180,7 +180,7 @@ Anymail's Sphinx conf sets up a few enhancements you can use in the docs:
.. _Django's added markup:
https://docs.djangoproject.com/en/stable/internals/contributing/writing-documentation/#django-specific-markup
.. _extlinks: http://www.sphinx-doc.org/en/stable/ext/extlinks.html
.. _intersphinx: http://www.sphinx-doc.org/en/master/ext/intersphinx.html
.. _extlinks: https://www.sphinx-doc.org/en/stable/usage/extensions/extlinks.html
.. _intersphinx: https://www.sphinx-doc.org/en/stable/usage/extensions/intersphinx.html
.. _Writing Documentation:
https://docs.djangoproject.com/en/stable/internals/contributing/writing-documentation/

View File

@@ -425,9 +425,9 @@ The event's :attr:`~anymail.signals.AnymailTrackingEvent.esp_event` field will b
the parsed `Mailgun webhook payload`_ as a Python `dict` with ``"signature"`` and
``"event-data"`` keys.
Anymail uses Mailgun's webhook `token` as its normalized
Anymail uses Mailgun's webhook ``token`` as its normalized
:attr:`~anymail.signals.AnymailTrackingEvent.event_id`, rather than Mailgun's
event-data `id` (which is only guaranteed to be unique during a single day).
event-data ``id`` (which is only guaranteed to be unique during a single day).
If you need the event-data id, it can be accessed in your webhook handler as
``event.esp_event["event-data"]["id"]``. (This can be helpful for working with
Mailgun's other event APIs.)

View File

@@ -3,7 +3,7 @@
Mandrill
========
Anymail integrates with the `Mandrill <http://mandrill.com/>`__
Anymail integrates with the `Mandrill <https://mandrill.com/>`__
transactional email service from MailChimp.
.. note:: **Limited Support for Mandrill**

View File

@@ -29,9 +29,10 @@ often help you pinpoint the problem...
**Double-check common issues**
* Did you add any required settings for your ESP to the `ANYMAIL` dict in your
settings.py? (E.g., ``"SENDGRID_API_KEY"`` for SendGrid.) See :ref:`supported-esps`.
settings.py? (E.g., ``"SENDGRID_API_KEY"`` for SendGrid.) Check the instructions
for the ESP you're using under :ref:`supported-esps`.
* Did you add ``'anymail'`` to the list of :setting:`INSTALLED_APPS` in settings.py?
* Are you using a valid from address? Django's default is "webmaster@localhost",
* Are you using a valid *from* address? Django's default is "webmaster@localhost",
which most ESPs reject. Either specify the ``from_email`` explicitly on every message
you send, or add :setting:`DEFAULT_FROM_EMAIL` to your settings.py.
@@ -61,8 +62,8 @@ Support
If you've gone through the troubleshooting above and still aren't sure what's wrong,
the Anymail community is happy to help. Anymail is supported and maintained by the
people who use it---like you! (The vast majority of Anymail contributors volunteer
their time, and are not employees of any ESP.)
people who use it---like you! (Anymail contributors volunteer their time, and are
not employees of any ESP.)
Here's how to contact the Anymail community:

View File

@@ -13,7 +13,7 @@ If you didn't set up webhooks when first installing Anymail, you'll need to
(You should also review :ref:`securing-webhooks`.)
Once you've enabled webhooks, Anymail will send a ``anymail.signals.inbound``
custom Django :mod:`signal <django.dispatch>` for each ESP inbound message it receives.
custom Django :doc:`signal <django:topics/signals>` for each ESP inbound message it receives.
You can connect your own receiver function to this signal for further processing.
(This is very much like how Anymail handles :ref:`status tracking <event-tracking>`
events for sent messages. Inbound events just use a different signal receiver
@@ -73,7 +73,7 @@ invoke your signal receiver once, separately, for each message in the batch.
:ref:`user-supplied content security <django:user-uploaded-content-security>`.
.. _using python-magic:
http://blog.hayleyanderson.us/2015/07/18/validating-file-types-in-django/
https://blog.hayleyanderson.us/2015/07/18/validating-file-types-in-django/
.. _inbound-event:
@@ -90,7 +90,7 @@ Normalized inbound event
.. attribute:: message
An :class:`~anymail.inbound.AnymailInboundMessage` representing the email
that was received. Most of what you're interested in will be on this `message`
that was received. Most of what you're interested in will be on this :attr:`!message`
attribute. See the full details :ref:`below <inbound-message>`.
.. attribute:: event_type
@@ -290,8 +290,6 @@ Handling Inbound Attachments
Anymail converts each inbound attachment to a specialized MIME object with
additional methods for handling attachments and integrating with Django.
It also backports some helpful MIME methods from newer versions of Python
to all versions supported by Anymail.
The attachment objects in an AnymailInboundMessage's
:attr:`~AnymailInboundMessage.attachments` list and
@@ -346,8 +344,6 @@ have these methods:
.. method:: is_attachment()
Returns `True` for a (non-inline) attachment, `False` otherwise.
(Anymail back-ports Python 3.4.2's :meth:`~email.message.EmailMessage.is_attachment` method
to all supported versions.)
.. method:: is_inline_attachment()
@@ -360,9 +356,6 @@ have these methods:
:mailheader:`Content-Disposition` header. The return value should be either "inline"
or "attachment", or `None` if the attachment is somehow missing that header.
(Anymail back-ports Python 3.5's :meth:`~email.message.Message.get_content_disposition`
method to all supported versions.)
.. method:: get_content_text(charset=None, errors='replace')
Returns the content of the attachment decoded to Unicode text.
@@ -453,7 +446,7 @@ And they may then retry sending these "failed" events, which could
cause duplicate processing in your code.
If your signal receiver code might be slow, you should instead
queue the event for later, asynchronous processing (e.g., using
something like `Celery`_).
something like :pypi:`celery`).
If your signal receiver function is defined within some other
function or instance method, you *must* use the `weak=False`
@@ -461,5 +454,3 @@ option when connecting it. Otherwise, it might seem to work at first,
but will unpredictably stop being called at some point---typically
on your production server, in a hard-to-debug way. See Django's
docs on :doc:`signals <django:topics/signals>` for more information.
.. _Celery: http://www.celeryproject.org/

View File

@@ -142,15 +142,15 @@ If you want to use Anymail's inbound or tracking webhooks:
.. code-block:: python
from django.conf.urls import include, url
from django.urls import include, re_path
urlpatterns = [
...
url(r'^anymail/', include('anymail.urls')),
re_path(r'^anymail/', include('anymail.urls')),
]
(You can change the "anymail" prefix in the first parameter to
:func:`~django.conf.urls.url` if you'd like the webhooks to be served
:func:`~django.urls.re_path` if you'd like the webhooks to be served
at some other URL. Just match whatever you use in the webhook URL you give
your ESP in the next step.)
@@ -186,7 +186,7 @@ See :ref:`event-tracking` for information on creating signal handlers and the
status tracking events you can receive. See :ref:`inbound` for information on
receiving inbound message events.
.. _mod_wsgi: http://modwsgi.readthedocs.io/en/latest/configuration-directives/WSGIPassAuthorization.html
.. _mod_wsgi: https://modwsgi.readthedocs.io/en/latest/configuration-directives/WSGIPassAuthorization.html
.. setting:: ANYMAIL
@@ -227,10 +227,11 @@ if you are using other Django apps that work with the same ESP.)
Finally, for complex use cases, you can override most settings on a per-instance
basis by providing keyword args where the instance is initialized (e.g., in a
:func:`~django.core.mail.get_connection` call to create an email backend instance,
or in `View.as_view()` call to set up webhooks in a custom urls.py). To get the kwargs
or in a `View.as_view()` call to set up webhooks in a custom urls.py). To get the kwargs
parameter for a setting, drop "ANYMAIL" and the ESP name, and lowercase the rest:
e.g., you can override ANYMAIL_MAILGUN_API_KEY by passing `api_key="abc"` to
:func:`~django.core.mail.get_connection`. See :ref:`multiple-backends` for an example.
e.g., you can override ANYMAIL_MAILGUN_API_KEY for a particular connection by calling
``get_connection("anymail.backends.mailgun.EmailBackend", api_key="abc")``.
See :ref:`multiple-backends` for an example.
There are specific Anymail settings for each ESP (like API keys and urls).
See the :ref:`supported ESPs <supported-esps>` section for details.
@@ -253,7 +254,7 @@ See :ref:`recipients-refused`.
}
.. rubric:: SEND_DEFAULTS and *ESP*\ _SEND_DEFAULTS`
.. rubric:: SEND_DEFAULTS and *ESP*\ _SEND_DEFAULTS
A `dict` of default options to apply to all messages sent through Anymail.
See :ref:`send-defaults`.

View File

@@ -26,18 +26,18 @@ The first approach is usually the simplest. The other two can be
helpful if you are working with Python development tools that
offer type checking or other static code analysis.
Availability of these features varies by ESP, and there may be additional
limitations even when an ESP does support a particular feature. Be sure
to check Anymail's docs for your :ref:`specific ESP <supported-esps>`.
If you try to use a feature your ESP does not offer, Anymail will raise
an :ref:`unsupported feature <unsupported-features>` error.
.. _anymail-send-options:
ESP send options (AnymailMessage)
---------------------------------
Availability of each of these features varies by ESP, and there may be additional
limitations even when an ESP does support a particular feature. Be sure
to check Anymail's docs for your :ref:`specific ESP <supported-esps>`.
If you try to use a feature your ESP does not offer, Anymail will raise
an :ref:`unsupported feature <unsupported-features>` error.
.. class:: AnymailMessage
A subclass of Django's :class:`~django.core.mail.EmailMultiAlternatives`
@@ -167,7 +167,7 @@ ESP send options (AnymailMessage)
ESPs have differing restrictions on tags. For portability,
it's best to stick with strings that start with an alphanumeric
character. (Also, Postmark only allows a single tag per message.)
character. (Also, a few ESPs allow only a single tag per message.)
.. caution::
@@ -359,7 +359,7 @@ ESP send status
* `'queued'` the ESP has accepted the message
and will try to send it asynchronously
* `'invalid'` the ESP considers the sender or recipient email invalid
* `'rejected'` the recipient is on an ESP blacklist
* `'rejected'` the recipient is on an ESP suppression list
(unsubscribe, previous bounces, etc.)
* `'failed'` the attempt to send failed for some other reason
* `'unknown'` anything else
@@ -402,7 +402,8 @@ ESP send status
.. code-block:: python
# This will work with a requests-based backend:
# This will work with a requests-based backend,
# for an ESP whose send API provides a JSON response:
message.anymail_status.esp_response.json()

View File

@@ -10,7 +10,7 @@ email using Django's default SMTP :class:`~django.core.mail.backends.smtp.EmailB
switching to Anymail will be easy. Anymail is designed to "just work" with Django.
If you're not familiar with Django's email functions, please take a look at
":mod:`sending email <django.core.mail>`" in the Django docs first.
:doc:`django:topics/email` in the Django docs first.
Anymail supports most of the functionality of Django's :class:`~django.core.mail.EmailMessage`
and :class:`~django.core.mail.EmailMultiAlternatives` classes.
@@ -39,8 +39,8 @@ function with the ``html_message`` parameter:
send_mail("Subject", "text body", "from@example.com",
["to@example.com"], html_message="<html>html body</html>")
However, many Django email capabilities -- and additional Anymail features --
are only available when working with an :class:`~django.core.mail.EmailMultiAlternatives`
However, many Django email capabilities---and additional Anymail features---are only
available when working with an :class:`~django.core.mail.EmailMultiAlternatives`
object. Use its :meth:`~django.core.mail.EmailMultiAlternatives.attach_alternative`
method to send HTML:
@@ -168,7 +168,8 @@ raise :exc:`~exceptions.AnymailUnsupportedFeature`.
.. setting:: ANYMAIL_IGNORE_UNSUPPORTED_FEATURES
If you'd like to silently ignore :exc:`~exceptions.AnymailUnsupportedFeature`
errors and send the messages anyway, set :setting:`!ANYMAIL_IGNORE_UNSUPPORTED_FEATURES`
errors and send the messages anyway, set
:setting:`"IGNORE_UNSUPPORTED_FEATURES" <ANYMAIL_IGNORE_UNSUPPORTED_FEATURES>`
to `True` in your settings.py:
.. code-block:: python
@@ -197,15 +198,16 @@ If a single message is sent to multiple recipients, and *any* recipient is valid
You can still examine the message's :attr:`~message.AnymailMessage.anymail_status`
property after the send to determine the status of each recipient.
You can disable this exception by setting :setting:`ANYMAIL_IGNORE_RECIPIENT_STATUS`
to `True` in your settings.py, which will cause Anymail to treat any non-API-error response
from your ESP as a successful send.
You can disable this exception by setting
:setting:`"IGNORE_RECIPIENT_STATUS" <ANYMAIL_IGNORE_RECIPIENT_STATUS>` to `True` in
your settings.py `ANYMAIL` dict, which will cause Anymail to treat *any*
response from your ESP (other than an API error) as a successful send.
.. note::
Many ESPs don't check recipient status during the send API call. For example,
Most ESPs don't check recipient status during the send API call. For example,
Mailgun always queues sent messages, so you'll never catch
:exc:`AnymailRecipientsRefused` with the Mailgun backend.
For those ESPs, use Anymail's :ref:`delivery event tracking <event-tracking>`
if you need to be notified of sends to blacklisted or invalid emails.
You can use Anymail's :ref:`delivery event tracking <event-tracking>`
if you need to be notified of sends to suppression-listed or invalid emails.

View File

@@ -211,7 +211,7 @@ for use as merge data:
# Do something this instead:
message.merge_global_data = {
'PRODUCT': product.name, # assuming name is a CharField
'TOTAL_COST': "%.2f" % total_cost,
'TOTAL_COST': "{cost:0.2f}".format(cost=total_cost),
'SHIP_DATE': ship_date.strftime('%B %d, %Y') # US-style "March 15, 2015"
}

View File

@@ -14,7 +14,7 @@ Webhook support is optional. If you haven't yet, you'll need to
project. (You may also want to review :ref:`securing-webhooks`.)
Once you've enabled webhooks, Anymail will send an ``anymail.signals.tracking``
custom Django :mod:`signal <django.dispatch>` for each ESP tracking event it receives.
custom Django :doc:`signal <django:topics/signals>` for each ESP tracking event it receives.
You can connect your own receiver function to this signal for further processing.
Be sure to read Django's `listening to signals`_ docs for information on defining
@@ -189,8 +189,8 @@ Normalized tracking event
.. attribute:: mta_response
If available, a `str` with a raw (intended for email administrators) response
from the receiving MTA. Otherwise `None`. Often includes SMTP response codes,
but the exact format varies by ESP (and sometimes receiving MTA).
from the receiving mail transfer agent. Otherwise `None`. Often includes SMTP
response codes, but the exact format varies by ESP (and sometimes receiving MTA).
.. attribute:: user_agent
@@ -203,7 +203,7 @@ Normalized tracking event
.. attribute:: esp_event
The "raw" event data from the ESP, deserialized into a python data structure.
The "raw" event data from the ESP, deserialized into a Python data structure.
For most ESPs this is either parsed JSON (as a `dict`), or HTTP POST fields
(as a Django :class:`~django.http.QueryDict`).
@@ -230,7 +230,7 @@ Your Anymail signal receiver must be a function with this signature:
:param AnymailTrackingEvent event: The normalized tracking event.
Almost anything you'd be interested in
will be in here.
:param str esp_name: e.g., "SendMail" or "Postmark". If you are working
:param str esp_name: e.g., "SendGrid" or "Postmark". If you are working
with multiple ESPs, you can use this to distinguish
ESP-specific handling in your shared event processing.
:param \**kwargs: Required by Django's signal mechanism
@@ -259,7 +259,7 @@ And will retry sending the "failed" events, which could cause duplicate
processing in your code.
If your signal receiver code might be slow, you should instead
queue the event for later, asynchronous processing (e.g., using
something like `Celery`_).
something like :pypi:`celery`).
If your signal receiver function is defined within some other
function or instance method, you *must* use the `weak=False`
@@ -268,7 +268,6 @@ but will unpredictably stop being called at some point---typically
on your production server, in a hard-to-debug way. See Django's
`listening to signals`_ docs for more information.
.. _Celery: http://www.celeryproject.org/
.. _listening to signals:
https://docs.djangoproject.com/en/stable/topics/signals/#listening-to-signals

View File

@@ -7,7 +7,7 @@ ESP's templating languages and merge capabilities are generally not compatible
with each other, which can make it hard to move email templates between them.
But since you're working in Django, you already have access to the
extremely-full-featured :mod:`Django templating system <django.template>`.
extremely-full-featured :doc:`Django templating system <django:topics/templates>`.
You don't even have to use Django's template syntax: it supports other
template languages (like Jinja2).
@@ -15,7 +15,7 @@ You're probably already using Django's templating system for your HTML pages,
so it can be an easy decision to use it for your email, too.
To compose email using *Django* templates, you can use Django's
:func:`~django.template.loaders.django.template.loader.render_to_string`
:func:`~django.template.loader.render_to_string`
template shortcut to build the body and html.
Example that builds an email from the templates ``message_subject.txt``,
@@ -24,16 +24,14 @@ Example that builds an email from the templates ``message_subject.txt``,
.. code-block:: python
from django.core.mail import EmailMultiAlternatives
from django.template import Context
from django.template.loader import render_to_string
merge_data = {
'ORDERNO': "12345", 'TRACKINGNO': "1Z987"
}
plaintext_context = Context(autoescape=False) # HTML escaping not appropriate in plaintext
subject = render_to_string("message_subject.txt", merge_data, plaintext_context)
text_body = render_to_string("message_body.txt", merge_data, plaintext_context)
subject = render_to_string("message_subject.txt", merge_data).strip()
text_body = render_to_string("message_body.txt", merge_data)
html_body = render_to_string("message_body.html", merge_data)
msg = EmailMultiAlternatives(subject=subject, from_email="store@example.com",
@@ -41,6 +39,9 @@ Example that builds an email from the templates ``message_subject.txt``,
msg.attach_alternative(html_body, "text/html")
msg.send()
Tip: use Django's :ttag:`{% autoescape off %}<autoescape>` template tag in your
plaintext ``.txt`` templates to avoid inappropriate HTML escaping.
Helpful add-ons
---------------
@@ -48,8 +49,6 @@ Helpful add-ons
These (third-party) packages can be helpful for building your email
in Django:
.. TODO: flesh this out
* :pypi:`django-templated-mail`, :pypi:`django-mail-templated`, or :pypi:`django-mail-templated-simple`
for building messages from sets of Django templates.
* :pypi:`premailer` for inlining css before sending

View File

@@ -73,10 +73,10 @@ Basic usage is covered in the
:ref:`webhooks configuration <webhooks-configuration>` docs.
If something posts to your webhooks without the required shared
secret as basic auth in the HTTP_AUTHORIZATION header, Anymail will
secret as basic auth in the HTTP *Authorization* header, Anymail will
raise an :exc:`AnymailWebhookValidationFailure` error, which is
a subclass of Django's :exc:`~django.core.exceptions.SuspiciousOperation`.
This will result in an HTTP 400 response, without further processing
This will result in an HTTP 400 "bad request" response, without further processing
the data or calling your signal receiver function.
In addition to a single "random:random" string, you can give a list

View File

@@ -4,7 +4,6 @@
# or
# runtests.py [tests.test_x tests.test_y.SomeTestCase ...]
from __future__ import print_function
import sys
from distutils.util import strtobool
@@ -33,7 +32,6 @@ def setup_and_run_tests(test_labels=None):
warnings.simplefilter('default') # show DeprecationWarning and other default-ignored warnings
# noinspection PyStringFormat
os.environ['DJANGO_SETTINGS_MODULE'] = \
'tests.test_settings.settings_%d_%d' % django.VERSION[:2]
django.setup()

View File

@@ -43,7 +43,7 @@ setup(
license="BSD License",
packages=["anymail"],
zip_safe=False,
install_requires=["django>=1.11", "requests>=2.4.3", "six"],
install_requires=["django>=2.0", "requests>=2.4.3"],
extras_require={
# This can be used if particular backends have unique dependencies.
# For simplicity, requests is included in the base requirements.
@@ -64,21 +64,21 @@ setup(
"Programming Language :: Python",
"Programming Language :: Python :: Implementation :: PyPy",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: 2",
"Programming Language :: Python :: 2.7",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.4",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"License :: OSI Approved :: BSD License",
"Topic :: Communications :: Email",
"Topic :: Software Development :: Libraries :: Python Modules",
"Intended Audience :: Developers",
"Framework :: Django",
"Framework :: Django :: 1.11",
"Framework :: Django :: 2.0",
"Framework :: Django :: 2.1",
"Framework :: Django :: 2.2",
"Framework :: Django :: 3.0",
"Framework :: Django :: 3.1",
"Environment :: Web Environment",
],
long_description=long_description,

View File

@@ -1,9 +1,9 @@
import json
from io import BytesIO
from django.core import mail
from django.test import SimpleTestCase
import requests
import six
from mock import patch
from anymail.exceptions import AnymailAPIError
@@ -13,7 +13,7 @@ from .utils import AnymailTestMixin
UNSET = object()
class RequestsBackendMockAPITestCase(SimpleTestCase, AnymailTestMixin):
class RequestsBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
"""TestCase that mocks API calls through requests"""
DEFAULT_RAW_RESPONSE = b"""{"subclass": "should override"}"""
@@ -22,15 +22,14 @@ class RequestsBackendMockAPITestCase(SimpleTestCase, AnymailTestMixin):
class MockResponse(requests.Response):
"""requests.request return value mock sufficient for testing"""
def __init__(self, status_code=200, raw=b"RESPONSE", encoding='utf-8', reason=None):
super(RequestsBackendMockAPITestCase.MockResponse, self).__init__()
super().__init__()
self.status_code = status_code
self.encoding = encoding
self.reason = reason or ("OK" if 200 <= status_code < 300 else "ERROR")
# six.BytesIO(None) returns b'None' in PY2 (rather than b'')
self.raw = six.BytesIO(raw) if raw is not None else six.BytesIO()
self.raw = BytesIO(raw)
def setUp(self):
super(RequestsBackendMockAPITestCase, self).setUp()
super().setUp()
self.patch_request = patch('requests.Session.request', autospec=True)
self.mock_request = self.patch_request.start()
self.addCleanup(self.patch_request.stop)
@@ -127,17 +126,25 @@ class RequestsBackendMockAPITestCase(SimpleTestCase, AnymailTestMixin):
raise AssertionError(msg or "ESP API was called and shouldn't have been")
# noinspection PyUnresolvedReferences
class SessionSharingTestCasesMixin(object):
"""Mixin that tests connection sharing in any RequestsBackendMockAPITestCase
class SessionSharingTestCases(RequestsBackendMockAPITestCase):
"""Common test cases for requests backend connection sharing.
(Contains actual test cases, so can't be included in RequestsBackendMockAPITestCase
itself, as that would re-run these tests several times for each backend, in
each TestCase for the backend.)
Instantiate for each ESP by:
- subclassing
- adding or overriding any tests as appropriate
"""
def __init__(self, methodName='runTest'):
if self.__class__ is SessionSharingTestCases:
# don't run these tests on the abstract base implementation
methodName = 'runNoTestsInBaseClass'
super().__init__(methodName)
def runNoTestsInBaseClass(self):
pass
def setUp(self):
super(SessionSharingTestCasesMixin, self).setUp()
super().setUp()
self.patch_close = patch('requests.Session.close', autospec=True)
self.mock_close = self.patch_close.start()
self.addCleanup(self.patch_close.stop)

View File

@@ -1,11 +1,7 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import json
from datetime import datetime
from email.mime.application import MIMEApplication
import six
from django.core import mail
from django.core.mail import BadHeaderError
from django.test import SimpleTestCase, override_settings, tag
@@ -19,11 +15,11 @@ from .utils import AnymailTestMixin, SAMPLE_IMAGE_FILENAME, sample_image_content
@tag('amazon_ses')
@override_settings(EMAIL_BACKEND='anymail.backends.amazon_ses.EmailBackend')
class AmazonSESBackendMockAPITestCase(SimpleTestCase, AnymailTestMixin):
class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
"""TestCase that uses the Amazon SES EmailBackend with a mocked boto3 client"""
def setUp(self):
super(AmazonSESBackendMockAPITestCase, self).setUp()
super().setUp()
# Mock boto3.session.Session().client('ses').send_raw_email (and any other client operations)
# (We could also use botocore.stub.Stubber, but mock works well with our test structure)
@@ -122,7 +118,7 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase):
# send_raw_email takes a fully-formatted MIME message.
# This is a simple (if inexact) way to check for expected headers and body:
raw_mime = params['RawMessage']['Data']
self.assertIsInstance(raw_mime, six.binary_type) # SendRawEmail expects Data as bytes
self.assertIsInstance(raw_mime, bytes) # SendRawEmail expects Data as bytes
self.assertIn(b"\nFrom: from@example.com\n", raw_mime)
self.assertIn(b"\nTo: to@example.com\n", raw_mime)
self.assertIn(b"\nSubject: Subject here\n", raw_mime)
@@ -259,7 +255,7 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase):
self.assertEqual(params['Source'], "from1@example.com")
def test_commas_in_subject(self):
"""Anymail works around a Python 2 email header bug that adds unwanted spaces after commas in long subjects"""
"""There used to be a Python email header bug that added unwanted spaces after commas in long subjects"""
self.message.subject = "100,000,000 isn't a number you'd really want to break up in this email subject, right?"
self.message.send()
sent_message = self.get_sent_message()

View File

@@ -1,5 +1,3 @@
from __future__ import unicode_literals
import json
from base64 import b64encode
from datetime import datetime
@@ -22,7 +20,7 @@ from .webhook_cases import WebhookTestCase
class AmazonSESInboundTests(WebhookTestCase, AmazonSESWebhookTestsMixin):
def setUp(self):
super(AmazonSESInboundTests, self).setUp()
super().setUp()
# Mock boto3.session.Session().client('s3').download_fileobj
# (We could also use botocore.stub.Stubber, but mock works well with our test structure)
self.patch_boto3_session = patch('anymail.webhooks.amazon_ses.boto3.session.Session', autospec=True)
@@ -263,9 +261,8 @@ class AmazonSESInboundTests(WebhookTestCase, AmazonSESWebhookTestsMixin):
self.assertEqual([str(to) for to in message.to],
['Recipient <inbound@example.com>', 'someone-else@example.org'])
self.assertEqual(message.subject, 'Test inbound message')
# rstrip below because the Python 3 EmailBytesParser converts \r\n to \n, but the Python 2 version doesn't
self.assertEqual(message.text.rstrip(), "It's a body\N{HORIZONTAL ELLIPSIS}")
self.assertEqual(message.html.rstrip(), """<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>""")
self.assertEqual(message.text, "It's a body\N{HORIZONTAL ELLIPSIS}\n")
self.assertEqual(message.html, """<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>\n""")
self.assertIsNone(message.spam_detected)
def test_inbound_s3_failure_message(self):

View File

@@ -1,6 +1,3 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os
import unittest
import warnings
@@ -12,11 +9,6 @@ from anymail.message import AnymailMessage
from .utils import AnymailTestMixin, sample_image_path
try:
ResourceWarning
except NameError:
ResourceWarning = Warning # Python 2
AMAZON_SES_TEST_ACCESS_KEY_ID = os.getenv("AMAZON_SES_TEST_ACCESS_KEY_ID")
AMAZON_SES_TEST_SECRET_ACCESS_KEY = os.getenv("AMAZON_SES_TEST_SECRET_ACCESS_KEY")
@@ -42,7 +34,7 @@ AMAZON_SES_TEST_REGION_NAME = os.getenv("AMAZON_SES_TEST_REGION_NAME", "us-east-
"AMAZON_SES_CONFIGURATION_SET_NAME": "TestConfigurationSet", # actual config set in Anymail test account
})
@tag('amazon_ses', 'live')
class AmazonSESBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
class AmazonSESBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"""Amazon SES API integration tests
These tests run against the **live** Amazon SES API, using the environment
@@ -63,14 +55,14 @@ class AmazonSESBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
"""
def setUp(self):
super(AmazonSESBackendIntegrationTests, self).setUp()
super().setUp()
self.message = AnymailMessage('Anymail Amazon SES integration test', 'Text content',
'test@test-ses.anymail.info', ['success@simulator.amazonses.com'])
self.message.attach_alternative('<p>HTML content</p>', "text/html")
# boto3 relies on GC to close connections. Python 3 warns about unclosed ssl.SSLSocket during cleanup.
# We don't care. (It might not be a real problem worth warning, but in any case it's not our problem.)
# https://www.google.com/search?q=unittest+boto3+ResourceWarning+unclosed+ssl.SSLSocket
# We don't care. (It may be a false positive, or it may be a botocore problem, but it's not *our* problem.)
# https://github.com/boto/boto3/issues/454#issuecomment-586033745
# Filter in TestCase.setUp because unittest resets the warning filters for each test.
# https://stackoverflow.com/a/26620811/647002
warnings.filterwarnings("ignore", message=r"unclosed <ssl\.SSLSocket", category=ResourceWarning)
@@ -154,8 +146,9 @@ class AmazonSESBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
}
})
def test_invalid_aws_credentials(self):
with self.assertRaises(AnymailAPIError) as cm:
self.message.send()
err = cm.exception
# Make sure the exception message includes AWS's response:
self.assertIn("The security token included in the request is invalid", str(err))
with self.assertRaisesMessage(
AnymailAPIError,
"The security token included in the request is invalid"
):
self.message.send()

View File

@@ -2,7 +2,7 @@ import json
import warnings
from datetime import datetime
from django.test import override_settings, tag
from django.test import SimpleTestCase, override_settings, tag
from django.utils.timezone import utc
from mock import ANY, patch
@@ -10,12 +10,11 @@ from anymail.exceptions import AnymailConfigurationError, AnymailInsecureWebhook
from anymail.signals import AnymailTrackingEvent
from anymail.webhooks.amazon_ses import AmazonSESTrackingWebhookView
from .webhook_cases import WebhookBasicAuthTestsMixin, WebhookTestCase
from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase
class AmazonSESWebhookTestsMixin(object):
class AmazonSESWebhookTestsMixin(SimpleTestCase):
def post_from_sns(self, path, raw_sns_message, **kwargs):
# noinspection PyUnresolvedReferences
return self.client.post(
path,
content_type='text/plain; charset=UTF-8', # SNS posts JSON as text/plain
@@ -27,12 +26,12 @@ class AmazonSESWebhookTestsMixin(object):
@tag('amazon_ses')
class AmazonSESWebhookSecurityTests(WebhookTestCase, AmazonSESWebhookTestsMixin, WebhookBasicAuthTestsMixin):
class AmazonSESWebhookSecurityTests(AmazonSESWebhookTestsMixin, WebhookBasicAuthTestCase):
def call_webhook(self):
return self.post_from_sns('/anymail/amazon_ses/tracking/',
{"Type": "Notification", "MessageId": "123", "Message": "{}"})
# Most actual tests are in WebhookBasicAuthTestsMixin
# Most actual tests are in WebhookBasicAuthTestCase
def test_verifies_missing_auth(self):
# Must handle missing auth header slightly differently from Anymail default 400 SuspiciousOperation:
@@ -412,7 +411,7 @@ class AmazonSESSubscriptionManagementTests(WebhookTestCase, AmazonSESWebhookTest
# (Note that WebhookTestCase sets up ANYMAIL WEBHOOK_SECRET.)
def setUp(self):
super(AmazonSESSubscriptionManagementTests, self).setUp()
super().setUp()
# Mock boto3.session.Session().client('sns').confirm_subscription (and any other client operations)
# (We could also use botocore.stub.Stubber, but mock works well with our test structure)
self.patch_boto3_session = patch('anymail.webhooks.amazon_ses.boto3.session.Session', autospec=True)

View File

@@ -14,7 +14,7 @@ class MinimalRequestsBackend(AnymailRequestsBackend):
api_url = "https://httpbin.org/post" # helpful echoback endpoint for live testing
def __init__(self, **kwargs):
super(MinimalRequestsBackend, self).__init__(self.api_url, **kwargs)
super().__init__(self.api_url, **kwargs)
def build_message_payload(self, message, defaults):
_payload_init = getattr(message, "_payload_init", {})
@@ -46,7 +46,7 @@ class RequestsBackendBaseTestCase(RequestsBackendMockAPITestCase):
"""Test common functionality in AnymailRequestsBackend"""
def setUp(self):
super(RequestsBackendBaseTestCase, self).setUp()
super().setUp()
self.message = AnymailMessage('Subject', 'Text Body', 'from@example.com', ['to@example.com'])
def test_minimal_requests_backend(self):
@@ -70,7 +70,7 @@ class RequestsBackendBaseTestCase(RequestsBackendMockAPITestCase):
@tag('live')
@override_settings(EMAIL_BACKEND='tests.test_base_backends.MinimalRequestsBackend')
class RequestsBackendLiveTestCase(SimpleTestCase, AnymailTestMixin):
class RequestsBackendLiveTestCase(AnymailTestMixin, SimpleTestCase):
@override_settings(ANYMAIL_DEBUG_API_REQUESTS=True)
def test_debug_logging(self):
message = AnymailMessage('Subject', 'Text Body', 'from@example.com', ['to@example.com'])

View File

@@ -7,7 +7,7 @@ from anymail.checks import check_deprecated_settings, check_insecure_settings
from .utils import AnymailTestMixin
class DeprecatedSettingsTests(SimpleTestCase, AnymailTestMixin):
class DeprecatedSettingsTests(AnymailTestMixin, SimpleTestCase):
@override_settings(ANYMAIL={"WEBHOOK_AUTHORIZATION": "abcde:12345"})
def test_webhook_authorization(self):
errors = check_deprecated_settings(None)
@@ -27,7 +27,7 @@ class DeprecatedSettingsTests(SimpleTestCase, AnymailTestMixin):
)])
class InsecureSettingsTests(SimpleTestCase, AnymailTestMixin):
class InsecureSettingsTests(AnymailTestMixin, SimpleTestCase):
@override_settings(ANYMAIL={"DEBUG_API_REQUESTS": True})
def test_debug_api_requests_deployed(self):
errors = check_insecure_settings(None)

View File

@@ -1,7 +1,6 @@
from datetime import datetime
from email.mime.text import MIMEText
import six
from django.core import mail
from django.core.exceptions import ImproperlyConfigured
from django.core.mail import get_connection, send_mail
@@ -9,7 +8,7 @@ from django.test import SimpleTestCase
from django.test.utils import override_settings
from django.utils.functional import Promise
from django.utils.timezone import utc
from django.utils.translation import ugettext_lazy
from django.utils.translation import gettext_lazy
from anymail.backends.test import EmailBackend as TestBackend, TestPayload
from anymail.exceptions import AnymailConfigurationError, AnymailInvalidAddress, AnymailUnsupportedFeature
@@ -29,15 +28,15 @@ class SettingsTestBackend(TestBackend):
default=None, allow_bare=True)
self.password = get_anymail_setting('password', esp_name=esp_name, kwargs=kwargs,
default=None, allow_bare=True)
super(SettingsTestBackend, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
@override_settings(EMAIL_BACKEND='anymail.backends.test.EmailBackend')
class TestBackendTestCase(SimpleTestCase, AnymailTestMixin):
class TestBackendTestCase(AnymailTestMixin, SimpleTestCase):
"""Base TestCase using Anymail's Test EmailBackend"""
def setUp(self):
super(TestBackendTestCase, self).setUp()
super().setUp()
# Simple message useful for many tests
self.message = AnymailMessage('Subject', 'Text Body', 'from@example.com', ['to@example.com'])
@@ -237,7 +236,7 @@ class SendDefaultsTests(TestBackendTestCase):
class LazyStringsTest(TestBackendTestCase):
"""
Tests ugettext_lazy strings forced real before passing to ESP transport.
Tests gettext_lazy strings forced real before passing to ESP transport.
Docs notwithstanding, Django lazy strings *don't* work anywhere regular
strings would. In particular, they aren't instances of unicode/str.
@@ -251,38 +250,38 @@ class LazyStringsTest(TestBackendTestCase):
def assertNotLazy(self, s, msg=None):
self.assertNotIsInstance(s, Promise,
msg=msg or "String %r is lazy" % six.text_type(s))
msg=msg or "String %r is lazy" % str(s))
def test_lazy_from(self):
# This sometimes ends up lazy when settings.DEFAULT_FROM_EMAIL is meant to be localized
self.message.from_email = ugettext_lazy(u'"Global Sales" <sales@example.com>')
self.message.from_email = gettext_lazy('"Global Sales" <sales@example.com>')
self.message.send()
params = self.get_send_params()
self.assertNotLazy(params['from'].address)
def test_lazy_subject(self):
self.message.subject = ugettext_lazy("subject")
self.message.subject = gettext_lazy("subject")
self.message.send()
params = self.get_send_params()
self.assertNotLazy(params['subject'])
def test_lazy_body(self):
self.message.body = ugettext_lazy("text body")
self.message.attach_alternative(ugettext_lazy("html body"), "text/html")
self.message.body = gettext_lazy("text body")
self.message.attach_alternative(gettext_lazy("html body"), "text/html")
self.message.send()
params = self.get_send_params()
self.assertNotLazy(params['text_body'])
self.assertNotLazy(params['html_body'])
def test_lazy_headers(self):
self.message.extra_headers['X-Test'] = ugettext_lazy("Test Header")
self.message.extra_headers['X-Test'] = gettext_lazy("Test Header")
self.message.send()
params = self.get_send_params()
self.assertNotLazy(params['extra_headers']['X-Test'])
def test_lazy_attachments(self):
self.message.attach(ugettext_lazy("test.csv"), ugettext_lazy("test,csv,data"), "text/csv")
self.message.attach(MIMEText(ugettext_lazy("contact info")))
self.message.attach(gettext_lazy("test.csv"), gettext_lazy("test,csv,data"), "text/csv")
self.message.attach(MIMEText(gettext_lazy("contact info")))
self.message.send()
params = self.get_send_params()
self.assertNotLazy(params['attachments'][0].name)
@@ -290,22 +289,22 @@ class LazyStringsTest(TestBackendTestCase):
self.assertNotLazy(params['attachments'][1].content)
def test_lazy_tags(self):
self.message.tags = [ugettext_lazy("Shipping"), ugettext_lazy("Sales")]
self.message.tags = [gettext_lazy("Shipping"), gettext_lazy("Sales")]
self.message.send()
params = self.get_send_params()
self.assertNotLazy(params['tags'][0])
self.assertNotLazy(params['tags'][1])
def test_lazy_metadata(self):
self.message.metadata = {'order_type': ugettext_lazy("Subscription")}
self.message.metadata = {'order_type': gettext_lazy("Subscription")}
self.message.send()
params = self.get_send_params()
self.assertNotLazy(params['metadata']['order_type'])
def test_lazy_merge_data(self):
self.message.merge_data = {
'to@example.com': {'duration': ugettext_lazy("One Month")}}
self.message.merge_global_data = {'order_type': ugettext_lazy("Subscription")}
'to@example.com': {'duration': gettext_lazy("One Month")}}
self.message.merge_global_data = {'order_type': gettext_lazy("Subscription")}
self.message.send()
params = self.get_send_params()
self.assertNotLazy(params['merge_data']['to@example.com']['duration'])
@@ -329,7 +328,7 @@ class CatchCommonErrorsTests(TestBackendTestCase):
def test_explains_reply_to_must_be_list_lazy(self):
"""Same as previous tests, with lazy strings"""
# Lazy strings can fool string/iterable detection
self.message.reply_to = ugettext_lazy("single-reply-to@example.com")
self.message.reply_to = gettext_lazy("single-reply-to@example.com")
with self.assertRaisesMessage(TypeError, '"reply_to" attribute must be a list or other iterable'):
self.message.send()
@@ -431,7 +430,7 @@ class BatchSendDetectionTestCase(TestBackendTestCase):
"""Tests shared code to consistently determine whether to use batch send"""
def setUp(self):
super(BatchSendDetectionTestCase, self).setUp()
super().setUp()
self.backend = TestBackend()
def test_default_is_not_batch(self):
@@ -460,7 +459,7 @@ class BatchSendDetectionTestCase(TestBackendTestCase):
def set_cc(self, emails):
if self.is_batch(): # this won't work here!
self.unsupported_feature("cc with batch send")
super(ImproperlyImplementedPayload, self).set_cc(emails)
super().set_cc(emails)
connection = mail.get_connection('anymail.backends.test.EmailBackend',
payload_class=ImproperlyImplementedPayload)

View File

@@ -1,6 +1,3 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import quopri
from base64 import b64encode
from email.utils import collapse_rfc2231_value
@@ -169,10 +166,9 @@ class AnymailInboundMessageConstructionTests(SimpleTestCase):
def test_parse_raw_mime_8bit_utf8(self):
# In come cases, the message below ends up with 'Content-Transfer-Encoding: 8bit',
# so needs to be parsed as bytes, not text (see https://bugs.python.org/issue18271).
# Message.as_string() returns str, which is is bytes on Python 2 and text on Python 3.
# Message.as_string() returns str (text), not bytes.
# (This might be a Django bug; plain old MIMEText avoids the problem by using
# 'Content-Transfer-Encoding: base64', which parses fine as text or bytes.
# Django <1.11 on Python 3 also used base64.)
# 'Content-Transfer-Encoding: base64', which parses fine as text or bytes.)
# Either way, AnymailInboundMessage should try to sidestep the whole issue.
raw = SafeMIMEText("Unicode ✓", "plain", "utf-8").as_string()
msg = AnymailInboundMessage.parse_raw_mime(raw)
@@ -495,9 +491,11 @@ class AnymailInboundMessageAttachedMessageTests(SimpleTestCase):
self.assertEqual(orig_msg.get_content_type(), "multipart/related")
class EmailParserWorkaroundTests(SimpleTestCase):
# Anymail includes workarounds for (some of) the more problematic bugs
# in the Python 2 email.parser.Parser.
class EmailParserBehaviorTests(SimpleTestCase):
# Python 3.5+'s EmailParser should handle all of these, so long as it's not
# invoked with its default policy=compat32. This double checks we're using it
# properly. (Also, older versions of Anymail included workarounds for these
# in older, broken versions of the EmailParser.)
def test_parse_folded_headers(self):
raw = dedent("""\
@@ -540,16 +538,11 @@ class EmailParserWorkaroundTests(SimpleTestCase):
self.assertEqual(msg.from_email.display_name, "Keith Moore")
self.assertEqual(msg.from_email.addr_spec, "moore@example.com")
# When an RFC2047 encoded-word abuts an RFC5322 quoted-word in a *structured* header,
# Python 3's parser nicely recombines them into a single quoted word. That's way too
# complicated for our Python 2 workaround ...
self.assertIn(msg["To"], [ # `To` header will decode to one of these:
'Keld Jørn Simonsen <keld@example.com>, "André Pirard, Jr." <PIRARD@example.com>', # Python 3
'Keld Jørn Simonsen <keld@example.com>, André "Pirard, Jr." <PIRARD@example.com>', # workaround version
])
# ... but the two forms are equivalent, and de-structure the same:
self.assertEqual(msg["To"],
'Keld Jørn Simonsen <keld@example.com>, '
'"André Pirard, Jr." <PIRARD@example.com>')
self.assertEqual(msg.to[0].display_name, "Keld Jørn Simonsen")
self.assertEqual(msg.to[1].display_name, "André Pirard, Jr.") # correct in Python 3 *and* workaround!
self.assertEqual(msg.to[1].display_name, "André Pirard, Jr.")
# Note: Like email.headerregistry.Address, Anymail decodes an RFC2047-encoded display_name,
# but does not decode a punycode domain. (Use `idna.decode(domain)` if you need that.)

View File

@@ -1,16 +1,7 @@
# -*- coding: utf-8 -*-
from datetime import date, datetime
from textwrap import dedent
try:
from email import message_from_bytes
except ImportError:
from email import message_from_string
def message_from_bytes(s):
return message_from_string(s.decode('utf-8'))
from email.mime.base import MIMEBase
from email.mime.image import MIMEImage
@@ -24,7 +15,7 @@ from anymail.exceptions import (
AnymailRequestsAPIError, AnymailUnsupportedFeature)
from anymail.message import attach_inline_image_file
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCasesMixin
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCases
from .utils import (AnymailTestMixin, sample_email_content,
sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME)
@@ -39,7 +30,7 @@ class MailgunBackendMockAPITestCase(RequestsBackendMockAPITestCase):
}"""
def setUp(self):
super(MailgunBackendMockAPITestCase, self).setUp()
super().setUp()
# Simple message useful for many tests
self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com'])
@@ -178,7 +169,7 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase):
self.assertEqual(len(inlines), 0)
def test_unicode_attachment_correctly_decoded(self):
self.message.attach(u"Une pièce jointe.html", u'<p>\u2019</p>', mimetype='text/html')
self.message.attach("Une pièce jointe.html", '<p>\u2019</p>', mimetype='text/html')
self.message.send()
# Verify the RFC 7578 compliance workaround has kicked in:
@@ -191,7 +182,7 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase):
workaround = True
data = data.decode("utf-8").replace("\r\n", "\n")
self.assertNotIn("filename*=", data) # No RFC 2231 encoding
self.assertIn(u'Content-Disposition: form-data; name="attachment"; filename="Une pièce jointe.html"', data)
self.assertIn('Content-Disposition: form-data; name="attachment"; filename="Une pièce jointe.html"', data)
if workaround:
files = self.get_api_call_files(required=False)
@@ -199,8 +190,8 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase):
def test_rfc_7578_compliance(self):
# Check some corner cases in the workaround that undoes RFC 2231 multipart/form-data encoding...
self.message.subject = u"Testing for filename*=utf-8''problems"
self.message.body = u"The attached message should have an attachment named 'vedhæftet fil.txt'"
self.message.subject = "Testing for filename*=utf-8''problems"
self.message.body = "The attached message should have an attachment named 'vedhæftet fil.txt'"
# A forwarded message with its own attachment:
forwarded_message = dedent("""\
MIME-Version: 1.0
@@ -219,7 +210,7 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase):
This is an attachment.
--boundary--
""")
self.message.attach(u"besked med vedhæftede filer", forwarded_message, "message/rfc822")
self.message.attach("besked med vedhæftede filer", forwarded_message, "message/rfc822")
self.message.send()
data = self.get_api_call_data()
@@ -230,13 +221,13 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase):
# Top-level attachment (in form-data) should have RFC 7578 filename (raw Unicode):
self.assertIn(
u'Content-Disposition: form-data; name="attachment"; filename="besked med vedhæftede filer"', data)
'Content-Disposition: form-data; name="attachment"; filename="besked med vedhæftede filer"', data)
# Embedded message/rfc822 attachment should retain its RFC 2231 encoded filename:
self.assertIn("Content-Type: text/plain; name*=utf-8''vedh%C3%A6ftet%20fil.txt", data)
self.assertIn("Content-Disposition: attachment; filename*=utf-8''vedh%C3%A6ftet%20fil.txt", data)
# References to RFC 2231 in message text should remain intact:
self.assertIn("Testing for filename*=utf-8''problems", data)
self.assertIn(u"The attached message should have an attachment named 'vedhæftet fil.txt'", data)
self.assertIn("The attached message should have an attachment named 'vedhæftet fil.txt'", data)
def test_attachment_missing_filename(self):
"""Mailgun silently drops attachments without filenames, so warn the caller"""
@@ -767,14 +758,14 @@ class MailgunBackendRecipientsRefusedTests(MailgunBackendMockAPITestCase):
@tag('mailgun')
class MailgunBackendSessionSharingTestCase(SessionSharingTestCasesMixin, MailgunBackendMockAPITestCase):
class MailgunBackendSessionSharingTestCase(SessionSharingTestCases, MailgunBackendMockAPITestCase):
"""Requests session sharing tests"""
pass # tests are defined in the mixin
pass # tests are defined in SessionSharingTestCases
@tag('mailgun')
@override_settings(EMAIL_BACKEND="anymail.backends.mailgun.EmailBackend")
class MailgunBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin):
class MailgunBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase):
"""Test ESP backend without required settings in place"""
def test_missing_api_key(self):

View File

@@ -1,8 +1,8 @@
import json
from datetime import datetime
from io import BytesIO
from textwrap import dedent
import six
from django.test import override_settings, tag
from django.utils.timezone import utc
from mock import ANY
@@ -97,13 +97,13 @@ class MailgunInboundTestCase(WebhookTestCase):
])
def test_attachments(self):
att1 = six.BytesIO('test attachment'.encode('utf-8'))
att1 = BytesIO('test attachment'.encode('utf-8'))
att1.name = 'test.txt'
image_content = sample_image_content()
att2 = six.BytesIO(image_content)
att2 = BytesIO(image_content)
att2.name = 'image.png'
email_content = sample_email_content()
att3 = six.BytesIO(email_content)
att3 = BytesIO(email_content)
att3.content_type = 'message/rfc822; charset="us-ascii"'
raw_event = mailgun_sign_legacy_payload({
'message-headers': '[]',
@@ -124,7 +124,7 @@ class MailgunInboundTestCase(WebhookTestCase):
self.assertEqual(len(attachments), 2)
self.assertEqual(attachments[0].get_filename(), 'test.txt')
self.assertEqual(attachments[0].get_content_type(), 'text/plain')
self.assertEqual(attachments[0].get_content_text(), u'test attachment')
self.assertEqual(attachments[0].get_content_text(), 'test attachment')
self.assertEqual(attachments[1].get_content_type(), 'message/rfc822')
self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content)
@@ -176,8 +176,8 @@ class MailgunInboundTestCase(WebhookTestCase):
self.assertEqual(message.envelope_sender, 'envelope-from@example.org')
self.assertEqual(message.envelope_recipient, 'test@inbound.example.com')
self.assertEqual(message.subject, 'Raw MIME test')
self.assertEqual(message.text, u"It's a body\N{HORIZONTAL ELLIPSIS}\n")
self.assertEqual(message.html, u"""<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>\n""")
self.assertEqual(message.text, "It's a body\N{HORIZONTAL ELLIPSIS}\n")
self.assertEqual(message.html, """<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>\n""")
def test_misconfigured_tracking(self):
raw_event = mailgun_sign_payload({

View File

@@ -1,21 +1,17 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os
import logging
import os
import unittest
from datetime import datetime, timedelta
from time import mktime, sleep
from time import sleep
import requests
from django.test import SimpleTestCase, override_settings, tag
from anymail.exceptions import AnymailAPIError
from anymail.message import AnymailMessage
from .utils import AnymailTestMixin, sample_image_path
MAILGUN_TEST_API_KEY = os.getenv('MAILGUN_TEST_API_KEY')
MAILGUN_TEST_DOMAIN = os.getenv('MAILGUN_TEST_DOMAIN')
@@ -28,7 +24,7 @@ MAILGUN_TEST_DOMAIN = os.getenv('MAILGUN_TEST_DOMAIN')
'MAILGUN_SENDER_DOMAIN': MAILGUN_TEST_DOMAIN,
'MAILGUN_SEND_DEFAULTS': {'esp_extra': {'o:testmode': 'yes'}}},
EMAIL_BACKEND="anymail.backends.mailgun.EmailBackend")
class MailgunBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
class MailgunBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"""Mailgun API integration tests
These tests run against the **live** Mailgun API, using the
@@ -39,7 +35,7 @@ class MailgunBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
"""
def setUp(self):
super(MailgunBackendIntegrationTests, self).setUp()
super().setUp()
self.message = AnymailMessage('Anymail Mailgun integration test', 'Text content',
'from@example.com', ['test+to1@anymail.info'])
self.message.attach_alternative('<p>HTML content</p>', "text/html")
@@ -101,7 +97,7 @@ class MailgunBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
def test_all_options(self):
send_at = datetime.now().replace(microsecond=0) + timedelta(minutes=2)
send_at_timestamp = mktime(send_at.timetuple()) # python3: send_at.timestamp()
send_at_timestamp = send_at.timestamp()
message = AnymailMessage(
subject="Anymail Mailgun all-options integration test",
body="This is the text body",

View File

@@ -12,7 +12,7 @@ from anymail.exceptions import AnymailConfigurationError
from anymail.signals import AnymailTrackingEvent
from anymail.webhooks.mailgun import MailgunTrackingWebhookView
from .webhook_cases import WebhookTestCase, WebhookBasicAuthTestsMixin
from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase
TEST_WEBHOOK_SIGNING_KEY = 'TEST_WEBHOOK_SIGNING_KEY'
@@ -99,14 +99,14 @@ class MailgunWebhookSettingsTestCase(WebhookTestCase):
@tag('mailgun')
@override_settings(ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY=TEST_WEBHOOK_SIGNING_KEY)
class MailgunWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin):
class MailgunWebhookSecurityTestCase(WebhookBasicAuthTestCase):
should_warn_if_no_auth = False # because we check webhook signature
def call_webhook(self):
return self.client.post('/anymail/mailgun/tracking/', content_type="application/json",
data=json.dumps(mailgun_sign_payload({'event-data': {'event': 'delivered'}})))
# Additional tests are in WebhookBasicAuthTestsMixin
# Additional tests are in WebhookBasicAuthTestCase
def test_verifies_correct_signature(self):
response = self.client.post('/anymail/mailgun/tracking/', content_type="application/json",

View File

@@ -1,5 +1,3 @@
# -*- coding: utf-8 -*-
from base64 import b64encode
from decimal import Decimal
from email.mime.base import MIMEBase
@@ -14,7 +12,7 @@ from anymail.exceptions import (AnymailAPIError, AnymailSerializationError,
AnymailRequestsAPIError)
from anymail.message import attach_inline_image_file
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCasesMixin
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCases
from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin, decode_att
@@ -49,7 +47,7 @@ class MailjetBackendMockAPITestCase(RequestsBackendMockAPITestCase):
}"""
def setUp(self):
super(MailjetBackendMockAPITestCase, self).setUp()
super().setUp()
# Simple message useful for many tests
self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com'])
@@ -222,13 +220,13 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase):
self.assertNotIn('ContentID', attachments[2])
def test_unicode_attachment_correctly_decoded(self):
self.message.attach(u"Une pièce jointe.html", u'<p>\u2019</p>', mimetype='text/html')
self.message.attach("Une pièce jointe.html", '<p>\u2019</p>', mimetype='text/html')
self.message.send()
data = self.get_api_call_json()
self.assertEqual(data['Attachments'], [{
'Filename': u'Une pièce jointe.html',
'Filename': 'Une pièce jointe.html',
'Content-type': 'text/html',
'content': b64encode(u'<p>\u2019</p>'.encode('utf-8')).decode('ascii')
'content': b64encode('<p>\u2019</p>'.encode('utf-8')).decode('ascii')
}])
def test_embedded_images(self):
@@ -656,14 +654,14 @@ class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase):
@tag('mailjet')
class MailjetBackendSessionSharingTestCase(SessionSharingTestCasesMixin, MailjetBackendMockAPITestCase):
class MailjetBackendSessionSharingTestCase(SessionSharingTestCases, MailjetBackendMockAPITestCase):
"""Requests session sharing tests"""
pass # tests are defined in the mixin
pass # tests are defined in SessionSharingTestCases
@tag('mailjet')
@override_settings(EMAIL_BACKEND="anymail.backends.mailjet.EmailBackend")
class MailjetBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin):
class MailjetBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase):
"""Test ESP backend without required settings in place"""
def test_missing_api_key(self):

View File

@@ -160,7 +160,7 @@ class MailjetInboundTestCase(WebhookTestCase):
self.assertEqual(len(attachments), 2)
self.assertEqual(attachments[0].get_filename(), 'test.txt')
self.assertEqual(attachments[0].get_content_type(), 'text/plain')
self.assertEqual(attachments[0].get_content_text(), u'test attachment')
self.assertEqual(attachments[0].get_content_text(), 'test attachment')
self.assertEqual(attachments[1].get_content_type(), 'message/rfc822')
self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content)

View File

@@ -19,7 +19,7 @@ MAILJET_TEST_SECRET_KEY = os.getenv('MAILJET_TEST_SECRET_KEY')
@override_settings(ANYMAIL_MAILJET_API_KEY=MAILJET_TEST_API_KEY,
ANYMAIL_MAILJET_SECRET_KEY=MAILJET_TEST_SECRET_KEY,
EMAIL_BACKEND="anymail.backends.mailjet.EmailBackend")
class MailjetBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
class MailjetBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"""Mailjet API integration tests
These tests run against the **live** Mailjet API, using the
@@ -36,7 +36,7 @@ class MailjetBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
"""
def setUp(self):
super(MailjetBackendIntegrationTests, self).setUp()
super().setUp()
self.message = AnymailMessage('Anymail Mailjet integration test', 'Text content',
'test@test-mj.anymail.info', ['test+to1@anymail.info'])
self.message.attach_alternative('<p>HTML content</p>', "text/html")

View File

@@ -7,16 +7,16 @@ from mock import ANY
from anymail.signals import AnymailTrackingEvent
from anymail.webhooks.mailjet import MailjetTrackingWebhookView
from .webhook_cases import WebhookBasicAuthTestsMixin, WebhookTestCase
from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase
@tag('mailjet')
class MailjetWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin):
class MailjetWebhookSecurityTestCase(WebhookBasicAuthTestCase):
def call_webhook(self):
return self.client.post('/anymail/mailjet/tracking/',
content_type='application/json', data=json.dumps([]))
# Actual tests are in WebhookBasicAuthTestsMixin
# Actual tests are in WebhookBasicAuthTestCase
@tag('mailjet')

View File

@@ -1,5 +1,3 @@
# -*- coding: utf-8 -*-
from datetime import date, datetime
from decimal import Decimal
from email.mime.base import MIMEBase
@@ -14,7 +12,7 @@ from anymail.exceptions import (AnymailAPIError, AnymailRecipientsRefused,
AnymailSerializationError, AnymailUnsupportedFeature)
from anymail.message import attach_inline_image
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCasesMixin
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCases
from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin, decode_att
@@ -30,7 +28,7 @@ class MandrillBackendMockAPITestCase(RequestsBackendMockAPITestCase):
}]"""
def setUp(self):
super(MandrillBackendMockAPITestCase, self).setUp()
super().setUp()
# Simple message useful for many tests
self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com'])
@@ -170,7 +168,7 @@ class MandrillBackendStandardEmailTests(MandrillBackendMockAPITestCase):
self.assertFalse('images' in data['message'])
def test_unicode_attachment_correctly_decoded(self):
self.message.attach(u"Une pièce jointe.html", u'<p>\u2019</p>', mimetype='text/html')
self.message.attach("Une pièce jointe.html", '<p>\u2019</p>', mimetype='text/html')
self.message.send()
data = self.get_api_call_json()
attachments = data['message']['attachments']
@@ -610,14 +608,14 @@ class MandrillBackendRecipientsRefusedTests(MandrillBackendMockAPITestCase):
@tag('mandrill')
class MandrillBackendSessionSharingTestCase(SessionSharingTestCasesMixin, MandrillBackendMockAPITestCase):
class MandrillBackendSessionSharingTestCase(SessionSharingTestCases, MandrillBackendMockAPITestCase):
"""Requests session sharing tests"""
pass # tests are defined in the mixin
pass # tests are defined in SessionSharingTestCases
@tag('mandrill')
@override_settings(EMAIL_BACKEND="anymail.backends.mandrill.EmailBackend")
class MandrillBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin):
class MandrillBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase):
"""Test backend without required settings"""
def test_missing_api_key(self):

View File

@@ -75,8 +75,8 @@ class MandrillInboundTestCase(WebhookTestCase):
self.assertEqual(message.to[1].addr_spec, 'other@example.com')
self.assertEqual(message.subject, 'Test subject')
self.assertEqual(message.date.isoformat(" "), "2017-10-12 18:03:30-07:00")
self.assertEqual(message.text, u"It's a body\N{HORIZONTAL ELLIPSIS}\n")
self.assertEqual(message.html, u"""<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>\n""")
self.assertEqual(message.text, "It's a body\N{HORIZONTAL ELLIPSIS}\n")
self.assertEqual(message.html, """<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>\n""")
self.assertIsNone(message.envelope_sender) # Mandrill doesn't provide sender
self.assertEqual(message.envelope_recipient, 'delivered-to@example.com')

View File

@@ -17,7 +17,7 @@ MANDRILL_TEST_API_KEY = os.getenv('MANDRILL_TEST_API_KEY')
"Set MANDRILL_TEST_API_KEY environment variable to run integration tests")
@override_settings(MANDRILL_API_KEY=MANDRILL_TEST_API_KEY,
EMAIL_BACKEND="anymail.backends.mandrill.EmailBackend")
class MandrillBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
class MandrillBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"""Mandrill API integration tests
These tests run against the **live** Mandrill API, using the
@@ -30,7 +30,7 @@ class MandrillBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
"""
def setUp(self):
super(MandrillBackendIntegrationTests, self).setUp()
super().setUp()
self.message = mail.EmailMultiAlternatives('Anymail Mandrill integration test', 'Text content',
'from@example.com', ['test+to1@anymail.info'])
self.message.attach_alternative('<p>HTML content</p>', "text/html")

View File

@@ -1,10 +1,10 @@
import json
from datetime import datetime
from six.moves.urllib.parse import urljoin
import hashlib
import hmac
import json
from base64 import b64encode
from datetime import datetime
from urllib.parse import urljoin
from django.core.exceptions import ImproperlyConfigured
from django.test import override_settings, tag
from django.utils.timezone import utc
@@ -12,8 +12,7 @@ from mock import ANY
from anymail.signals import AnymailTrackingEvent
from anymail.webhooks.mandrill import MandrillCombinedWebhookView, MandrillTrackingWebhookView
from .webhook_cases import WebhookTestCase, WebhookBasicAuthTestsMixin
from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase
TEST_WEBHOOK_KEY = 'TEST_WEBHOOK_KEY'
@@ -65,14 +64,14 @@ class MandrillWebhookSettingsTestCase(WebhookTestCase):
@tag('mandrill')
@override_settings(ANYMAIL_MANDRILL_WEBHOOK_KEY=TEST_WEBHOOK_KEY)
class MandrillWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin):
class MandrillWebhookSecurityTestCase(WebhookBasicAuthTestCase):
should_warn_if_no_auth = False # because we check webhook signature
def call_webhook(self):
kwargs = mandrill_args([{'event': 'send'}])
return self.client.post(**kwargs)
# Additional tests are in WebhookBasicAuthTestsMixin
# Additional tests are in WebhookBasicAuthTestCase
def test_verifies_correct_signature(self):
kwargs = mandrill_args([{'event': 'send'}])
@@ -112,7 +111,7 @@ class MandrillWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixi
response = self.client.post(SERVER_NAME="127.0.0.1", **kwargs)
self.assertEqual(response.status_code, 200)
# override WebhookBasicAuthTestsMixin version of this test
# override WebhookBasicAuthTestCase version of this test
@override_settings(ANYMAIL={'WEBHOOK_SECRET': ['cred1:pass1', 'cred2:pass2']})
def test_supports_credential_rotation(self):
"""You can supply a list of basic auth credentials, and any is allowed"""

View File

@@ -10,7 +10,7 @@ from .utils import AnymailTestMixin, sample_image_content
class InlineImageTests(AnymailTestMixin, SimpleTestCase):
def setUp(self):
self.message = EmailMultiAlternatives()
super(InlineImageTests, self).setUp()
super().setUp()
@patch("email.utils.socket.getfqdn")
def test_default_domain(self, mock_getfqdn):

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
import json
from base64 import b64encode
from decimal import Decimal
@@ -14,7 +13,7 @@ from anymail.exceptions import (
AnymailUnsupportedFeature, AnymailRecipientsRefused, AnymailInvalidAddress)
from anymail.message import attach_inline_image_file, AnymailMessage
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCasesMixin
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCases
from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin, decode_att
@@ -31,7 +30,7 @@ class PostmarkBackendMockAPITestCase(RequestsBackendMockAPITestCase):
}"""
def setUp(self):
super(PostmarkBackendMockAPITestCase, self).setUp()
super().setUp()
# Simple message useful for many tests
self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com'])
@@ -182,13 +181,13 @@ class PostmarkBackendStandardEmailTests(PostmarkBackendMockAPITestCase):
self.assertNotIn('ContentID', attachments[2])
def test_unicode_attachment_correctly_decoded(self):
self.message.attach(u"Une pièce jointe.html", u'<p>\u2019</p>', mimetype='text/html')
self.message.attach("Une pièce jointe.html", '<p>\u2019</p>', mimetype='text/html')
self.message.send()
data = self.get_api_call_json()
self.assertEqual(data['Attachments'], [{
'Name': u'Une pièce jointe.html',
'Name': 'Une pièce jointe.html',
'ContentType': 'text/html',
'Content': b64encode(u'<p>\u2019</p>'.encode('utf-8')).decode('ascii')
'Content': b64encode('<p>\u2019</p>'.encode('utf-8')).decode('ascii')
}])
def test_embedded_images(self):
@@ -758,14 +757,14 @@ class PostmarkBackendRecipientsRefusedTests(PostmarkBackendMockAPITestCase):
@tag('postmark')
class PostmarkBackendSessionSharingTestCase(SessionSharingTestCasesMixin, PostmarkBackendMockAPITestCase):
class PostmarkBackendSessionSharingTestCase(SessionSharingTestCases, PostmarkBackendMockAPITestCase):
"""Requests session sharing tests"""
pass # tests are defined in the mixin
pass # tests are defined in SessionSharingTestCases
@tag('postmark')
@override_settings(EMAIL_BACKEND="anymail.backends.postmark.EmailBackend")
class PostmarkBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin):
class PostmarkBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase):
"""Test ESP backend without required settings in place"""
def test_missing_api_key(self):

View File

@@ -163,7 +163,7 @@ class PostmarkInboundTestCase(WebhookTestCase):
self.assertEqual(len(attachments), 2)
self.assertEqual(attachments[0].get_filename(), 'test.txt')
self.assertEqual(attachments[0].get_content_type(), 'text/plain')
self.assertEqual(attachments[0].get_content_text(), u'test attachment')
self.assertEqual(attachments[0].get_content_text(), 'test attachment')
self.assertEqual(attachments[1].get_content_type(), 'message/rfc822')
self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content)

View File

@@ -18,7 +18,7 @@ POSTMARK_TEST_TEMPLATE_ID = os.getenv('POSTMARK_TEST_TEMPLATE_ID')
@tag('postmark', 'live')
@override_settings(ANYMAIL_POSTMARK_SERVER_TOKEN="POSTMARK_API_TEST",
EMAIL_BACKEND="anymail.backends.postmark.EmailBackend")
class PostmarkBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
class PostmarkBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"""Postmark API integration tests
These tests run against the **live** Postmark API, but using a
@@ -26,7 +26,7 @@ class PostmarkBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
"""
def setUp(self):
super(PostmarkBackendIntegrationTests, self).setUp()
super().setUp()
self.message = AnymailMessage('Anymail Postmark integration test', 'Text content',
'from@example.com', ['test+to1@anymail.info'])
self.message.attach_alternative('<p>HTML content</p>', "text/html")

View File

@@ -8,16 +8,16 @@ from mock import ANY
from anymail.exceptions import AnymailConfigurationError
from anymail.signals import AnymailTrackingEvent
from anymail.webhooks.postmark import PostmarkTrackingWebhookView
from .webhook_cases import WebhookBasicAuthTestsMixin, WebhookTestCase
from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase
@tag('postmark')
class PostmarkWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin):
class PostmarkWebhookSecurityTestCase(WebhookBasicAuthTestCase):
def call_webhook(self):
return self.client.post('/anymail/postmark/tracking/',
content_type='application/json', data=json.dumps({}))
# Actual tests are in WebhookBasicAuthTestsMixin
# Actual tests are in WebhookBasicAuthTestCase
@tag('postmark')

View File

@@ -1,5 +1,3 @@
# -*- coding: utf-8 -*-
from base64 import b64encode, b64decode
from calendar import timegm
from datetime import date, datetime
@@ -7,7 +5,6 @@ from decimal import Decimal
from email.mime.base import MIMEBase
from email.mime.image import MIMEImage
import six
from django.core import mail
from django.test import SimpleTestCase, override_settings, tag
from django.utils.timezone import get_fixed_timezone, override as override_current_timezone
@@ -17,12 +14,9 @@ from anymail.exceptions import (AnymailAPIError, AnymailConfigurationError, Anym
AnymailUnsupportedFeature, AnymailWarning)
from anymail.message import attach_inline_image_file
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCasesMixin
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCases
from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin
# noinspection PyUnresolvedReferences
longtype = int if six.PY3 else long # NOQA: F821
@tag('sendgrid')
@override_settings(EMAIL_BACKEND='anymail.backends.sendgrid.EmailBackend',
@@ -32,7 +26,7 @@ class SendGridBackendMockAPITestCase(RequestsBackendMockAPITestCase):
DEFAULT_STATUS_CODE = 202 # SendGrid v3 uses '202 Accepted' for success (in most cases)
def setUp(self):
super(SendGridBackendMockAPITestCase, self).setUp()
super().setUp()
# Patch uuid4 to generate predictable anymail_ids for testing
patch_uuid4 = patch('anymail.backends.sendgrid.uuid.uuid4',
@@ -153,13 +147,12 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase):
self.assertEqual(data['content'][0], {'type': "text/html", 'value': html_content})
def test_extra_headers(self):
self.message.extra_headers = {'X-Custom': 'string', 'X-Num': 123, 'X-Long': longtype(123),
self.message.extra_headers = {'X-Custom': 'string', 'X-Num': 123,
'Reply-To': '"Do Not Reply" <noreply@example.com>'}
self.message.send()
data = self.get_api_call_json()
self.assertEqual(data['headers']['X-Custom'], 'string')
self.assertEqual(data['headers']['X-Num'], '123') # converted to string (undoc'd SendGrid requirement)
self.assertEqual(data['headers']['X-Long'], '123') # converted to string (undoc'd SendGrid requirement)
# Reply-To must be moved to separate param
self.assertNotIn('Reply-To', data['headers'])
self.assertEqual(data['reply_to'], {'name': "Do Not Reply", 'email': "noreply@example.com"})
@@ -222,11 +215,11 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase):
'type': "application/pdf"})
def test_unicode_attachment_correctly_decoded(self):
self.message.attach(u"Une pièce jointe.html", u'<p>\u2019</p>', mimetype='text/html')
self.message.attach("Une pièce jointe.html", '<p>\u2019</p>', mimetype='text/html')
self.message.send()
attachment = self.get_api_call_json()['attachments'][0]
self.assertEqual(attachment['filename'], u'Une pièce jointe.html')
self.assertEqual(b64decode(attachment['content']).decode('utf-8'), u'<p>\u2019</p>')
self.assertEqual(attachment['filename'], 'Une pièce jointe.html')
self.assertEqual(b64decode(attachment['content']).decode('utf-8'), '<p>\u2019</p>')
def test_embedded_images(self):
image_filename = SAMPLE_IMAGE_FILENAME
@@ -348,14 +341,14 @@ class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase):
self.message.send()
def test_metadata(self):
self.message.metadata = {'user_id': "12345", 'items': 6, 'float': 98.6, 'long': longtype(123)}
self.message.metadata = {'user_id': "12345", 'items': 6, 'float': 98.6}
self.message.send()
data = self.get_api_call_json()
data['custom_args'].pop('anymail_id', None) # remove anymail_id we added for tracking
self.assertEqual(data['custom_args'], {'user_id': "12345",
'items': "6", # int converted to a string,
'float': "98.6", # float converted to a string (watch binary rounding!)
'long': "123"}) # long converted to string
})
def test_send_at(self):
utc_plus_6 = get_fixed_timezone(6 * 60)
@@ -879,14 +872,14 @@ class SendGridBackendRecipientsRefusedTests(SendGridBackendMockAPITestCase):
@tag('sendgrid')
class SendGridBackendSessionSharingTestCase(SessionSharingTestCasesMixin, SendGridBackendMockAPITestCase):
class SendGridBackendSessionSharingTestCase(SessionSharingTestCases, SendGridBackendMockAPITestCase):
"""Requests session sharing tests"""
pass # tests are defined in the mixin
pass # tests are defined in SessionSharingTestCases
@tag('sendgrid')
@override_settings(EMAIL_BACKEND="anymail.backends.sendgrid.EmailBackend")
class SendGridBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin):
class SendGridBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase):
"""Test ESP backend without required settings in place"""
def test_missing_auth(self):
@@ -896,7 +889,7 @@ class SendGridBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin)
@tag('sendgrid')
@override_settings(EMAIL_BACKEND="anymail.backends.sendgrid.EmailBackend")
class SendGridBackendDisallowsV2Tests(SimpleTestCase, AnymailTestMixin):
class SendGridBackendDisallowsV2Tests(AnymailTestMixin, SimpleTestCase):
"""Using v2-API-only features should cause errors with v3 backend"""
@override_settings(ANYMAIL={'SENDGRID_USERNAME': 'sg_username', 'SENDGRID_PASSWORD': 'sg_password'})

View File

@@ -1,9 +1,7 @@
# -*- coding: utf-8 -*-
import json
from io import BytesIO
from textwrap import dedent
import six
from django.test import tag
from mock import ANY
@@ -91,13 +89,13 @@ class SendgridInboundTestCase(WebhookTestCase):
])
def test_attachments(self):
att1 = six.BytesIO('test attachment'.encode('utf-8'))
att1 = BytesIO('test attachment'.encode('utf-8'))
att1.name = 'test.txt'
image_content = sample_image_content()
att2 = six.BytesIO(image_content)
att2 = BytesIO(image_content)
att2.name = 'image.png'
email_content = sample_email_content()
att3 = six.BytesIO(email_content)
att3 = BytesIO(email_content)
att3.content_type = 'message/rfc822; charset="us-ascii"'
raw_event = {
'headers': '',
@@ -124,7 +122,7 @@ class SendgridInboundTestCase(WebhookTestCase):
self.assertEqual(len(attachments), 2)
self.assertEqual(attachments[0].get_filename(), 'test.txt')
self.assertEqual(attachments[0].get_content_type(), 'text/plain')
self.assertEqual(attachments[0].get_content_text(), u'test attachment')
self.assertEqual(attachments[0].get_content_text(), 'test attachment')
self.assertEqual(attachments[1].get_content_type(), 'message/rfc822')
self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content)
@@ -183,8 +181,8 @@ class SendgridInboundTestCase(WebhookTestCase):
self.assertEqual(message.envelope_sender, 'envelope-from@example.org')
self.assertEqual(message.envelope_recipient, 'test@inbound.example.com')
self.assertEqual(message.subject, 'Raw MIME test')
self.assertEqual(message.text, u"It's a body\N{HORIZONTAL ELLIPSIS}\n")
self.assertEqual(message.html, u"""<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>\n""")
self.assertEqual(message.text, "It's a body\N{HORIZONTAL ELLIPSIS}\n")
self.assertEqual(message.html, """<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>\n""")
def test_inbound_charsets(self):
# Captured (sanitized) from actual SendGrid inbound webhook payload 7/2020,
@@ -233,11 +231,11 @@ class SendgridInboundTestCase(WebhookTestCase):
event = kwargs['event']
message = event.message
self.assertEqual(message.from_email.display_name, u"Opérateur de test")
self.assertEqual(message.from_email.display_name, "Opérateur de test")
self.assertEqual(message.from_email.addr_spec, "sender@example.com")
self.assertEqual(len(message.to), 1)
self.assertEqual(message.to[0].display_name, u"Récipiendaire précieux")
self.assertEqual(message.to[0].display_name, "Récipiendaire précieux")
self.assertEqual(message.to[0].addr_spec, "inbound@sg.example.com")
self.assertEqual(message.subject, u"Como usted pidió")
self.assertEqual(message.text, u"Test the ESPs inbound charset handling…")
self.assertEqual(message.html, u"<p>¿Esto se ve como esperabas?</p>")
self.assertEqual(message.subject, "Como usted pidió")
self.assertEqual(message.text, "Test the ESPs inbound charset handling…")
self.assertEqual(message.html, "<p>¿Esto se ve como esperabas?</p>")

View File

@@ -22,7 +22,7 @@ SENDGRID_TEST_TEMPLATE_ID = os.getenv('SENDGRID_TEST_TEMPLATE_ID')
"mail_settings": {"sandbox_mode": {"enable": True}},
}},
EMAIL_BACKEND="anymail.backends.sendgrid.EmailBackend")
class SendGridBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
class SendGridBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"""SendGrid v3 API integration tests
These tests run against the **live** SendGrid API, using the
@@ -38,7 +38,7 @@ class SendGridBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
"""
def setUp(self):
super(SendGridBackendIntegrationTests, self).setUp()
super().setUp()
self.message = AnymailMessage('Anymail SendGrid integration test', 'Text content',
'from@example.com', ['to@sink.sendgrid.net'])
self.message.attach_alternative('<p>HTML content</p>', "text/html")

View File

@@ -7,16 +7,16 @@ from mock import ANY
from anymail.signals import AnymailTrackingEvent
from anymail.webhooks.sendgrid import SendGridTrackingWebhookView
from .webhook_cases import WebhookBasicAuthTestsMixin, WebhookTestCase
from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase
@tag('sendgrid')
class SendGridWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin):
class SendGridWebhookSecurityTestCase(WebhookBasicAuthTestCase):
def call_webhook(self):
return self.client.post('/anymail/sendgrid/tracking/',
content_type='application/json', data=json.dumps([]))
# Actual tests are in WebhookBasicAuthTestsMixin
# Actual tests are in WebhookBasicAuthTestCase
@tag('sendgrid')

View File

@@ -1,5 +1,3 @@
# -*- coding: utf-8 -*-
import json
from base64 import b64encode, b64decode
from datetime import datetime
@@ -7,7 +5,6 @@ from decimal import Decimal
from email.mime.base import MIMEBase
from email.mime.image import MIMEImage
import six
from django.core import mail
from django.test import SimpleTestCase, override_settings, tag
from django.utils.timezone import get_fixed_timezone, override as override_current_timezone
@@ -15,12 +12,9 @@ from django.utils.timezone import get_fixed_timezone, override as override_curre
from anymail.exceptions import (AnymailAPIError, AnymailConfigurationError, AnymailSerializationError,
AnymailUnsupportedFeature)
from anymail.message import attach_inline_image_file
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCasesMixin
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCases
from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin
# noinspection PyUnresolvedReferences
longtype = int if six.PY3 else long # NOQA: F821
@tag('sendinblue')
@override_settings(EMAIL_BACKEND='anymail.backends.sendinblue.EmailBackend',
@@ -31,7 +25,7 @@ class SendinBlueBackendMockAPITestCase(RequestsBackendMockAPITestCase):
DEFAULT_STATUS_CODE = 201 # SendinBlue v3 uses '201 Created' for success (in most cases)
def setUp(self):
super(SendinBlueBackendMockAPITestCase, self).setUp()
super().setUp()
# Simple message useful for many tests
self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com'])
@@ -119,13 +113,12 @@ class SendinBlueBackendStandardEmailTests(SendinBlueBackendMockAPITestCase):
self.assertNotIn('textContent', data)
def test_extra_headers(self):
self.message.extra_headers = {'X-Custom': 'string', 'X-Num': 123, 'X-Long': longtype(123),
self.message.extra_headers = {'X-Custom': 'string', 'X-Num': 123,
'Reply-To': '"Do Not Reply" <noreply@example.com>'}
self.message.send()
data = self.get_api_call_json()
self.assertEqual(data['headers']['X-Custom'], 'string')
self.assertEqual(data['headers']['X-Num'], 123)
self.assertEqual(data['headers']['X-Long'], 123)
# Reply-To must be moved to separate param
self.assertNotIn('Reply-To', data['headers'])
self.assertEqual(data['replyTo'], {'name': "Do Not Reply", 'email': "noreply@example.com"})
@@ -185,11 +178,11 @@ class SendinBlueBackendStandardEmailTests(SendinBlueBackendMockAPITestCase):
'content': b64encode(pdf_content).decode('ascii')})
def test_unicode_attachment_correctly_decoded(self):
self.message.attach(u"Une pièce jointe.html", u'<p>\u2019</p>', mimetype='text/html')
self.message.attach("Une pièce jointe.html", '<p>\u2019</p>', mimetype='text/html')
self.message.send()
attachment = self.get_api_call_json()['attachment'][0]
self.assertEqual(attachment['name'], u'Une pièce jointe.html')
self.assertEqual(b64decode(attachment['content']).decode('utf-8'), u'<p>\u2019</p>')
self.assertEqual(attachment['name'], 'Une pièce jointe.html')
self.assertEqual(b64decode(attachment['content']).decode('utf-8'), '<p>\u2019</p>')
def test_embedded_images(self):
# SendinBlue doesn't support inline image
@@ -284,7 +277,7 @@ class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase):
self.message.send()
def test_metadata(self):
self.message.metadata = {'user_id': "12345", 'items': 6, 'float': 98.6, 'long': longtype(123)}
self.message.metadata = {'user_id': "12345", 'items': 6, 'float': 98.6}
self.message.send()
data = self.get_api_call_json()
@@ -293,7 +286,6 @@ class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase):
self.assertEqual(metadata['user_id'], "12345")
self.assertEqual(metadata['items'], 6)
self.assertEqual(metadata['float'], 98.6)
self.assertEqual(metadata['long'], longtype(123))
def test_send_at(self):
utc_plus_6 = get_fixed_timezone(6 * 60)
@@ -451,14 +443,14 @@ class SendinBlueBackendRecipientsRefusedTests(SendinBlueBackendMockAPITestCase):
@tag('sendinblue')
class SendinBlueBackendSessionSharingTestCase(SessionSharingTestCasesMixin, SendinBlueBackendMockAPITestCase):
class SendinBlueBackendSessionSharingTestCase(SessionSharingTestCases, SendinBlueBackendMockAPITestCase):
"""Requests session sharing tests"""
pass # tests are defined in the mixin
pass # tests are defined in SessionSharingTestCases
@tag('sendinblue')
@override_settings(EMAIL_BACKEND="anymail.backends.sendinblue.EmailBackend")
class SendinBlueBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin):
class SendinBlueBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase):
"""Test ESP backend without required settings in place"""
def test_missing_auth(self):

View File

@@ -18,7 +18,7 @@ SENDINBLUE_TEST_API_KEY = os.getenv('SENDINBLUE_TEST_API_KEY')
@override_settings(ANYMAIL_SENDINBLUE_API_KEY=SENDINBLUE_TEST_API_KEY,
ANYMAIL_SENDINBLUE_SEND_DEFAULTS=dict(),
EMAIL_BACKEND="anymail.backends.sendinblue.EmailBackend")
class SendinBlueBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
class SendinBlueBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"""SendinBlue v3 API integration tests
SendinBlue doesn't have sandbox so these tests run
@@ -31,7 +31,7 @@ class SendinBlueBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
"""
def setUp(self):
super(SendinBlueBackendIntegrationTests, self).setUp()
super().setUp()
self.message = AnymailMessage('Anymail SendinBlue integration test', 'Text content',
'from@test-sb.anymail.info', ['test+to1@anymail.info'])

View File

@@ -7,16 +7,16 @@ from mock import ANY
from anymail.signals import AnymailTrackingEvent
from anymail.webhooks.sendinblue import SendinBlueTrackingWebhookView
from .webhook_cases import WebhookBasicAuthTestsMixin, WebhookTestCase
from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase
@tag('sendinblue')
class SendinBlueWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin):
class SendinBlueWebhookSecurityTestCase(WebhookBasicAuthTestCase):
def call_webhook(self):
return self.client.post('/anymail/sendinblue/tracking/',
content_type='application/json', data=json.dumps({}))
# Actual tests are in WebhookBasicAuthTestsMixin
# Actual tests are in WebhookBasicAuthTestCase
@tag('sendinblue')

View File

@@ -1,121 +0,0 @@
"""
Django settings for Anymail tests.
Generated by 'django-admin startproject' using Django 1.11.
For more information on this file, see
https://docs.djangoproject.com/en/1.11/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.11/ref/settings/
"""
import os
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'NOT_FOR_PRODUCTION_USE'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'anymail',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'tests.test_settings.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'tests.wsgi.application'
# Database
# https://docs.djangoproject.com/en/1.11/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}
# Password validation
# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/1.11/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.11/howto/static-files/
STATIC_URL = '/static/'

View File

@@ -1,5 +1,5 @@
from django.conf.urls import include, url
from django.urls import include, re_path
urlpatterns = [
url(r'^anymail/', include('anymail.urls')),
re_path(r'^anymail/', include('anymail.urls')),
]

View File

@@ -1,32 +1,30 @@
# -*- coding: utf-8 -*-
from datetime import datetime, date
import os
from datetime import date, datetime
from email.mime.base import MIMEBase
from email.mime.image import MIMEImage
import os
from io import BytesIO
import requests
import six
from django.core import mail
from django.test import SimpleTestCase, override_settings, tag
from django.utils.timezone import get_fixed_timezone, override as override_current_timezone, utc
from mock import patch
from anymail.exceptions import (AnymailAPIError, AnymailUnsupportedFeature, AnymailRecipientsRefused,
AnymailConfigurationError, AnymailInvalidAddress)
from anymail.exceptions import (
AnymailAPIError, AnymailConfigurationError, AnymailInvalidAddress, AnymailRecipientsRefused,
AnymailUnsupportedFeature)
from anymail.message import attach_inline_image_file
from .utils import AnymailTestMixin, decode_att, SAMPLE_IMAGE_FILENAME, sample_image_path, sample_image_content
from .utils import AnymailTestMixin, SAMPLE_IMAGE_FILENAME, decode_att, sample_image_content, sample_image_path
@tag('sparkpost')
@override_settings(EMAIL_BACKEND='anymail.backends.sparkpost.EmailBackend',
ANYMAIL={'SPARKPOST_API_KEY': 'test_api_key'})
class SparkPostBackendMockAPITestCase(SimpleTestCase, AnymailTestMixin):
class SparkPostBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
"""TestCase that uses SparkPostEmailBackend with a mocked transmissions.send API"""
def setUp(self):
super(SparkPostBackendMockAPITestCase, self).setUp()
super().setUp()
self.patch_send = patch('sparkpost.Transmissions.send', autospec=True)
self.mock_send = self.patch_send.start()
self.addCleanup(self.patch_send.stop)
@@ -52,7 +50,7 @@ class SparkPostBackendMockAPITestCase(SimpleTestCase, AnymailTestMixin):
response = requests.Response()
response.status_code = status_code
response.encoding = encoding
response.raw = six.BytesIO(raw)
response.raw = BytesIO(raw)
response.url = "/mock/send"
self.mock_send.side_effect = SparkPostAPIException(response)
@@ -205,7 +203,7 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase):
def test_unicode_attachment_correctly_decoded(self):
# Slight modification from the Django unicode docs:
# http://django.readthedocs.org/en/latest/ref/unicode.html#email
self.message.attach(u"Une pièce jointe.html", u'<p>\u2019</p>', mimetype='text/html')
self.message.attach("Une pièce jointe.html", '<p>\u2019</p>', mimetype='text/html')
self.message.send()
params = self.get_send_params()
attachments = params['attachments']
@@ -609,7 +607,7 @@ class SparkPostBackendRecipientsRefusedTests(SparkPostBackendMockAPITestCase):
@tag('sparkpost')
@override_settings(EMAIL_BACKEND="anymail.backends.sparkpost.EmailBackend")
class SparkPostBackendConfigurationTests(SimpleTestCase, AnymailTestMixin):
class SparkPostBackendConfigurationTests(AnymailTestMixin, SimpleTestCase):
"""Test various SparkPost client options"""
def test_missing_api_key(self):

View File

@@ -80,8 +80,8 @@ class SparkpostInboundTestCase(WebhookTestCase):
['cc@example.com'])
self.assertEqual(message.subject, 'Test subject')
self.assertEqual(message.date.isoformat(" "), "2017-10-11 18:31:04-07:00")
self.assertEqual(message.text, u"It's a body\N{HORIZONTAL ELLIPSIS}\n")
self.assertEqual(message.html, u"""<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>\n""")
self.assertEqual(message.text, "It's a body\N{HORIZONTAL ELLIPSIS}\n")
self.assertEqual(message.html, """<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>\n""")
self.assertEqual(message.envelope_sender, 'envelope-from@example.org')
self.assertEqual(message.envelope_recipient, 'test@inbound.example.com')
@@ -158,7 +158,7 @@ class SparkpostInboundTestCase(WebhookTestCase):
self.assertEqual(len(attachments), 2)
self.assertEqual(attachments[0].get_filename(), 'test.txt')
self.assertEqual(attachments[0].get_content_type(), 'text/plain')
self.assertEqual(attachments[0].get_content_text(), u'test attachment')
self.assertEqual(attachments[0].get_content_text(), 'test attachment')
self.assertEqual(attachments[1].get_content_type(), 'message/rfc822')
self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content)

View File

@@ -1,5 +1,6 @@
import os
import unittest
import warnings
from datetime import datetime, timedelta
from django.test import SimpleTestCase, override_settings, tag
@@ -18,7 +19,7 @@ SPARKPOST_TEST_API_KEY = os.getenv('SPARKPOST_TEST_API_KEY')
"to run SparkPost integration tests")
@override_settings(ANYMAIL_SPARKPOST_API_KEY=SPARKPOST_TEST_API_KEY,
EMAIL_BACKEND="anymail.backends.sparkpost.EmailBackend")
class SparkPostBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
class SparkPostBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"""SparkPost API integration tests
These tests run against the **live** SparkPost API, using the
@@ -28,19 +29,31 @@ class SparkPostBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
SparkPost doesn't offer a test mode -- it tries to send everything
you ask. To avoid stacking up a pile of undeliverable @example.com
emails, the tests use SparkPost's "sink domain" @*.sink.sparkpostmail.com.
https://support.sparkpost.com/customer/en/portal/articles/2361300-how-to-test-integrations
https://www.sparkpost.com/docs/faq/using-sink-server/
SparkPost also doesn't support arbitrary senders (so no from@example.com).
We've set up @test-sp.anymail.info as a validated sending domain for these tests.
"""
def setUp(self):
super(SparkPostBackendIntegrationTests, self).setUp()
super().setUp()
self.message = AnymailMessage('Anymail SparkPost integration test', 'Text content',
'test@test-sp.anymail.info', ['to@test.sink.sparkpostmail.com'])
self.message.attach_alternative('<p>HTML content</p>', "text/html")
# The SparkPost Python package uses requests directly, without managing sessions, and relies
# on GC to close connections. This leads to harmless (but valid) warnings about unclosed
# ssl.SSLSocket during cleanup: https://github.com/psf/requests/issues/1882
# There's not much we can do about that, short of switching from the SparkPost package
# to our own requests backend implementation (which *does* manage sessions properly).
# Unless/until we do that, filter the warnings to avoid test noise.
# Filter in TestCase.setUp because unittest resets the warning filters for each test.
# https://stackoverflow.com/a/26620811/647002
from anymail.backends.base_requests import AnymailRequestsBackend
from anymail.backends.sparkpost import EmailBackend as SparkPostBackend
assert not issubclass(SparkPostBackend, AnymailRequestsBackend) # else this filter can be removed
warnings.filterwarnings("ignore", message=r"unclosed <ssl\.SSLSocket", category=ResourceWarning)
def test_simple_send(self):
# Example of getting the SparkPost send status and transmission id from the message
sent_count = self.message.send()

View File

@@ -8,16 +8,16 @@ from mock import ANY
from anymail.signals import AnymailTrackingEvent
from anymail.webhooks.sparkpost import SparkPostTrackingWebhookView
from .webhook_cases import WebhookBasicAuthTestsMixin, WebhookTestCase
from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase
@tag('sparkpost')
class SparkPostWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin):
class SparkPostWebhookSecurityTestCase(WebhookBasicAuthTestCase):
def call_webhook(self):
return self.client.post('/anymail/sparkpost/tracking/',
content_type='application/json', data=json.dumps([]))
# Actual tests are in WebhookBasicAuthTestsMixin
# Actual tests are in WebhookBasicAuthTestCase
@tag('sparkpost')

View File

@@ -4,22 +4,11 @@ import base64
import copy
import pickle
from email.mime.image import MIMEImage
from unittest import skipIf
import six
from django.http import QueryDict
from django.test import SimpleTestCase, RequestFactory, override_settings
from django.utils.translation import ugettext_lazy
try:
from django.utils.text import format_lazy # Django >= 1.11
except ImportError:
format_lazy = None
try:
from django.utils.translation import string_concat # Django < 2.1
except ImportError:
string_concat = None
from django.utils.text import format_lazy
from django.utils.translation import gettext_lazy
from anymail.exceptions import AnymailInvalidAddress, _LazyError
from anymail.utils import (
@@ -66,11 +55,11 @@ class ParseAddressListTests(SimpleTestCase):
self.assertEqual(parsed.address, 'Display Name <test@example.com>')
def test_unicode_display_name(self):
parsed_list = parse_address_list([u'"Unicode \N{HEAVY BLACK HEART}" <test@example.com>'])
parsed_list = parse_address_list(['"Unicode \N{HEAVY BLACK HEART}" <test@example.com>'])
self.assertEqual(len(parsed_list), 1)
parsed = parsed_list[0]
self.assertEqual(parsed.addr_spec, "test@example.com")
self.assertEqual(parsed.display_name, u"Unicode \N{HEAVY BLACK HEART}")
self.assertEqual(parsed.display_name, "Unicode \N{HEAVY BLACK HEART}")
# formatted display-name automatically shifts to quoted-printable/base64 for non-ascii chars:
self.assertEqual(parsed.address, '=?utf-8?b?VW5pY29kZSDinaQ=?= <test@example.com>')
@@ -83,13 +72,13 @@ class ParseAddressListTests(SimpleTestCase):
parse_address_list(['Display Name, Inc. <test@example.com>'])
def test_idn(self):
parsed_list = parse_address_list([u"idn@\N{ENVELOPE}.example.com"])
parsed_list = parse_address_list(["idn@\N{ENVELOPE}.example.com"])
self.assertEqual(len(parsed_list), 1)
parsed = parsed_list[0]
self.assertEqual(parsed.addr_spec, u"idn@\N{ENVELOPE}.example.com")
self.assertEqual(parsed.addr_spec, "idn@\N{ENVELOPE}.example.com")
self.assertEqual(parsed.address, "idn@xn--4bi.example.com") # punycode-encoded domain
self.assertEqual(parsed.username, "idn")
self.assertEqual(parsed.domain, u"\N{ENVELOPE}.example.com")
self.assertEqual(parsed.domain, "\N{ENVELOPE}.example.com")
def test_none_address(self):
# used for, e.g., telling Mandrill to use template default from_email
@@ -139,10 +128,9 @@ class ParseAddressListTests(SimpleTestCase):
parse_address_list(['"Display Name"', '<valid@example.com>'])
def test_invalid_with_unicode(self):
# (assertRaisesMessage can't handle unicode in Python 2)
with self.assertRaises(AnymailInvalidAddress) as cm:
parse_address_list([u"\N{ENVELOPE}"])
self.assertIn(u"Invalid email address '\N{ENVELOPE}'", six.text_type(cm.exception))
with self.assertRaisesMessage(AnymailInvalidAddress,
"Invalid email address '\N{ENVELOPE}'"):
parse_address_list(["\N{ENVELOPE}"])
def test_single_string(self):
# bare strings are used by the from_email parsing in BasePayload
@@ -151,12 +139,12 @@ class ParseAddressListTests(SimpleTestCase):
self.assertEqual(parsed_list[0].addr_spec, "one@example.com")
def test_lazy_strings(self):
parsed_list = parse_address_list([ugettext_lazy('"Example, Inc." <one@example.com>')])
parsed_list = parse_address_list([gettext_lazy('"Example, Inc." <one@example.com>')])
self.assertEqual(len(parsed_list), 1)
self.assertEqual(parsed_list[0].display_name, "Example, Inc.")
self.assertEqual(parsed_list[0].addr_spec, "one@example.com")
parsed_list = parse_address_list(ugettext_lazy("one@example.com"))
parsed_list = parse_address_list(gettext_lazy("one@example.com"))
self.assertEqual(len(parsed_list), 1)
self.assertEqual(parsed_list[0].display_name, "")
self.assertEqual(parsed_list[0].addr_spec, "one@example.com")
@@ -221,47 +209,38 @@ class LazyCoercionTests(SimpleTestCase):
"""Test utils.is_lazy and force_non_lazy*"""
def test_is_lazy(self):
self.assertTrue(is_lazy(ugettext_lazy("lazy string is lazy")))
self.assertTrue(is_lazy(gettext_lazy("lazy string is lazy")))
def test_not_lazy(self):
self.assertFalse(is_lazy(u"text not lazy"))
self.assertFalse(is_lazy("text not lazy"))
self.assertFalse(is_lazy(b"bytes not lazy"))
self.assertFalse(is_lazy(None))
self.assertFalse(is_lazy({'dict': "not lazy"}))
self.assertFalse(is_lazy(["list", "not lazy"]))
self.assertFalse(is_lazy(object()))
self.assertFalse(is_lazy([ugettext_lazy("doesn't recurse")]))
self.assertFalse(is_lazy([gettext_lazy("doesn't recurse")]))
def test_force_lazy(self):
result = force_non_lazy(ugettext_lazy(u"text"))
self.assertIsInstance(result, six.text_type)
self.assertEqual(result, u"text")
result = force_non_lazy(gettext_lazy("text"))
self.assertIsInstance(result, str)
self.assertEqual(result, "text")
@skipIf(string_concat is None, "string_concat not in this Django version")
def test_force_concat(self):
self.assertTrue(is_lazy(string_concat(ugettext_lazy("concatenation"),
ugettext_lazy("is lazy"))))
result = force_non_lazy(string_concat(ugettext_lazy(u"text"), ugettext_lazy("concat")))
self.assertIsInstance(result, six.text_type)
self.assertEqual(result, u"textconcat")
@skipIf(format_lazy is None, "format_lazy not in this Django version")
def test_format_lazy(self):
self.assertTrue(is_lazy(format_lazy("{0}{1}",
ugettext_lazy("concatenation"), ugettext_lazy("is lazy"))))
gettext_lazy("concatenation"), gettext_lazy("is lazy"))))
result = force_non_lazy(format_lazy("{first}/{second}",
first=ugettext_lazy(u"text"), second=ugettext_lazy("format")))
self.assertIsInstance(result, six.text_type)
self.assertEqual(result, u"text/format")
first=gettext_lazy("text"), second=gettext_lazy("format")))
self.assertIsInstance(result, str)
self.assertEqual(result, "text/format")
def test_force_string(self):
result = force_non_lazy(u"text")
self.assertIsInstance(result, six.text_type)
self.assertEqual(result, u"text")
result = force_non_lazy("text")
self.assertIsInstance(result, str)
self.assertEqual(result, "text")
def test_force_bytes(self):
result = force_non_lazy(b"bytes \xFE")
self.assertIsInstance(result, six.binary_type)
self.assertIsInstance(result, bytes)
self.assertEqual(result, b"bytes \xFE")
def test_force_none(self):
@@ -269,16 +248,16 @@ class LazyCoercionTests(SimpleTestCase):
self.assertIsNone(result)
def test_force_dict(self):
result = force_non_lazy_dict({'a': 1, 'b': ugettext_lazy(u"b"),
'c': {'c1': ugettext_lazy(u"c1")}})
self.assertEqual(result, {'a': 1, 'b': u"b", 'c': {'c1': u"c1"}})
self.assertIsInstance(result['b'], six.text_type)
self.assertIsInstance(result['c']['c1'], six.text_type)
result = force_non_lazy_dict({'a': 1, 'b': gettext_lazy("b"),
'c': {'c1': gettext_lazy("c1")}})
self.assertEqual(result, {'a': 1, 'b': "b", 'c': {'c1': "c1"}})
self.assertIsInstance(result['b'], str)
self.assertIsInstance(result['c']['c1'], str)
def test_force_list(self):
result = force_non_lazy_list([0, ugettext_lazy(u"b"), u"c"])
self.assertEqual(result, [0, u"b", u"c"]) # coerced to list
self.assertIsInstance(result[1], six.text_type)
result = force_non_lazy_list([0, gettext_lazy("b"), "c"])
self.assertEqual(result, [0, "b", "c"]) # coerced to list
self.assertIsInstance(result[1], str)
class UpdateDeepTests(SimpleTestCase):
@@ -313,7 +292,7 @@ class RequestUtilsTests(SimpleTestCase):
def setUp(self):
self.request_factory = RequestFactory()
super(RequestUtilsTests, self).setUp()
super().setUp()
@staticmethod
def basic_auth(username, password):

View File

@@ -1,6 +1,4 @@
# Anymail test utils
import collections
import logging
import os
import re
import sys
@@ -8,10 +6,10 @@ import uuid
import warnings
from base64 import b64decode
from contextlib import contextmanager
from io import StringIO
from unittest import TestCase
import six
from django.test import Client
from six.moves import StringIO
def decode_att(att):
@@ -71,36 +69,9 @@ def sample_email_content(filename=SAMPLE_EMAIL_FILENAME):
# TestCase helpers
#
# noinspection PyUnresolvedReferences
class AnymailTestMixin:
class AnymailTestMixin(TestCase):
"""Helpful additional methods for Anymail tests"""
def assertLogs(self, logger=None, level=None):
# Note: django.utils.log.DEFAULT_LOGGING config is set to *not* propagate certain
# logging records. That means you *can't* capture those logs at the root (None) logger.
assert logger is not None # `None` root logger won't reliably capture
try:
return super(AnymailTestMixin, self).assertLogs(logger, level)
except (AttributeError, TypeError):
# Python <3.4: use our backported assertLogs
return _AssertLogsContext(self, logger, level)
def assertWarns(self, expected_warning, msg=None):
# We only support the context-manager version
try:
return super(AnymailTestMixin, self).assertWarns(expected_warning, msg=msg)
except TypeError:
# Python 2.x: use our backported assertWarns
return _AssertWarnsContext(expected_warning, self, msg=msg)
def assertWarnsRegex(self, expected_warning, expected_regex, msg=None):
# We only support the context-manager version
try:
return super(AnymailTestMixin, self).assertWarnsRegex(expected_warning, expected_regex, msg=msg)
except TypeError:
# Python 2.x: use our backported assertWarns
return _AssertWarnsContext(expected_warning, self, expected_regex=expected_regex, msg=msg)
@contextmanager
def assertDoesNotWarn(self, disallowed_warning=Warning):
"""Makes test error (rather than fail) if disallowed_warning occurs.
@@ -115,31 +86,13 @@ class AnymailTestMixin:
finally:
warnings.resetwarnings()
def assertCountEqual(self, *args, **kwargs):
try:
return super(AnymailTestMixin, self).assertCountEqual(*args, **kwargs)
except TypeError:
return self.assertItemsEqual(*args, **kwargs) # Python 2
def assertRaisesRegex(self, *args, **kwargs):
try:
return super(AnymailTestMixin, self).assertRaisesRegex(*args, **kwargs)
except TypeError:
return self.assertRaisesRegexp(*args, **kwargs) # Python 2
def assertRegex(self, *args, **kwargs):
try:
return super(AnymailTestMixin, self).assertRegex(*args, **kwargs)
except TypeError:
return self.assertRegexpMatches(*args, **kwargs) # Python 2
def assertEqualIgnoringHeaderFolding(self, first, second, msg=None):
# Unfold (per RFC-8222) all text first and second, then compare result.
# Useful for message/rfc822 attachment tests, where various Python email
# versions handled folding slightly differently.
# (Technically, this is unfolding both headers and (incorrectly) bodies,
# but that doesn't really affect the tests.)
if isinstance(first, six.binary_type) and isinstance(second, six.binary_type):
if isinstance(first, bytes) and isinstance(second, bytes):
first = first.decode('utf-8')
second = second.decode('utf-8')
first = rfc822_unfold(first)
@@ -190,131 +143,6 @@ class AnymailTestMixin:
sys.stdout = old_stdout
# Backported from Python 3.4
class _AssertLogsContext(object):
"""A context manager used to implement TestCase.assertLogs()."""
LOGGING_FORMAT = "%(levelname)s:%(name)s:%(message)s"
def __init__(self, test_case, logger_name, level):
self.test_case = test_case
self.logger_name = logger_name
if level:
self.level = logging._nameToLevel.get(level, level)
else:
self.level = logging.INFO
self.msg = None
def _raiseFailure(self, standardMsg):
msg = self.test_case._formatMessage(self.msg, standardMsg)
raise self.test_case.failureException(msg)
class _CapturingHandler(logging.Handler):
"""A logging handler capturing all (raw and formatted) logging output."""
_LoggingWatcher = collections.namedtuple("_LoggingWatcher", ["records", "output"])
def __init__(self):
logging.Handler.__init__(self)
self.watcher = self._LoggingWatcher([], [])
def flush(self):
pass
def emit(self, record):
self.watcher.records.append(record)
msg = self.format(record)
self.watcher.output.append(msg)
def __enter__(self):
if isinstance(self.logger_name, logging.Logger):
logger = self.logger = self.logger_name
else:
logger = self.logger = logging.getLogger(self.logger_name)
formatter = logging.Formatter(self.LOGGING_FORMAT)
handler = self._CapturingHandler()
handler.setFormatter(formatter)
self.watcher = handler.watcher
self.old_handlers = logger.handlers[:]
self.old_level = logger.level
self.old_propagate = logger.propagate
logger.handlers = [handler]
logger.setLevel(self.level)
logger.propagate = False
return handler.watcher
def __exit__(self, exc_type, exc_value, tb):
self.logger.handlers = self.old_handlers
self.logger.propagate = self.old_propagate
self.logger.setLevel(self.old_level)
if exc_type is not None:
# let unexpected exceptions pass through
return False
if len(self.watcher.records) == 0:
self._raiseFailure(
"no logs of level {} or higher triggered on {}"
.format(logging.getLevelName(self.level), self.logger.name))
# Backported from python 3.5
class _AssertWarnsContext(object):
"""A context manager used to implement TestCase.assertWarns* methods."""
def __init__(self, expected, test_case, expected_regex=None, msg=None):
self.test_case = test_case
self.expected = expected
self.test_case = test_case
if expected_regex is not None:
expected_regex = re.compile(expected_regex)
self.expected_regex = expected_regex
self.msg = msg
def _raiseFailure(self, standardMsg):
# msg = self.test_case._formatMessage(self.msg, standardMsg)
msg = self.msg or standardMsg
raise self.test_case.failureException(msg)
def __enter__(self):
# The __warningregistry__'s need to be in a pristine state for tests
# to work properly.
for v in sys.modules.values():
if getattr(v, '__warningregistry__', None):
v.__warningregistry__ = {}
self.warnings_manager = warnings.catch_warnings(record=True)
self.warnings = self.warnings_manager.__enter__()
warnings.simplefilter("always", self.expected)
return self
def __exit__(self, exc_type, exc_value, tb):
self.warnings_manager.__exit__(exc_type, exc_value, tb)
if exc_type is not None:
# let unexpected exceptions pass through
return
try:
exc_name = self.expected.__name__
except AttributeError:
exc_name = str(self.expected)
first_matching = None
for m in self.warnings:
w = m.message
if not isinstance(w, self.expected):
continue
if first_matching is None:
first_matching = w
if self.expected_regex is not None and not self.expected_regex.search(str(w)):
continue
# store warning for later retrieval
self.warning = w
self.filename = m.filename
self.lineno = m.lineno
return
# Now we simply try to choose a helpful failure message
if first_matching is not None:
self._raiseFailure('"{}" does not match "{}"'.format(
self.expected_regex.pattern, str(first_matching)))
self._raiseFailure("{} not triggered".format(exc_name))
class ClientWithCsrfChecks(Client):
"""Django test Client that enforces CSRF checks
@@ -322,8 +150,7 @@ class ClientWithCsrfChecks(Client):
"""
def __init__(self, **defaults):
super(ClientWithCsrfChecks, self).__init__(
enforce_csrf_checks=True, **defaults)
super().__init__(enforce_csrf_checks=True, **defaults)
# dedent for bytestrs

View File

@@ -25,7 +25,7 @@ class WebhookTestCase(AnymailTestMixin, SimpleTestCase):
client_class = ClientWithCsrfChecks
def setUp(self):
super(WebhookTestCase, self).setUp()
super().setUp()
# Use correct basic auth by default (individual tests can override):
self.set_basic_auth()
@@ -71,15 +71,24 @@ class WebhookTestCase(AnymailTestMixin, SimpleTestCase):
return actual_kwargs
# noinspection PyUnresolvedReferences
class WebhookBasicAuthTestsMixin(object):
class WebhookBasicAuthTestCase(WebhookTestCase):
"""Common test cases for webhook basic authentication.
Instantiate for each ESP's webhooks by:
- mixing into WebhookTestCase
- subclassing
- defining call_webhook to invoke the ESP's webhook
- adding or overriding any tests as appropriate
"""
def __init__(self, methodName='runTest'):
if self.__class__ is WebhookBasicAuthTestCase:
# don't run these tests on the abstract base implementation
methodName = 'runNoTestsInBaseClass'
super().__init__(methodName)
def runNoTestsInBaseClass(self):
pass
should_warn_if_no_auth = True # subclass set False if other webhook verification used
def call_webhook(self):

15
tox.ini
View File

@@ -3,27 +3,24 @@ envlist =
# Factors: django-python-extras
# Test these environments first, to catch most errors early...
lint
django30-py37-all
django111-py27-all
django31-py38-all
django20-py35-all
docs
# ... then test all the other supported combinations:
django30-py{36,38,py3}-all
django31-py{36,37,py3}-all
django30-py{36,37,38,py3}-all
django22-py{35,36,37,py3}-all
django21-py{35,36,37,py3}-all
django20-py{35,36,py3}-all
django111-py{34,35,36,py}-all
django20-py{36,py3}-all
# ... then prereleases (if available):
django31-py{36,37,38,py3}-all
djangoDev-py{36,37,38}-all
# ... then partial installation (limit extras):
django22-py37-{none,amazon_ses,sparkpost}
django31-py37-{none,amazon_ses,sparkpost}
# ... then older versions of some dependencies:
django111-py27-all-old_urllib3
django22-py37-all-old_urllib3
[testenv]
deps =
django111: django~=1.11.0
django20: django~=2.0.0
django21: django~=2.1.0
django22: django~=2.2.0