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 language: python
os: linux
dist: xenial dist: xenial
branches: branches:
@@ -15,9 +15,9 @@ env:
# Let Travis report failures that tox.ini would normally ignore: # Let Travis report failures that tox.ini would normally ignore:
- TOX_FORCE_IGNORE_OUTCOME=false - TOX_FORCE_IGNORE_OUTCOME=false
matrix: jobs:
include: include:
- python: 3.6 - python: 3.8
env: TOXENV="lint,docs" env: TOXENV="lint,docs"
# Anymail supports the same Python versions as Django, plus PyPy. # 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 # 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. # 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+ # Django 2.0: Python 3.5+
- { env: TOXENV=django20-py35-all, python: 3.5 } - { env: TOXENV=django20-py35-all, python: 3.5 }
- { env: TOXENV=django20-py36-all, python: 3.6 } - { env: TOXENV=django20-py36-all, python: 3.6 }
@@ -44,7 +38,7 @@ matrix:
# Django 2.2: Python 3.5, 3.6, or 3.7 # Django 2.2: Python 3.5, 3.6, or 3.7
- { env: TOXENV=django22-py35-all, python: 3.5 } - { env: TOXENV=django22-py35-all, python: 3.5 }
- { env: TOXENV=django22-py36-all, python: 3.6 } - { 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 } - { env: TOXENV=django22-pypy3-all, python: pypy3 }
# Django 3.0: Python 3.6, 3.7, or 3.8 # Django 3.0: Python 3.6, 3.7, or 3.8
- { env: TOXENV=django30-py36-all, python: 3.6 } - { env: TOXENV=django30-py36-all, python: 3.6 }
@@ -54,16 +48,15 @@ matrix:
# Django 3.1: Python 3.6, 3.7, or 3.8 # Django 3.1: Python 3.6, 3.7, or 3.8
- { env: TOXENV=django31-py36-all, python: 3.6 } - { env: TOXENV=django31-py36-all, python: 3.6 }
- { env: TOXENV=django31-py37-all, python: 3.7 } - { 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 } - { env: TOXENV=django31-pypy3-all, python: pypy3 }
# Django current development (direct from GitHub source main branch): # Django current development (direct from GitHub source main branch):
- { env: TOXENV=djangoDev-py37-all, python: 3.7 } - { env: TOXENV=djangoDev-py37-all, python: 3.7 }
# Install without optional extras (don't need to cover entire matrix) # Install without optional extras (don't need to cover entire matrix)
- { env: TOXENV=django22-py37-none, python: 3.7 } - { env: TOXENV=django31-py37-none, python: 3.7 }
- { env: TOXENV=django22-py37-amazon_ses, python: 3.7 } - { env: TOXENV=django31-py37-amazon_ses, python: 3.7 }
- { env: TOXENV=django22-py37-sparkpost, python: 3.7 } - { env: TOXENV=django31-py37-sparkpost, python: 3.7 }
# Test some specific older package versions # 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 } - { env: TOXENV=django22-py37-all-old_urllib3, python: 3.7 }
allow_failures: allow_failures:

View File

@@ -25,6 +25,30 @@ Release history
^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^
.. This extra heading level keeps the ToC from becoming unmanageably long .. 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 v7.2 LTS
-------- --------

View File

@@ -10,7 +10,6 @@ name = "pypi"
boto3 = "*" boto3 = "*"
django = "*" django = "*"
requests = "*" requests = "*"
six = "*"
sparkpost = "*" sparkpost = "*"
[dev-packages] [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, * Inbound message support, to receive email through your ESP's webhooks,
with simplified, portable access to attachments and other inbound content with simplified, portable access to attachments and other inbound content
Anymail is released under the BSD license. It is extensively tested against Anymail maintains compatibility with all Django versions that are in mainstream
Django 1.11--3.0 on all Python versions supported by Django. or extended support, plus (usually) a few older Django versions, and is extensively
Anymail releases follow `semantic versioning <http://semver.org/>`_. 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 .. END shared-intro
@@ -124,7 +129,7 @@ or SparkPost or any other supported ESP where you see "mailgun":
msg = EmailMultiAlternatives( msg = EmailMultiAlternatives(
subject="Please activate your account", 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>", from_email="Example <admin@example.com>",
to=["New User <user1@example.com>", "account.manager@example.com"], to=["New User <user1@example.com>", "account.manager@example.com"],
reply_to=["Helpdesk <support@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: # Include an inline image in the html:
logo_cid = attach_inline_image_file(msg, "/path/to/logo.jpg") logo_cid = attach_inline_image_file(msg, "/path/to/logo.jpg")
html = """<img alt="Logo" src="cid:{logo_cid}"> 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) your account</p>""".format(logo_cid=logo_cid)
msg.attach_alternative(html, "text/html") 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 __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" __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.charset import Charset, QP
from email.header import Header
from email.mime.base import MIMEBase
from email.mime.text import MIMEText from email.mime.text import MIMEText
from django.core.mail import BadHeaderError
from .base import AnymailBaseBackend, BasePayload from .base import AnymailBaseBackend, BasePayload
from .._version import __version__ from .._version import __version__
from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled
@@ -15,42 +11,14 @@ try:
import boto3 import boto3
from botocore.client import Config from botocore.client import Config
from botocore.exceptions import BotoCoreError, ClientError, ConnectionError from botocore.exceptions import BotoCoreError, ClientError, ConnectionError
except ImportError: except ImportError as err:
raise AnymailImproperlyInstalled(missing_package='boto3', backend='amazon_ses') raise AnymailImproperlyInstalled(missing_package='boto3', backend='amazon_ses') from err
# boto3 has several root exception classes; this is meant to cover all of them # boto3 has several root exception classes; this is meant to cover all of them
BOTO_BASE_ERRORS = (BotoCoreError, ClientError, ConnectionError) 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): class EmailBackend(AnymailBaseBackend):
""" """
Amazon SES Email Backend (using boto3) Amazon SES Email Backend (using boto3)
@@ -60,7 +28,7 @@ class EmailBackend(AnymailBaseBackend):
def __init__(self, **kwargs): def __init__(self, **kwargs):
"""Init options from Django settings""" """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 # 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.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, 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: except BOTO_BASE_ERRORS:
if not self.fail_silently: if not self.fail_silently:
raise raise
else:
return True # created client
def close(self): def close(self):
if self.client is None: if self.client is None:
@@ -98,7 +68,7 @@ class EmailBackend(AnymailBaseBackend):
except BOTO_BASE_ERRORS as err: except BOTO_BASE_ERRORS as err:
# ClientError has a response attr with parsed json error response (other errors don't) # 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, 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 return response
def parse_recipient_status(self, response, payload, message): def parse_recipient_status(self, response, payload, message):
@@ -125,12 +95,9 @@ class AmazonSESBasePayload(BasePayload):
class AmazonSESSendRawEmailPayload(AmazonSESBasePayload): class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
def init_payload(self): def init_payload(self):
super(AmazonSESSendRawEmailPayload, self).init_payload() super().init_payload()
self.all_recipients = [] self.all_recipients = []
self.mime_message = self.message.message() 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: # Work around an Amazon SES bug where, if all of:
# - the message body (text or html) contains non-ASCII characters # - the message body (text or html) contains non-ASCII characters
@@ -165,7 +132,7 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
except (KeyError, TypeError) as err: except (KeyError, TypeError) as err:
raise AnymailAPIError( raise AnymailAPIError(
"%s parsing Amazon SES send result %r" % (str(err), response), "%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") recipient_status = AnymailRecipientStatus(message_id=message_id, status="queued")
return {recipient.addr_spec: recipient_status for recipient in self.all_recipients} 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/ # (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.) # and https://forums.aws.amazon.com/thread.jspa?messageID=782922.)
# To support reliable retrieval in webhooks, just use custom headers for metadata. # 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): def set_tags(self, tags):
# See note about Amazon SES Message Tags and custom headers in set_metadata above. # 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. # To support reliable retrieval in webhooks, use custom headers for tags.
# (There are no restrictions on number or content for custom header tags.) # (There are no restrictions on number or content for custom header tags.)
for tag in 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 # 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. # 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): class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload):
def init_payload(self): def init_payload(self):
super(AmazonSESSendBulkTemplatedEmailPayload, self).init_payload() super().init_payload()
# late-bind recipients and merge_data in call_send_api # late-bind recipients and merge_data in call_send_api
self.recipients = {"to": [], "cc": [], "bcc": []} self.recipients = {"to": [], "cc": [], "bcc": []}
self.merge_data = {} self.merge_data = {}
@@ -311,7 +278,7 @@ class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload):
except (KeyError, TypeError) as err: except (KeyError, TypeError) as err:
raise AnymailAPIError( raise AnymailAPIError(
"%s parsing Amazon SES send result %r" % (str(err), response), "%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"]] to_addrs = [to.addr_spec for to in self.recipients["to"]]
if len(anymail_statuses) != len(to_addrs): if len(anymail_statuses) != len(to_addrs):

View File

@@ -1,7 +1,6 @@
import json import json
from datetime import date, datetime from datetime import date, datetime
import six
from django.conf import settings from django.conf import settings
from django.core.mail.backends.base import BaseEmailBackend from django.core.mail.backends.base import BaseEmailBackend
from django.utils.timezone import is_naive, get_current_timezone, make_aware, utc 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): 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', self.ignore_unsupported_features = get_anymail_setting('ignore_unsupported_features',
kwargs=kwargs, default=False) kwargs=kwargs, default=False)
@@ -207,7 +206,7 @@ class AnymailBaseBackend(BaseEmailBackend):
(self.__class__.__module__, self.__class__.__name__)) (self.__class__.__module__, self.__class__.__name__))
class BasePayload(object): class BasePayload:
# Listing of EmailMessage/EmailMultiAlternatives attributes # Listing of EmailMessage/EmailMultiAlternatives attributes
# to process into Payload. Each item is in the form: # to process into Payload. Each item is in the form:
# (attr, combiner, converter) # (attr, combiner, converter)
@@ -365,7 +364,7 @@ class BasePayload(object):
# TypeError: must be str, not list # TypeError: must be str, not list
# TypeError: can only concatenate list (not "str") to list # TypeError: can only concatenate list (not "str") to list
# TypeError: Can't convert 'list' object to str implicitly # 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)) 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: except TypeError as err:
# Add some context to the "not JSON serializable" message # Add some context to the "not JSON serializable" message
raise AnymailSerializationError(orig_err=err, email_message=self.message, raise AnymailSerializationError(orig_err=err, email_message=self.message,
backend=self.backend, payload=self) backend=self.backend, payload=self) from None
@staticmethod @staticmethod
def _json_default(o): def _json_default(o):

View File

@@ -1,13 +1,11 @@
from __future__ import print_function from urllib.parse import urljoin
import requests import requests
import six
from six.moves.urllib.parse import urljoin
from anymail.utils import get_anymail_setting from anymail.utils import get_anymail_setting
from .base import AnymailBaseBackend, BasePayload from .base import AnymailBaseBackend, BasePayload
from ..exceptions import AnymailRequestsAPIError
from .._version import __version__ from .._version import __version__
from ..exceptions import AnymailRequestsAPIError
class AnymailRequestsBackend(AnymailBaseBackend): class AnymailRequestsBackend(AnymailBaseBackend):
@@ -19,7 +17,7 @@ class AnymailRequestsBackend(AnymailBaseBackend):
"""Init options from Django settings""" """Init options from Django settings"""
self.api_url = api_url self.api_url = api_url
self.timeout = get_anymail_setting('requests_timeout', kwargs=kwargs, default=30) self.timeout = get_anymail_setting('requests_timeout', kwargs=kwargs, default=30)
super(AnymailRequestsBackend, self).__init__(**kwargs) super().__init__(**kwargs)
self.session = None self.session = None
def open(self): def open(self):
@@ -57,7 +55,7 @@ class AnymailRequestsBackend(AnymailBaseBackend):
"Session has not been opened in {class_name}._send. " "Session has not been opened in {class_name}._send. "
"(This is either an implementation error in {class_name}, " "(This is either an implementation error in {class_name}, "
"or you are incorrectly calling _send directly.)".format(class_name=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): def post_to_esp(self, payload, message):
"""Post payload to ESP send API endpoint, and return the raw response. """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)), {}) exc_class = type('AnymailRequestsAPIError', (AnymailRequestsAPIError, type(err)), {})
raise exc_class( raise exc_class(
"Error posting to %s:" % params.get('url', '<missing url>'), "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) self.raise_for_status(response, payload, message)
return response return response
@@ -100,10 +98,10 @@ class AnymailRequestsBackend(AnymailBaseBackend):
""" """
try: try:
return response.json() return response.json()
except ValueError: except ValueError as err:
raise AnymailRequestsAPIError("Invalid JSON in %s API response" % self.esp_name, raise AnymailRequestsAPIError("Invalid JSON in %s API response" % self.esp_name,
email_message=message, payload=payload, response=response, email_message=message, payload=payload, response=response,
backend=self) backend=self) from err
@staticmethod @staticmethod
def _dump_api_request(response, **kwargs): def _dump_api_request(response, **kwargs):
@@ -113,21 +111,21 @@ class AnymailRequestsBackend(AnymailBaseBackend):
# If you need the raw bytes, configure HTTPConnection logging as shown # If you need the raw bytes, configure HTTPConnection logging as shown
# in http://docs.python-requests.org/en/v3.0.0/api/#api-changes) # in http://docs.python-requests.org/en/v3.0.0/api/#api-changes)
request = response.request # a PreparedRequest request = response.request # a PreparedRequest
print(u"\n===== Anymail API request") print("\n===== Anymail API request")
print(u"{method} {url}\n{headers}".format( print("{method} {url}\n{headers}".format(
method=request.method, url=request.url, 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()), for (header, value) in request.headers.items()),
)) ))
if request.body is not None: 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") else request.body.decode("utf-8", errors="replace")
).replace("\r\n", "\n") ).replace("\r\n", "\n")
print(body_text) print(body_text)
print(u"\n----- Response") print("\n----- Response")
print(u"HTTP {status} {reason}\n{headers}\n{body}".format( print("HTTP {status} {reason}\n{headers}\n{body}".format(
status=response.status_code, reason=response.reason, 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()), for (header, value) in response.headers.items()),
body=response.text, # Let Requests decode body content for us body=response.text, # Let Requests decode body content for us
)) ))
@@ -145,7 +143,7 @@ class RequestsPayload(BasePayload):
self.headers = headers self.headers = headers
self.files = files self.files = files
self.auth = auth self.auth = auth
super(RequestsPayload, self).__init__(message, defaults, backend) super().__init__(message, defaults, backend)
def get_request_params(self, api_url): def get_request_params(self, api_url):
"""Returns a dict of requests.request params that will send payload to the ESP. """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 datetime import datetime
from email.utils import encode_rfc2231 from email.utils import encode_rfc2231
from six.moves.urllib.parse import quote from urllib.parse import quote
from requests import Request 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 ..message import AnymailRecipientStatus
from ..utils import get_anymail_setting, rfc2822date 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- # 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.) # 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.) # (Note: when this workaround is removed, please also remove the "old_urllib3" tox envs.)
def is_requests_rfc_5758_compliant(): def is_requests_rfc_5758_compliant():
request = Request(method='POST', url='https://www.example.com', 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() prepared = request.prepare()
form_data = prepared.body # bytes form_data = prepared.body # bytes
return b'filename*=' not in form_data return b'filename*=' not in form_data
@@ -43,7 +42,7 @@ class EmailBackend(AnymailRequestsBackend):
default="https://api.mailgun.net/v3") default="https://api.mailgun.net/v3")
if not api_url.endswith("/"): if not api_url.endswith("/"):
api_url += "/" api_url += "/"
super(EmailBackend, self).__init__(api_url, **kwargs) super().__init__(api_url, **kwargs)
def build_message_payload(self, message, defaults): def build_message_payload(self, message, defaults):
return MailgunPayload(message, defaults, self) return MailgunPayload(message, defaults, self)
@@ -62,10 +61,10 @@ class EmailBackend(AnymailRequestsBackend):
try: try:
message_id = parsed_response["id"] message_id = parsed_response["id"]
mailgun_message = parsed_response["message"] mailgun_message = parsed_response["message"]
except (KeyError, TypeError): except (KeyError, TypeError) as err:
raise AnymailRequestsAPIError("Invalid Mailgun API response format", raise AnymailRequestsAPIError("Invalid Mailgun API response format",
email_message=message, payload=payload, response=response, email_message=message, payload=payload, response=response,
backend=self) backend=self) from err
if not mailgun_message.startswith("Queued"): if not mailgun_message.startswith("Queued"):
raise AnymailRequestsAPIError("Unrecognized Mailgun API message '%s'" % mailgun_message, raise AnymailRequestsAPIError("Unrecognized Mailgun API message '%s'" % mailgun_message,
email_message=message, payload=payload, response=response, email_message=message, payload=payload, response=response,
@@ -89,7 +88,7 @@ class MailgunPayload(RequestsPayload):
self.merge_metadata = {} self.merge_metadata = {}
self.to_emails = [] 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): def get_api_endpoint(self):
if self.sender_domain is None: if self.sender_domain is None:
@@ -105,7 +104,7 @@ class MailgunPayload(RequestsPayload):
return "%s/messages" % quote(self.sender_domain, safe='') return "%s/messages" % quote(self.sender_domain, safe='')
def get_request_params(self, api_url): 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 non_ascii_filenames = [filename
for (field, (filename, content, mimetype)) in params["files"] for (field, (filename, content, mimetype)) in params["files"]
if filename is not None and not isascii(filename)] if filename is not None and not isascii(filename)]
@@ -122,9 +121,7 @@ class MailgunPayload(RequestsPayload):
prepared = Request(**params).prepare() prepared = Request(**params).prepare()
form_data = prepared.body # bytes form_data = prepared.body # bytes
for filename in non_ascii_filenames: # text for filename in non_ascii_filenames: # text
rfc2231_filename = encode_rfc2231( # wants a str (text in PY3, bytes in PY2) rfc2231_filename = encode_rfc2231(filename, charset="utf-8")
filename if isinstance(filename, str) else filename.encode("utf-8"),
charset="utf-8")
form_data = form_data.replace( form_data = form_data.replace(
b'filename*=' + rfc2231_filename.encode("utf-8"), b'filename*=' + rfc2231_filename.encode("utf-8"),
b'filename="' + filename.encode("utf-8") + b'"') b'filename="' + filename.encode("utf-8") + b'"')

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,7 +30,7 @@ class EmailBackend(AnymailRequestsBackend):
) )
if not api_url.endswith("/"): if not api_url.endswith("/"):
api_url += "/" api_url += "/"
super(EmailBackend, self).__init__(api_url, **kwargs) super().__init__(api_url, **kwargs)
def build_message_payload(self, message, defaults): def build_message_payload(self, message, defaults):
return SendinBluePayload(message, defaults, self) return SendinBluePayload(message, defaults, self)
@@ -53,10 +53,10 @@ class EmailBackend(AnymailRequestsBackend):
parsed_response = self.deserialize_json_response(response, payload, message) parsed_response = self.deserialize_json_response(response, payload, message)
try: try:
message_id = parsed_response['messageId'] message_id = parsed_response['messageId']
except (KeyError, TypeError): except (KeyError, TypeError) as err:
raise AnymailRequestsAPIError("Invalid SendinBlue API response format", raise AnymailRequestsAPIError("Invalid SendinBlue API response format",
email_message=message, payload=payload, response=response, email_message=message, payload=payload, response=response,
backend=self) backend=self) from err
status = AnymailRecipientStatus(message_id=message_id, status="queued") status = AnymailRecipientStatus(message_id=message_id, status="queued")
return {recipient.addr_spec: status for recipient in payload.all_recipients} 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['api-key'] = backend.api_key
http_headers['Content-Type'] = 'application/json' 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): def get_api_endpoint(self):
return "smtp/email" 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 .base import AnymailBaseBackend, BasePayload
from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled, AnymailConfigurationError from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled, AnymailConfigurationError
from ..message import AnymailRecipientStatus from ..message import AnymailRecipientStatus
@@ -7,8 +5,8 @@ from ..utils import get_anymail_setting
try: try:
from sparkpost import SparkPost, SparkPostException from sparkpost import SparkPost, SparkPostException
except ImportError: except ImportError as err:
raise AnymailImproperlyInstalled(missing_package='sparkpost', backend='sparkpost') raise AnymailImproperlyInstalled(missing_package='sparkpost', backend='sparkpost') from err
class EmailBackend(AnymailBaseBackend): class EmailBackend(AnymailBaseBackend):
@@ -20,7 +18,7 @@ class EmailBackend(AnymailBaseBackend):
def __init__(self, **kwargs): def __init__(self, **kwargs):
"""Init options from Django settings""" """Init options from Django settings"""
super(EmailBackend, self).__init__(**kwargs) super().__init__(**kwargs)
# SPARKPOST_API_KEY is optional - library reads from env by default # SPARKPOST_API_KEY is optional - library reads from env by default
self.api_key = get_anymail_setting('api_key', esp_name=self.esp_name, self.api_key = get_anymail_setting('api_key', esp_name=self.esp_name,
kwargs=kwargs, allow_bare=True, default=None) kwargs=kwargs, allow_bare=True, default=None)
@@ -43,7 +41,7 @@ class EmailBackend(AnymailBaseBackend):
"You may need to set ANYMAIL = {'SPARKPOST_API_KEY': ...} " "You may need to set ANYMAIL = {'SPARKPOST_API_KEY': ...} "
"or ANYMAIL_SPARKPOST_API_KEY in your Django settings, " "or ANYMAIL_SPARKPOST_API_KEY in your Django settings, "
"or SPARKPOST_API_KEY in your environment." % str(err) "or SPARKPOST_API_KEY in your environment." % str(err)
) ) from err
# Note: SparkPost python API doesn't expose requests session sharing # Note: SparkPost python API doesn't expose requests session sharing
# (so there's no need to implement open/close connection management here) # (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, str(err), backend=self, email_message=message, payload=payload,
response=getattr(err, 'response', None), # SparkPostAPIException requests.Response response=getattr(err, 'response', None), # SparkPostAPIException requests.Response
status_code=getattr(err, 'status', None), # SparkPostAPIException HTTP status_code status_code=getattr(err, 'status', None), # SparkPostAPIException HTTP status_code
) ) from err
return response return response
def parse_recipient_status(self, response, payload, message): def parse_recipient_status(self, response, payload, message):
@@ -72,7 +70,7 @@ class EmailBackend(AnymailBaseBackend):
raise AnymailAPIError( raise AnymailAPIError(
"%s in SparkPost.transmissions.send result %r" % (str(err), response), "%s in SparkPost.transmissions.send result %r" % (str(err), response),
backend=self, email_message=message, payload=payload, backend=self, email_message=message, payload=payload,
) ) from err
# SparkPost doesn't (yet*) tell us *which* recipients were accepted or rejected. # SparkPost doesn't (yet*) tell us *which* recipients were accepted or rejected.
# (* looks like undocumented 'rcpt_to_errors' might provide this info.) # (* 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. # Allow replacing the payload, for testing.
# (Real backends would generally not implement this option.) # (Real backends would generally not implement this option.)
self._payload_class = kwargs.pop('payload_class', TestPayload) self._payload_class = kwargs.pop('payload_class', TestPayload)
super(EmailBackend, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if not hasattr(mail, 'outbox'): if not hasattr(mail, 'outbox'):
mail.outbox = [] # see django.core.mail.backends.locmem mail.outbox = [] # see django.core.mail.backends.locmem
@@ -60,8 +60,8 @@ class EmailBackend(AnymailBaseBackend):
def parse_recipient_status(self, response, payload, message): def parse_recipient_status(self, response, payload, message):
try: try:
return response['recipient_status'] return response['recipient_status']
except KeyError: except KeyError as err:
raise AnymailAPIError('Unparsable test response') raise AnymailAPIError('Unparsable test response') from err
class TestPayload(BasePayload): class TestPayload(BasePayload):

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ from django.core.mail import EmailMessage, EmailMultiAlternatives, make_msgid
from .utils import UNSET from .utils import UNSET
class AnymailMessageMixin(object): class AnymailMessageMixin(EmailMessage):
"""Mixin for EmailMessage that exposes Anymail features. """Mixin for EmailMessage that exposes Anymail features.
Use of this mixin is optional. You can always just set Anymail 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.merge_metadata = kwargs.pop('merge_metadata', UNSET)
self.anymail_status = AnymailStatus() self.anymail_status = AnymailStatus()
# noinspection PyArgumentList super().__init__(*args, **kwargs)
super(AnymailMessageMixin, self).__init__(*args, **kwargs)
def attach_inline_image_file(self, path, subtype=None, idstring="img", domain=None): 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""" """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""" """Information about an EmailMessage's send status for a single recipient"""
def __init__(self, message_id, status): 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 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""" """Information about an EmailMessage's send status for all recipients"""
def __init__(self): def __init__(self):

View File

@@ -2,19 +2,23 @@ from django.dispatch import Signal
# Outbound message, before sending # Outbound message, before sending
pre_send = Signal(providing_args=['message', 'esp_name']) # provides args: message, esp_name
pre_send = Signal()
# Outbound message, after sending # 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 # 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 # 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""" """Base class for normalized Anymail webhook events"""
def __init__(self, event_type, timestamp=None, event_id=None, esp_event=None, **kwargs): 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""" """Normalized delivery and tracking event for sent messages"""
def __init__(self, **kwargs): def __init__(self, **kwargs):
super(AnymailTrackingEvent, self).__init__(**kwargs) super().__init__(**kwargs)
self.click_url = kwargs.pop('click_url', None) # str self.click_url = kwargs.pop('click_url', None) # str
self.description = kwargs.pop('description', None) # str, usually human-readable, not normalized self.description = kwargs.pop('description', None) # str, usually human-readable, not normalized
self.message_id = kwargs.pop('message_id', None) # str, format may vary self.message_id = kwargs.pop('message_id', None) # str, format may vary
@@ -44,7 +48,7 @@ class AnymailInboundEvent(AnymailEvent):
"""Normalized inbound message event""" """Normalized inbound message event"""
def __init__(self, **kwargs): def __init__(self, **kwargs):
super(AnymailInboundEvent, self).__init__(**kwargs) super().__init__(**kwargs)
self.message = kwargs.pop('message', None) # anymail.inbound.AnymailInboundMessage 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.amazon_ses import AmazonSESInboundWebhookView, AmazonSESTrackingWebhookView
from .webhooks.mailgun import MailgunInboundWebhookView, MailgunTrackingWebhookView from .webhooks.mailgun import MailgunInboundWebhookView, MailgunTrackingWebhookView
@@ -12,23 +12,23 @@ from .webhooks.sparkpost import SparkPostInboundWebhookView, SparkPostTrackingWe
app_name = 'anymail' app_name = 'anymail'
urlpatterns = [ urlpatterns = [
url(r'^amazon_ses/inbound/$', AmazonSESInboundWebhookView.as_view(), name='amazon_ses_inbound_webhook'), re_path(r'^amazon_ses/inbound/$', AmazonSESInboundWebhookView.as_view(), name='amazon_ses_inbound_webhook'),
url(r'^mailgun/inbound(_mime)?/$', MailgunInboundWebhookView.as_view(), name='mailgun_inbound_webhook'), re_path(r'^mailgun/inbound(_mime)?/$', MailgunInboundWebhookView.as_view(), name='mailgun_inbound_webhook'),
url(r'^mailjet/inbound/$', MailjetInboundWebhookView.as_view(), name='mailjet_inbound_webhook'), re_path(r'^mailjet/inbound/$', MailjetInboundWebhookView.as_view(), name='mailjet_inbound_webhook'),
url(r'^postmark/inbound/$', PostmarkInboundWebhookView.as_view(), name='postmark_inbound_webhook'), re_path(r'^postmark/inbound/$', PostmarkInboundWebhookView.as_view(), name='postmark_inbound_webhook'),
url(r'^sendgrid/inbound/$', SendGridInboundWebhookView.as_view(), name='sendgrid_inbound_webhook'), re_path(r'^sendgrid/inbound/$', SendGridInboundWebhookView.as_view(), name='sendgrid_inbound_webhook'),
url(r'^sparkpost/inbound/$', SparkPostInboundWebhookView.as_view(), name='sparkpost_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'), re_path(r'^amazon_ses/tracking/$', AmazonSESTrackingWebhookView.as_view(), name='amazon_ses_tracking_webhook'),
url(r'^mailgun/tracking/$', MailgunTrackingWebhookView.as_view(), name='mailgun_tracking_webhook'), re_path(r'^mailgun/tracking/$', MailgunTrackingWebhookView.as_view(), name='mailgun_tracking_webhook'),
url(r'^mailjet/tracking/$', MailjetTrackingWebhookView.as_view(), name='mailjet_tracking_webhook'), re_path(r'^mailjet/tracking/$', MailjetTrackingWebhookView.as_view(), name='mailjet_tracking_webhook'),
url(r'^postmark/tracking/$', PostmarkTrackingWebhookView.as_view(), name='postmark_tracking_webhook'), re_path(r'^postmark/tracking/$', PostmarkTrackingWebhookView.as_view(), name='postmark_tracking_webhook'),
url(r'^sendgrid/tracking/$', SendGridTrackingWebhookView.as_view(), name='sendgrid_tracking_webhook'), re_path(r'^sendgrid/tracking/$', SendGridTrackingWebhookView.as_view(), name='sendgrid_tracking_webhook'),
url(r'^sendinblue/tracking/$', SendinBlueTrackingWebhookView.as_view(), name='sendinblue_tracking_webhook'), re_path(r'^sendinblue/tracking/$', SendinBlueTrackingWebhookView.as_view(), name='sendinblue_tracking_webhook'),
url(r'^sparkpost/tracking/$', SparkPostTrackingWebhookView.as_view(), name='sparkpost_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: # 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: # 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 base64
import mimetypes import mimetypes
from base64 import b64encode from base64 import b64encode
from datetime import datetime from collections.abc import Mapping, MutableMapping
from email.mime.base import MIMEBase from email.mime.base import MIMEBase
from email.utils import formatdate, getaddresses, unquote from email.utils import formatdate, getaddresses, parsedate_to_datetime, unquote
from time import mktime from urllib.parse import urlsplit, urlunsplit
import six
from django.conf import settings from django.conf import settings
from django.core.mail.message import DEFAULT_ATTACHMENT_MIME_TYPE, sanitize_address 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.functional import Promise
from django.utils.timezone import get_fixed_timezone, utc
from requests.structures import CaseInsensitiveDict from requests.structures import CaseInsensitiveDict
from six.moves.urllib.parse import urlsplit, urlunsplit
from .exceptions import AnymailConfigurationError, AnymailInvalidAddress from .exceptions import AnymailConfigurationError, AnymailInvalidAddress
if six.PY2: BASIC_NUMERIC_TYPES = (int, float)
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
UNSET = type('UNSET', (object,), {}) # Used as non-None default value 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`]: :return list[:class:`EmailAddress`]:
:raises :exc:`AnymailInvalidAddress`: :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] address_list = [address_list]
if address_list is None or address_list == [None]: 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: for address in parsed:
if address.username == '' or address.domain == '': if address.username == '' or address.domain == '':
# Django SMTP allows username-only emails, but they're not meaningful with an ESP # 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, problem=address.addr_spec,
source=u", ".join(address_list_strings), source=", ".join(address_list_strings),
where=u" in `%s`" % field if field else "", where=" in `%s`" % field if field else "",
) )
if len(parsed) > len(address_list): 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) raise AnymailInvalidAddress(errmsg)
return parsed return parsed
@@ -192,7 +179,7 @@ def parse_single_address(address, field=None):
return parsed[0] return parsed[0]
class EmailAddress(object): class EmailAddress:
"""A sanitized, complete email address with easy access """A sanitized, complete email address with easy access
to display-name, addr-spec (email), etc. to display-name, addr-spec (email), etc.
@@ -249,9 +236,8 @@ class EmailAddress(object):
This is essentially the same as :func:`email.utils.formataddr` This is essentially the same as :func:`email.utils.formataddr`
on the EmailAddress's name and email properties, but uses on the EmailAddress's name and email properties, but uses
Django's :func:`~django.core.mail.message.sanitize_address` Django's :func:`~django.core.mail.message.sanitize_address`
for improved PY2/3 compatibility, consistent handling of for consistent handling of encoding (a.k.a. charset) and
encoding (a.k.a. charset), and proper handling of IDN proper handling of IDN domain portions.
domain portions.
:param str|None encoding: :param str|None encoding:
the charset to use for the display-name portion; the charset to use for the display-name portion;
@@ -264,7 +250,7 @@ class EmailAddress(object):
return self.address return self.address
class Attachment(object): class Attachment:
"""A normalized EmailMessage.attachments item with additional functionality """A normalized EmailMessage.attachments item with additional functionality
Normalized to have these properties: Normalized to have these properties:
@@ -289,14 +275,10 @@ class Attachment(object):
self.name = attachment.get_filename() self.name = attachment.get_filename()
self.content = attachment.get_payload(decode=True) self.content = attachment.get_payload(decode=True)
if self.content is None: if self.content is None:
if hasattr(attachment, 'as_bytes'):
self.content = 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() 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): if content_disposition == 'inline' or (not content_disposition and 'Content-ID' in attachment):
self.inline = True self.inline = True
self.content_id = attachment["Content-ID"] # probably including the <...> self.content_id = attachment["Content-ID"] # probably including the <...>
@@ -319,23 +301,11 @@ class Attachment(object):
def b64content(self): def b64content(self):
"""Content encoded as a base64 ascii string""" """Content encoded as a base64 ascii string"""
content = self.content content = self.content
if isinstance(content, six.text_type): if isinstance(content, str):
content = content.encode(self.encoding) content = content.encode(self.encoding)
return b64encode(content).decode("ascii") 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): def get_anymail_setting(name, default=UNSET, esp_name=None, kwargs=None, allow_bare=False):
"""Returns an Anymail option from kwargs or Django settings. """Returns 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: if allow_bare:
message += " or %s" % setting message += " or %s" % setting
message += " in your Django settings" message += " in your Django settings"
raise AnymailConfigurationError(message) raise AnymailConfigurationError(message) from None
else: else:
return default return default
@@ -442,26 +412,11 @@ def querydict_getfirst(qdict, field, default=UNSET):
return qdict[field] # raise appropriate KeyError 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): def rfc2822date(dt):
"""Turn a datetime into a date string as specified in RFC 2822.""" """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 ..." # but treats naive datetimes as local rather than "UTC with no information ..."
timeval = timestamp(dt) timeval = dt.timestamp()
return formatdate(timeval, usegmt=True) return formatdate(timeval, usegmt=True)
@@ -480,7 +435,7 @@ def angle_wrap(s):
def is_lazy(obj): def is_lazy(obj):
"""Return True if obj is a Django lazy object.""" """Return True if obj is a Django lazy object."""
# See django.utils.functional.lazy. (This appears to be preferred # 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) 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.) (Similar to django.utils.encoding.force_text, but doesn't alter non-text objects.)
""" """
if is_lazy(obj): if is_lazy(obj):
return six.text_type(obj) return str(obj)
return obj return obj
@@ -541,27 +496,6 @@ def get_request_uri(request):
return url 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): def parse_rfc2822date(s):
"""Parses an RFC-2822 formatted date string into a datetime.datetime """Parses an RFC-2822 formatted date string into a datetime.datetime

View File

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

View File

@@ -1,6 +1,5 @@
import warnings import warnings
import six
from django.http import HttpResponse from django.http import HttpResponse
from django.utils.crypto import constant_time_compare from django.utils.crypto import constant_time_compare
from django.utils.decorators import method_decorator 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 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, # Mixin note: Django's View.__init__ doesn't cooperate with chaining,
# so all mixins that need __init__ must appear before View in MRO. # so all mixins that need __init__ must appear before View in MRO.
class AnymailBaseWebhookView(AnymailBasicAuthMixin, View): class AnymailCoreWebhookView(View):
"""Base view for processing ESP event webhooks """Common view for processing ESP event webhooks
ESP-specific implementations should subclass ESP-specific implementations will need to implement parse_events.
and implement parse_events. They may also
want to implement validate_request 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. if additional security is available.
""" """
def __init__(self, **kwargs): def __init__(self, **kwargs):
super(AnymailBaseWebhookView, self).__init__(**kwargs) super().__init__(**kwargs)
self.validators = collect_all_methods(self.__class__, 'validate_request') self.validators = collect_all_methods(self.__class__, 'validate_request')
# Subclass implementation: # Subclass implementation:
@@ -106,7 +64,7 @@ class AnymailBaseWebhookView(AnymailBasicAuthMixin, View):
@method_decorator(csrf_exempt) @method_decorator(csrf_exempt)
def dispatch(self, request, *args, **kwargs): 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): def head(self, request, *args, **kwargs):
# Some ESPs verify the webhook with a HEAD request at configuration time # 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" % raise NotImplementedError("%s.%s must declare esp_name class attr" %
(self.__class__.__module__, self.__class__.__name__)) (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) kwargs=kwargs, allow_bare=True, default=None)
webhook_signing_key = get_anymail_setting('webhook_signing_key', esp_name=self.esp_name, 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) 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 self.webhook_signing_key = webhook_signing_key.encode('ascii') # hmac.new requires bytes key
super(MailgunBaseWebhookView, self).__init__(**kwargs) super().__init__(**kwargs)
def validate_request(self, request): 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": if request.content_type == "application/json":
# New-style webhook: json payload with separate signature block # New-style webhook: json payload with separate signature block
try: try:
@@ -45,8 +45,7 @@ class MailgunBaseWebhookView(AnymailBaseWebhookView):
signature = signature_block['signature'] signature = signature_block['signature']
except (KeyError, ValueError, UnicodeDecodeError) as err: except (KeyError, ValueError, UnicodeDecodeError) as err:
raise AnymailWebhookValidationFailure( raise AnymailWebhookValidationFailure(
"Mailgun webhook called with invalid payload format", "Mailgun webhook called with invalid payload format") from err
raised_from=err)
else: else:
# Legacy webhook: signature fields are interspersed with other POST data # Legacy webhook: signature fields are interspersed with other POST data
try: try:
@@ -54,9 +53,10 @@ class MailgunBaseWebhookView(AnymailBaseWebhookView):
# (Fortunately, Django QueryDict is specced to return the last value.) # (Fortunately, Django QueryDict is specced to return the last value.)
token = request.POST['token'] token = request.POST['token']
timestamp = request.POST['timestamp'] timestamp = request.POST['timestamp']
signature = str(request.POST['signature']) # force to same type as hexdigest() (for python2) signature = request.POST['signature']
except KeyError: except KeyError as err:
raise AnymailWebhookValidationFailure("Mailgun webhook called without required security fields") 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'), expected_signature = hmac.new(key=self.webhook_signing_key, msg='{}{}'.format(timestamp, token).encode('ascii'),
digestmod=hashlib.sha256).hexdigest() 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.crypto import constant_time_compare
from django.utils.timezone import utc from django.utils.timezone import utc
from .base import AnymailBaseWebhookView from .base import AnymailBaseWebhookView, AnymailCoreWebhookView
from ..exceptions import AnymailWebhookValidationFailure from ..exceptions import AnymailWebhookValidationFailure
from ..inbound import AnymailInboundMessage from ..inbound import AnymailInboundMessage
from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType
from ..utils import get_anymail_setting, getfirst, get_request_uri from ..utils import get_anymail_setting, getfirst, get_request_uri
class MandrillSignatureMixin(object): class MandrillSignatureMixin(AnymailCoreWebhookView):
"""Validates Mandrill webhook signature""" """Validates Mandrill webhook signature"""
# These can be set from kwargs in View.as_view, or pulled from settings in init: # 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 webhook_url = None # optional; defaults to actual url used
def __init__(self, **kwargs): def __init__(self, **kwargs):
# noinspection PyUnresolvedReferences
esp_name = self.esp_name esp_name = self.esp_name
# webhook_key is required for POST, but not for HEAD when Mandrill validates webhook url. # 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... # 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, webhook_key = get_anymail_setting('webhook_key', esp_name=esp_name, default=None,
kwargs=kwargs, allow_bare=True) kwargs=kwargs, allow_bare=True)
if webhook_key is not None: 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, self.webhook_url = get_anymail_setting('webhook_url', esp_name=esp_name, default=None,
kwargs=kwargs, allow_bare=True) kwargs=kwargs, allow_bare=True)
# noinspection PyArgumentList super().__init__(**kwargs)
super(MandrillSignatureMixin, self).__init__(**kwargs)
def validate_request(self, request): def validate_request(self, request):
if self.webhook_key is None: if self.webhook_key is None:
# issue deferred "missing setting" error (re-call get-setting without a default) # 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) get_anymail_setting('webhook_key', esp_name=self.esp_name, allow_bare=True)
try: try:
signature = request.META["HTTP_X_MANDRILL_SIGNATURE"] signature = request.META["HTTP_X_MANDRILL_SIGNATURE"]
except KeyError: 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: # Mandrill signs the exact URL (including basic auth, if used) plus the sorted POST params:
url = self.webhook_url or get_request_uri(request) url = self.webhook_url or get_request_uri(request)

View File

@@ -1,12 +1,13 @@
import json import json
from datetime import datetime from datetime import datetime
from email.parser import BytesParser
from email.policy import default as default_policy
from django.utils.timezone import utc from django.utils.timezone import utc
from .base import AnymailBaseWebhookView from .base import AnymailBaseWebhookView
from .._email_compat import EmailBytesParser
from ..inbound import AnymailInboundMessage 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): class SendGridTrackingWebhookView(AnymailBaseWebhookView):
@@ -204,7 +205,7 @@ class SendGridInboundWebhookView(AnymailBaseWebhookView):
b"\r\n\r\n", b"\r\n\r\n",
request.body 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: for part in parsed_parts:
name = part.get_param('name', header='content-disposition') name = part.get_param('name', header='content-disposition')
if name == 'text': if name == 'text':

View File

@@ -40,7 +40,8 @@ class SparkPostBaseWebhookView(AnymailBaseWebhookView):
# Empty event (SparkPost sometimes sends as a "ping") # Empty event (SparkPost sometimes sends as a "ping")
event_class = event = None event_class = event = None
else: 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 return event_class, event, raw_event
def esp_to_anymail_event(self, 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 # Anymail documentation build configuration file, created by
# sphinx-quickstart # sphinx-quickstart
# #
@@ -50,9 +48,9 @@ source_suffix = '.rst'
master_doc = 'index' master_doc = 'index'
# General information about the project. # General information about the project.
project = u'Anymail' project = 'Anymail'
# noinspection PyShadowingBuiltins # 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 # The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the # |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 # Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title, author, documentclass [howto/manual]). # (source start file, target name, title, author, documentclass [howto/manual]).
latex_documents = [ latex_documents = [
('index', 'Anymail.tex', u'Anymail Documentation', ('index', 'Anymail.tex', 'Anymail Documentation',
u'Anymail contributors (see AUTHORS.txt)', 'manual'), 'Anymail contributors (see AUTHORS.txt)', 'manual'),
] ]
# The name of an image file (relative to this directory) to place at the top of # 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 # One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section). # (source start file, name, description, authors, manual section).
man_pages = [ man_pages = [
('index', 'anymail', u'Anymail Documentation', ('index', 'anymail', 'Anymail Documentation',
[u'Anymail contributors (see AUTHORS.txt)'], 1) ['Anymail contributors (see AUTHORS.txt)'], 1)
] ]
# If true, show URL addresses after external links. # If true, show URL addresses after external links.
@@ -247,8 +245,8 @@ man_pages = [
# (source start file, target name, title, author, # (source start file, target name, title, author,
# dir menu entry, description, category) # dir menu entry, description, category)
texinfo_documents = [ texinfo_documents = [
('index', 'Anymail', u'Anymail Documentation', ('index', 'Anymail', 'Anymail Documentation',
u'Anymail contributors (see AUTHORS.txt)', 'Anymail', 'Multi-ESP transactional email for Django.', 'Anymail contributors (see AUTHORS.txt)', 'Anymail', 'Multi-ESP transactional email for Django.',
'Miscellaneous'), 'Miscellaneous'),
] ]
@@ -270,14 +268,9 @@ extlinks = {
# -- Options for Intersphinx ------------------------------------------------ # -- Options for Intersphinx ------------------------------------------------
intersphinx_mapping = { 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/'), 'django': ('https://docs.djangoproject.com/en/stable/', 'https://docs.djangoproject.com/en/stable/_objects/'),
# Requests docs may be moving (Sep 2019): 'requests': ('https://requests.readthedocs.io/en/stable/', None),
# 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')),
} }

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. and other dependencies have changed out from under Anymail.
For local development, the recommended test command is 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 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 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. 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 $ 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`. 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 You'll need some version of Python 3 available. (If your system doesn't come
with those, `pyenv`_ is a helpful way to install and manage multiple Python versions.) with that, `pyenv`_ is a helpful way to install and manage multiple Python versions.)
.. code-block:: console .. code-block:: console
$ pip install tox # (if you haven't already) $ 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.: ## 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: ## to test more Python/Django versions:
$ tox --parallel auto # ALL 20+ envs! (in parallel if possible) $ 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_API_KEY='your-Mailgun-API-key'
$ export MAILGUN_TEST_DOMAIN='mail.example.com' # sending domain for that 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 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 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: .. _Django's added markup:
https://docs.djangoproject.com/en/stable/internals/contributing/writing-documentation/#django-specific-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 .. _extlinks: https://www.sphinx-doc.org/en/stable/usage/extensions/extlinks.html
.. _intersphinx: http://www.sphinx-doc.org/en/master/ext/intersphinx.html .. _intersphinx: https://www.sphinx-doc.org/en/stable/usage/extensions/intersphinx.html
.. _Writing Documentation: .. _Writing Documentation:
https://docs.djangoproject.com/en/stable/internals/contributing/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 the parsed `Mailgun webhook payload`_ as a Python `dict` with ``"signature"`` and
``"event-data"`` keys. ``"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 :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 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 ``event.esp_event["event-data"]["id"]``. (This can be helpful for working with
Mailgun's other event APIs.) Mailgun's other event APIs.)

View File

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

View File

@@ -29,9 +29,10 @@ often help you pinpoint the problem...
**Double-check common issues** **Double-check common issues**
* Did you add any required settings for your ESP to the `ANYMAIL` dict in your * 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? * 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 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. 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, 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 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 people who use it---like you! (Anymail contributors volunteer their time, and are
their time, and are not employees of any ESP.) not employees of any ESP.)
Here's how to contact the Anymail community: 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`.) (You should also review :ref:`securing-webhooks`.)
Once you've enabled webhooks, Anymail will send a ``anymail.signals.inbound`` 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. 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>` (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 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>`. :ref:`user-supplied content security <django:user-uploaded-content-security>`.
.. _using python-magic: .. _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: .. _inbound-event:
@@ -90,7 +90,7 @@ Normalized inbound event
.. attribute:: message .. attribute:: message
An :class:`~anymail.inbound.AnymailInboundMessage` representing the email 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. See the full details :ref:`below <inbound-message>`.
.. attribute:: event_type .. attribute:: event_type
@@ -290,8 +290,6 @@ Handling Inbound Attachments
Anymail converts each inbound attachment to a specialized MIME object with Anymail converts each inbound attachment to a specialized MIME object with
additional methods for handling attachments and integrating with Django. 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 The attachment objects in an AnymailInboundMessage's
:attr:`~AnymailInboundMessage.attachments` list and :attr:`~AnymailInboundMessage.attachments` list and
@@ -346,8 +344,6 @@ have these methods:
.. method:: is_attachment() .. method:: is_attachment()
Returns `True` for a (non-inline) attachment, `False` otherwise. 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() .. method:: is_inline_attachment()
@@ -360,9 +356,6 @@ have these methods:
:mailheader:`Content-Disposition` header. The return value should be either "inline" :mailheader:`Content-Disposition` header. The return value should be either "inline"
or "attachment", or `None` if the attachment is somehow missing that header. 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') .. method:: get_content_text(charset=None, errors='replace')
Returns the content of the attachment decoded to Unicode text. 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. cause duplicate processing in your code.
If your signal receiver code might be slow, you should instead If your signal receiver code might be slow, you should instead
queue the event for later, asynchronous processing (e.g., using 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 If your signal receiver function is defined within some other
function or instance method, you *must* use the `weak=False` 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 but will unpredictably stop being called at some point---typically
on your production server, in a hard-to-debug way. See Django's on your production server, in a hard-to-debug way. See Django's
docs on :doc:`signals <django:topics/signals>` for more information. 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 .. code-block:: python
from django.conf.urls import include, url from django.urls import include, re_path
urlpatterns = [ 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 (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 at some other URL. Just match whatever you use in the webhook URL you give
your ESP in the next step.) 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 status tracking events you can receive. See :ref:`inbound` for information on
receiving inbound message events. 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 .. 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 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 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, :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: 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 e.g., you can override ANYMAIL_MAILGUN_API_KEY for a particular connection by calling
:func:`~django.core.mail.get_connection`. See :ref:`multiple-backends` for an example. ``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). There are specific Anymail settings for each ESP (like API keys and urls).
See the :ref:`supported ESPs <supported-esps>` section for details. 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. A `dict` of default options to apply to all messages sent through Anymail.
See :ref:`send-defaults`. 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 helpful if you are working with Python development tools that
offer type checking or other static code analysis. 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: .. _anymail-send-options:
ESP send options (AnymailMessage) 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 .. class:: AnymailMessage
A subclass of Django's :class:`~django.core.mail.EmailMultiAlternatives` 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, ESPs have differing restrictions on tags. For portability,
it's best to stick with strings that start with an alphanumeric 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:: .. caution::
@@ -359,7 +359,7 @@ ESP send status
* `'queued'` the ESP has accepted the message * `'queued'` the ESP has accepted the message
and will try to send it asynchronously and will try to send it asynchronously
* `'invalid'` the ESP considers the sender or recipient email invalid * `'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.) (unsubscribe, previous bounces, etc.)
* `'failed'` the attempt to send failed for some other reason * `'failed'` the attempt to send failed for some other reason
* `'unknown'` anything else * `'unknown'` anything else
@@ -402,7 +402,8 @@ ESP send status
.. code-block:: python .. 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() 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. 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 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` Anymail supports most of the functionality of Django's :class:`~django.core.mail.EmailMessage`
and :class:`~django.core.mail.EmailMultiAlternatives` classes. 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", send_mail("Subject", "text body", "from@example.com",
["to@example.com"], html_message="<html>html body</html>") ["to@example.com"], html_message="<html>html body</html>")
However, many Django email capabilities -- and additional Anymail features -- However, many Django email capabilities---and additional Anymail features---are only
are only available when working with an :class:`~django.core.mail.EmailMultiAlternatives` available when working with an :class:`~django.core.mail.EmailMultiAlternatives`
object. Use its :meth:`~django.core.mail.EmailMultiAlternatives.attach_alternative` object. Use its :meth:`~django.core.mail.EmailMultiAlternatives.attach_alternative`
method to send HTML: method to send HTML:
@@ -168,7 +168,8 @@ raise :exc:`~exceptions.AnymailUnsupportedFeature`.
.. setting:: ANYMAIL_IGNORE_UNSUPPORTED_FEATURES .. setting:: ANYMAIL_IGNORE_UNSUPPORTED_FEATURES
If you'd like to silently ignore :exc:`~exceptions.AnymailUnsupportedFeature` 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: to `True` in your settings.py:
.. code-block:: python .. 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` You can still examine the message's :attr:`~message.AnymailMessage.anymail_status`
property after the send to determine the status of each recipient. property after the send to determine the status of each recipient.
You can disable this exception by setting :setting:`ANYMAIL_IGNORE_RECIPIENT_STATUS` You can disable this exception by setting
to `True` in your settings.py, which will cause Anymail to treat any non-API-error response :setting:`"IGNORE_RECIPIENT_STATUS" <ANYMAIL_IGNORE_RECIPIENT_STATUS>` to `True` in
from your ESP as a successful send. 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:: .. 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 Mailgun always queues sent messages, so you'll never catch
:exc:`AnymailRecipientsRefused` with the Mailgun backend. :exc:`AnymailRecipientsRefused` with the Mailgun backend.
For those ESPs, use Anymail's :ref:`delivery event tracking <event-tracking>` You can use Anymail's :ref:`delivery event tracking <event-tracking>`
if you need to be notified of sends to blacklisted or invalid emails. 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: # Do something this instead:
message.merge_global_data = { message.merge_global_data = {
'PRODUCT': product.name, # assuming name is a CharField '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" '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`.) project. (You may also want to review :ref:`securing-webhooks`.)
Once you've enabled webhooks, Anymail will send an ``anymail.signals.tracking`` 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. 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 Be sure to read Django's `listening to signals`_ docs for information on defining
@@ -189,8 +189,8 @@ Normalized tracking event
.. attribute:: mta_response .. attribute:: mta_response
If available, a `str` with a raw (intended for email administrators) response If available, a `str` with a raw (intended for email administrators) response
from the receiving MTA. Otherwise `None`. Often includes SMTP response codes, from the receiving mail transfer agent. Otherwise `None`. Often includes SMTP
but the exact format varies by ESP (and sometimes receiving MTA). response codes, but the exact format varies by ESP (and sometimes receiving MTA).
.. attribute:: user_agent .. attribute:: user_agent
@@ -203,7 +203,7 @@ Normalized tracking event
.. attribute:: esp_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 For most ESPs this is either parsed JSON (as a `dict`), or HTTP POST fields
(as a Django :class:`~django.http.QueryDict`). (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. :param AnymailTrackingEvent event: The normalized tracking event.
Almost anything you'd be interested in Almost anything you'd be interested in
will be in here. 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 with multiple ESPs, you can use this to distinguish
ESP-specific handling in your shared event processing. ESP-specific handling in your shared event processing.
:param \**kwargs: Required by Django's signal mechanism :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. processing in your code.
If your signal receiver code might be slow, you should instead If your signal receiver code might be slow, you should instead
queue the event for later, asynchronous processing (e.g., using 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 If your signal receiver function is defined within some other
function or instance method, you *must* use the `weak=False` 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 on your production server, in a hard-to-debug way. See Django's
`listening to signals`_ docs for more information. `listening to signals`_ docs for more information.
.. _Celery: http://www.celeryproject.org/
.. _listening to signals: .. _listening to signals:
https://docs.djangoproject.com/en/stable/topics/signals/#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. 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 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 You don't even have to use Django's template syntax: it supports other
template languages (like Jinja2). 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. 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 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. template shortcut to build the body and html.
Example that builds an email from the templates ``message_subject.txt``, 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 .. code-block:: python
from django.core.mail import EmailMultiAlternatives from django.core.mail import EmailMultiAlternatives
from django.template import Context
from django.template.loader import render_to_string from django.template.loader import render_to_string
merge_data = { merge_data = {
'ORDERNO': "12345", 'TRACKINGNO': "1Z987" 'ORDERNO': "12345", 'TRACKINGNO': "1Z987"
} }
plaintext_context = Context(autoescape=False) # HTML escaping not appropriate in plaintext subject = render_to_string("message_subject.txt", merge_data).strip()
subject = render_to_string("message_subject.txt", merge_data, plaintext_context) text_body = render_to_string("message_body.txt", merge_data)
text_body = render_to_string("message_body.txt", merge_data, plaintext_context)
html_body = render_to_string("message_body.html", merge_data) html_body = render_to_string("message_body.html", merge_data)
msg = EmailMultiAlternatives(subject=subject, from_email="store@example.com", 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.attach_alternative(html_body, "text/html")
msg.send() 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 Helpful add-ons
--------------- ---------------
@@ -48,8 +49,6 @@ Helpful add-ons
These (third-party) packages can be helpful for building your email These (third-party) packages can be helpful for building your email
in Django: in Django:
.. TODO: flesh this out
* :pypi:`django-templated-mail`, :pypi:`django-mail-templated`, or :pypi:`django-mail-templated-simple` * :pypi:`django-templated-mail`, :pypi:`django-mail-templated`, or :pypi:`django-mail-templated-simple`
for building messages from sets of Django templates. for building messages from sets of Django templates.
* :pypi:`premailer` for inlining css before sending * :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. :ref:`webhooks configuration <webhooks-configuration>` docs.
If something posts to your webhooks without the required shared 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 raise an :exc:`AnymailWebhookValidationFailure` error, which is
a subclass of Django's :exc:`~django.core.exceptions.SuspiciousOperation`. 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. the data or calling your signal receiver function.
In addition to a single "random:random" string, you can give a list In addition to a single "random:random" string, you can give a list

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,7 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import json import json
from datetime import datetime from datetime import datetime
from email.mime.application import MIMEApplication from email.mime.application import MIMEApplication
import six
from django.core import mail from django.core import mail
from django.core.mail import BadHeaderError from django.core.mail import BadHeaderError
from django.test import SimpleTestCase, override_settings, tag 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') @tag('amazon_ses')
@override_settings(EMAIL_BACKEND='anymail.backends.amazon_ses.EmailBackend') @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""" """TestCase that uses the Amazon SES EmailBackend with a mocked boto3 client"""
def setUp(self): def setUp(self):
super(AmazonSESBackendMockAPITestCase, self).setUp() super().setUp()
# Mock boto3.session.Session().client('ses').send_raw_email (and any other client operations) # 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) # (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. # send_raw_email takes a fully-formatted MIME message.
# This is a simple (if inexact) way to check for expected headers and body: # This is a simple (if inexact) way to check for expected headers and body:
raw_mime = params['RawMessage']['Data'] 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"\nFrom: from@example.com\n", raw_mime)
self.assertIn(b"\nTo: to@example.com\n", raw_mime) self.assertIn(b"\nTo: to@example.com\n", raw_mime)
self.assertIn(b"\nSubject: Subject here\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") self.assertEqual(params['Source'], "from1@example.com")
def test_commas_in_subject(self): 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.subject = "100,000,000 isn't a number you'd really want to break up in this email subject, right?"
self.message.send() self.message.send()
sent_message = self.get_sent_message() sent_message = self.get_sent_message()

View File

@@ -1,5 +1,3 @@
from __future__ import unicode_literals
import json import json
from base64 import b64encode from base64 import b64encode
from datetime import datetime from datetime import datetime
@@ -22,7 +20,7 @@ from .webhook_cases import WebhookTestCase
class AmazonSESInboundTests(WebhookTestCase, AmazonSESWebhookTestsMixin): class AmazonSESInboundTests(WebhookTestCase, AmazonSESWebhookTestsMixin):
def setUp(self): def setUp(self):
super(AmazonSESInboundTests, self).setUp() super().setUp()
# Mock boto3.session.Session().client('s3').download_fileobj # Mock boto3.session.Session().client('s3').download_fileobj
# (We could also use botocore.stub.Stubber, but mock works well with our test structure) # (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) 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], self.assertEqual([str(to) for to in message.to],
['Recipient <inbound@example.com>', 'someone-else@example.org']) ['Recipient <inbound@example.com>', 'someone-else@example.org'])
self.assertEqual(message.subject, 'Test inbound message') 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, "It's a body\N{HORIZONTAL ELLIPSIS}\n")
self.assertEqual(message.text.rstrip(), "It's a body\N{HORIZONTAL ELLIPSIS}") self.assertEqual(message.html, """<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>\n""")
self.assertEqual(message.html.rstrip(), """<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>""")
self.assertIsNone(message.spam_detected) self.assertIsNone(message.spam_detected)
def test_inbound_s3_failure_message(self): def test_inbound_s3_failure_message(self):

View File

@@ -1,6 +1,3 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os import os
import unittest import unittest
import warnings import warnings
@@ -12,11 +9,6 @@ from anymail.message import AnymailMessage
from .utils import AnymailTestMixin, sample_image_path 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_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") 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 "AMAZON_SES_CONFIGURATION_SET_NAME": "TestConfigurationSet", # actual config set in Anymail test account
}) })
@tag('amazon_ses', 'live') @tag('amazon_ses', 'live')
class AmazonSESBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): class AmazonSESBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"""Amazon SES API integration tests """Amazon SES API integration tests
These tests run against the **live** Amazon SES API, using the environment These tests run against the **live** Amazon SES API, using the environment
@@ -63,14 +55,14 @@ class AmazonSESBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
""" """
def setUp(self): def setUp(self):
super(AmazonSESBackendIntegrationTests, self).setUp() super().setUp()
self.message = AnymailMessage('Anymail Amazon SES integration test', 'Text content', self.message = AnymailMessage('Anymail Amazon SES integration test', 'Text content',
'test@test-ses.anymail.info', ['success@simulator.amazonses.com']) 'test@test-ses.anymail.info', ['success@simulator.amazonses.com'])
self.message.attach_alternative('<p>HTML content</p>', "text/html") 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. # 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.) # We don't care. (It may be a false positive, or it may be a botocore problem, but it's not *our* problem.)
# https://www.google.com/search?q=unittest+boto3+ResourceWarning+unclosed+ssl.SSLSocket # https://github.com/boto/boto3/issues/454#issuecomment-586033745
# Filter in TestCase.setUp because unittest resets the warning filters for each test. # Filter in TestCase.setUp because unittest resets the warning filters for each test.
# https://stackoverflow.com/a/26620811/647002 # https://stackoverflow.com/a/26620811/647002
warnings.filterwarnings("ignore", message=r"unclosed <ssl\.SSLSocket", category=ResourceWarning) warnings.filterwarnings("ignore", message=r"unclosed <ssl\.SSLSocket", category=ResourceWarning)
@@ -154,8 +146,9 @@ class AmazonSESBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
} }
}) })
def test_invalid_aws_credentials(self): 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: # 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 import warnings
from datetime import datetime 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 django.utils.timezone import utc
from mock import ANY, patch from mock import ANY, patch
@@ -10,12 +10,11 @@ from anymail.exceptions import AnymailConfigurationError, AnymailInsecureWebhook
from anymail.signals import AnymailTrackingEvent from anymail.signals import AnymailTrackingEvent
from anymail.webhooks.amazon_ses import AmazonSESTrackingWebhookView 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): def post_from_sns(self, path, raw_sns_message, **kwargs):
# noinspection PyUnresolvedReferences
return self.client.post( return self.client.post(
path, path,
content_type='text/plain; charset=UTF-8', # SNS posts JSON as text/plain content_type='text/plain; charset=UTF-8', # SNS posts JSON as text/plain
@@ -27,12 +26,12 @@ class AmazonSESWebhookTestsMixin(object):
@tag('amazon_ses') @tag('amazon_ses')
class AmazonSESWebhookSecurityTests(WebhookTestCase, AmazonSESWebhookTestsMixin, WebhookBasicAuthTestsMixin): class AmazonSESWebhookSecurityTests(AmazonSESWebhookTestsMixin, WebhookBasicAuthTestCase):
def call_webhook(self): def call_webhook(self):
return self.post_from_sns('/anymail/amazon_ses/tracking/', return self.post_from_sns('/anymail/amazon_ses/tracking/',
{"Type": "Notification", "MessageId": "123", "Message": "{}"}) {"Type": "Notification", "MessageId": "123", "Message": "{}"})
# Most actual tests are in WebhookBasicAuthTestsMixin # Most actual tests are in WebhookBasicAuthTestCase
def test_verifies_missing_auth(self): def test_verifies_missing_auth(self):
# Must handle missing auth header slightly differently from Anymail default 400 SuspiciousOperation: # 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.) # (Note that WebhookTestCase sets up ANYMAIL WEBHOOK_SECRET.)
def setUp(self): def setUp(self):
super(AmazonSESSubscriptionManagementTests, self).setUp() super().setUp()
# Mock boto3.session.Session().client('sns').confirm_subscription (and any other client operations) # 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) # (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) 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 api_url = "https://httpbin.org/post" # helpful echoback endpoint for live testing
def __init__(self, **kwargs): 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): def build_message_payload(self, message, defaults):
_payload_init = getattr(message, "_payload_init", {}) _payload_init = getattr(message, "_payload_init", {})
@@ -46,7 +46,7 @@ class RequestsBackendBaseTestCase(RequestsBackendMockAPITestCase):
"""Test common functionality in AnymailRequestsBackend""" """Test common functionality in AnymailRequestsBackend"""
def setUp(self): def setUp(self):
super(RequestsBackendBaseTestCase, self).setUp() super().setUp()
self.message = AnymailMessage('Subject', 'Text Body', 'from@example.com', ['to@example.com']) self.message = AnymailMessage('Subject', 'Text Body', 'from@example.com', ['to@example.com'])
def test_minimal_requests_backend(self): def test_minimal_requests_backend(self):
@@ -70,7 +70,7 @@ class RequestsBackendBaseTestCase(RequestsBackendMockAPITestCase):
@tag('live') @tag('live')
@override_settings(EMAIL_BACKEND='tests.test_base_backends.MinimalRequestsBackend') @override_settings(EMAIL_BACKEND='tests.test_base_backends.MinimalRequestsBackend')
class RequestsBackendLiveTestCase(SimpleTestCase, AnymailTestMixin): class RequestsBackendLiveTestCase(AnymailTestMixin, SimpleTestCase):
@override_settings(ANYMAIL_DEBUG_API_REQUESTS=True) @override_settings(ANYMAIL_DEBUG_API_REQUESTS=True)
def test_debug_logging(self): def test_debug_logging(self):
message = AnymailMessage('Subject', 'Text Body', 'from@example.com', ['to@example.com']) 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 from .utils import AnymailTestMixin
class DeprecatedSettingsTests(SimpleTestCase, AnymailTestMixin): class DeprecatedSettingsTests(AnymailTestMixin, SimpleTestCase):
@override_settings(ANYMAIL={"WEBHOOK_AUTHORIZATION": "abcde:12345"}) @override_settings(ANYMAIL={"WEBHOOK_AUTHORIZATION": "abcde:12345"})
def test_webhook_authorization(self): def test_webhook_authorization(self):
errors = check_deprecated_settings(None) 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}) @override_settings(ANYMAIL={"DEBUG_API_REQUESTS": True})
def test_debug_api_requests_deployed(self): def test_debug_api_requests_deployed(self):
errors = check_insecure_settings(None) errors = check_insecure_settings(None)

View File

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

View File

@@ -1,6 +1,3 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import quopri import quopri
from base64 import b64encode from base64 import b64encode
from email.utils import collapse_rfc2231_value from email.utils import collapse_rfc2231_value
@@ -169,10 +166,9 @@ class AnymailInboundMessageConstructionTests(SimpleTestCase):
def test_parse_raw_mime_8bit_utf8(self): def test_parse_raw_mime_8bit_utf8(self):
# In come cases, the message below ends up with 'Content-Transfer-Encoding: 8bit', # 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). # 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 # (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. # 'Content-Transfer-Encoding: base64', which parses fine as text or bytes.)
# Django <1.11 on Python 3 also used base64.)
# Either way, AnymailInboundMessage should try to sidestep the whole issue. # Either way, AnymailInboundMessage should try to sidestep the whole issue.
raw = SafeMIMEText("Unicode ✓", "plain", "utf-8").as_string() raw = SafeMIMEText("Unicode ✓", "plain", "utf-8").as_string()
msg = AnymailInboundMessage.parse_raw_mime(raw) msg = AnymailInboundMessage.parse_raw_mime(raw)
@@ -495,9 +491,11 @@ class AnymailInboundMessageAttachedMessageTests(SimpleTestCase):
self.assertEqual(orig_msg.get_content_type(), "multipart/related") self.assertEqual(orig_msg.get_content_type(), "multipart/related")
class EmailParserWorkaroundTests(SimpleTestCase): class EmailParserBehaviorTests(SimpleTestCase):
# Anymail includes workarounds for (some of) the more problematic bugs # Python 3.5+'s EmailParser should handle all of these, so long as it's not
# in the Python 2 email.parser.Parser. # 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): def test_parse_folded_headers(self):
raw = dedent("""\ raw = dedent("""\
@@ -540,16 +538,11 @@ class EmailParserWorkaroundTests(SimpleTestCase):
self.assertEqual(msg.from_email.display_name, "Keith Moore") self.assertEqual(msg.from_email.display_name, "Keith Moore")
self.assertEqual(msg.from_email.addr_spec, "moore@example.com") self.assertEqual(msg.from_email.addr_spec, "moore@example.com")
# When an RFC2047 encoded-word abuts an RFC5322 quoted-word in a *structured* header, self.assertEqual(msg["To"],
# Python 3's parser nicely recombines them into a single quoted word. That's way too 'Keld Jørn Simonsen <keld@example.com>, '
# complicated for our Python 2 workaround ... '"André Pirard, Jr." <PIRARD@example.com>')
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[0].display_name, "Keld Jørn Simonsen") 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, # 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.) # 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 datetime import date, datetime
from textwrap import dedent from textwrap import dedent
try:
from email import message_from_bytes 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.base import MIMEBase
from email.mime.image import MIMEImage from email.mime.image import MIMEImage
@@ -24,7 +15,7 @@ from anymail.exceptions import (
AnymailRequestsAPIError, AnymailUnsupportedFeature) AnymailRequestsAPIError, AnymailUnsupportedFeature)
from anymail.message import attach_inline_image_file 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, from .utils import (AnymailTestMixin, sample_email_content,
sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME) sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME)
@@ -39,7 +30,7 @@ class MailgunBackendMockAPITestCase(RequestsBackendMockAPITestCase):
}""" }"""
def setUp(self): def setUp(self):
super(MailgunBackendMockAPITestCase, self).setUp() super().setUp()
# Simple message useful for many tests # Simple message useful for many tests
self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com']) 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) self.assertEqual(len(inlines), 0)
def test_unicode_attachment_correctly_decoded(self): 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() self.message.send()
# Verify the RFC 7578 compliance workaround has kicked in: # Verify the RFC 7578 compliance workaround has kicked in:
@@ -191,7 +182,7 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase):
workaround = True workaround = True
data = data.decode("utf-8").replace("\r\n", "\n") data = data.decode("utf-8").replace("\r\n", "\n")
self.assertNotIn("filename*=", data) # No RFC 2231 encoding 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: if workaround:
files = self.get_api_call_files(required=False) files = self.get_api_call_files(required=False)
@@ -199,8 +190,8 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase):
def test_rfc_7578_compliance(self): def test_rfc_7578_compliance(self):
# Check some corner cases in the workaround that undoes RFC 2231 multipart/form-data encoding... # 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.subject = "Testing for filename*=utf-8''problems"
self.message.body = u"The attached message should have an attachment named 'vedhæftet fil.txt'" self.message.body = "The attached message should have an attachment named 'vedhæftet fil.txt'"
# A forwarded message with its own attachment: # A forwarded message with its own attachment:
forwarded_message = dedent("""\ forwarded_message = dedent("""\
MIME-Version: 1.0 MIME-Version: 1.0
@@ -219,7 +210,7 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase):
This is an attachment. This is an attachment.
--boundary-- --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() self.message.send()
data = self.get_api_call_data() 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): # Top-level attachment (in form-data) should have RFC 7578 filename (raw Unicode):
self.assertIn( 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: # 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-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) self.assertIn("Content-Disposition: attachment; filename*=utf-8''vedh%C3%A6ftet%20fil.txt", data)
# References to RFC 2231 in message text should remain intact: # References to RFC 2231 in message text should remain intact:
self.assertIn("Testing for filename*=utf-8''problems", data) 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): def test_attachment_missing_filename(self):
"""Mailgun silently drops attachments without filenames, so warn the caller""" """Mailgun silently drops attachments without filenames, so warn the caller"""
@@ -767,14 +758,14 @@ class MailgunBackendRecipientsRefusedTests(MailgunBackendMockAPITestCase):
@tag('mailgun') @tag('mailgun')
class MailgunBackendSessionSharingTestCase(SessionSharingTestCasesMixin, MailgunBackendMockAPITestCase): class MailgunBackendSessionSharingTestCase(SessionSharingTestCases, MailgunBackendMockAPITestCase):
"""Requests session sharing tests""" """Requests session sharing tests"""
pass # tests are defined in the mixin pass # tests are defined in SessionSharingTestCases
@tag('mailgun') @tag('mailgun')
@override_settings(EMAIL_BACKEND="anymail.backends.mailgun.EmailBackend") @override_settings(EMAIL_BACKEND="anymail.backends.mailgun.EmailBackend")
class MailgunBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin): class MailgunBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase):
"""Test ESP backend without required settings in place""" """Test ESP backend without required settings in place"""
def test_missing_api_key(self): def test_missing_api_key(self):

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ from anymail.exceptions import AnymailConfigurationError
from anymail.signals import AnymailTrackingEvent from anymail.signals import AnymailTrackingEvent
from anymail.webhooks.mailgun import MailgunTrackingWebhookView 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' TEST_WEBHOOK_SIGNING_KEY = 'TEST_WEBHOOK_SIGNING_KEY'
@@ -99,14 +99,14 @@ class MailgunWebhookSettingsTestCase(WebhookTestCase):
@tag('mailgun') @tag('mailgun')
@override_settings(ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY=TEST_WEBHOOK_SIGNING_KEY) @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 should_warn_if_no_auth = False # because we check webhook signature
def call_webhook(self): def call_webhook(self):
return self.client.post('/anymail/mailgun/tracking/', content_type="application/json", return self.client.post('/anymail/mailgun/tracking/', content_type="application/json",
data=json.dumps(mailgun_sign_payload({'event-data': {'event': 'delivered'}}))) 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): def test_verifies_correct_signature(self):
response = self.client.post('/anymail/mailgun/tracking/', content_type="application/json", 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 base64 import b64encode
from decimal import Decimal from decimal import Decimal
from email.mime.base import MIMEBase from email.mime.base import MIMEBase
@@ -14,7 +12,7 @@ from anymail.exceptions import (AnymailAPIError, AnymailSerializationError,
AnymailRequestsAPIError) AnymailRequestsAPIError)
from anymail.message import attach_inline_image_file 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 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): def setUp(self):
super(MailjetBackendMockAPITestCase, self).setUp() super().setUp()
# Simple message useful for many tests # Simple message useful for many tests
self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com']) 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]) self.assertNotIn('ContentID', attachments[2])
def test_unicode_attachment_correctly_decoded(self): 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() self.message.send()
data = self.get_api_call_json() data = self.get_api_call_json()
self.assertEqual(data['Attachments'], [{ self.assertEqual(data['Attachments'], [{
'Filename': u'Une pièce jointe.html', 'Filename': 'Une pièce jointe.html',
'Content-type': 'text/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): def test_embedded_images(self):
@@ -656,14 +654,14 @@ class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase):
@tag('mailjet') @tag('mailjet')
class MailjetBackendSessionSharingTestCase(SessionSharingTestCasesMixin, MailjetBackendMockAPITestCase): class MailjetBackendSessionSharingTestCase(SessionSharingTestCases, MailjetBackendMockAPITestCase):
"""Requests session sharing tests""" """Requests session sharing tests"""
pass # tests are defined in the mixin pass # tests are defined in SessionSharingTestCases
@tag('mailjet') @tag('mailjet')
@override_settings(EMAIL_BACKEND="anymail.backends.mailjet.EmailBackend") @override_settings(EMAIL_BACKEND="anymail.backends.mailjet.EmailBackend")
class MailjetBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin): class MailjetBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase):
"""Test ESP backend without required settings in place""" """Test ESP backend without required settings in place"""
def test_missing_api_key(self): def test_missing_api_key(self):

View File

@@ -160,7 +160,7 @@ class MailjetInboundTestCase(WebhookTestCase):
self.assertEqual(len(attachments), 2) self.assertEqual(len(attachments), 2)
self.assertEqual(attachments[0].get_filename(), 'test.txt') self.assertEqual(attachments[0].get_filename(), 'test.txt')
self.assertEqual(attachments[0].get_content_type(), 'text/plain') 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.assertEqual(attachments[1].get_content_type(), 'message/rfc822')
self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content) 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, @override_settings(ANYMAIL_MAILJET_API_KEY=MAILJET_TEST_API_KEY,
ANYMAIL_MAILJET_SECRET_KEY=MAILJET_TEST_SECRET_KEY, ANYMAIL_MAILJET_SECRET_KEY=MAILJET_TEST_SECRET_KEY,
EMAIL_BACKEND="anymail.backends.mailjet.EmailBackend") EMAIL_BACKEND="anymail.backends.mailjet.EmailBackend")
class MailjetBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): class MailjetBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"""Mailjet API integration tests """Mailjet API integration tests
These tests run against the **live** Mailjet API, using the These tests run against the **live** Mailjet API, using the
@@ -36,7 +36,7 @@ class MailjetBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
""" """
def setUp(self): def setUp(self):
super(MailjetBackendIntegrationTests, self).setUp() super().setUp()
self.message = AnymailMessage('Anymail Mailjet integration test', 'Text content', self.message = AnymailMessage('Anymail Mailjet integration test', 'Text content',
'test@test-mj.anymail.info', ['test+to1@anymail.info']) 'test@test-mj.anymail.info', ['test+to1@anymail.info'])
self.message.attach_alternative('<p>HTML content</p>', "text/html") 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.signals import AnymailTrackingEvent
from anymail.webhooks.mailjet import MailjetTrackingWebhookView from anymail.webhooks.mailjet import MailjetTrackingWebhookView
from .webhook_cases import WebhookBasicAuthTestsMixin, WebhookTestCase from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase
@tag('mailjet') @tag('mailjet')
class MailjetWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin): class MailjetWebhookSecurityTestCase(WebhookBasicAuthTestCase):
def call_webhook(self): def call_webhook(self):
return self.client.post('/anymail/mailjet/tracking/', return self.client.post('/anymail/mailjet/tracking/',
content_type='application/json', data=json.dumps([])) content_type='application/json', data=json.dumps([]))
# Actual tests are in WebhookBasicAuthTestsMixin # Actual tests are in WebhookBasicAuthTestCase
@tag('mailjet') @tag('mailjet')

View File

@@ -1,5 +1,3 @@
# -*- coding: utf-8 -*-
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from email.mime.base import MIMEBase from email.mime.base import MIMEBase
@@ -14,7 +12,7 @@ from anymail.exceptions import (AnymailAPIError, AnymailRecipientsRefused,
AnymailSerializationError, AnymailUnsupportedFeature) AnymailSerializationError, AnymailUnsupportedFeature)
from anymail.message import attach_inline_image 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 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): def setUp(self):
super(MandrillBackendMockAPITestCase, self).setUp() super().setUp()
# Simple message useful for many tests # Simple message useful for many tests
self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com']) 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']) self.assertFalse('images' in data['message'])
def test_unicode_attachment_correctly_decoded(self): 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() self.message.send()
data = self.get_api_call_json() data = self.get_api_call_json()
attachments = data['message']['attachments'] attachments = data['message']['attachments']
@@ -610,14 +608,14 @@ class MandrillBackendRecipientsRefusedTests(MandrillBackendMockAPITestCase):
@tag('mandrill') @tag('mandrill')
class MandrillBackendSessionSharingTestCase(SessionSharingTestCasesMixin, MandrillBackendMockAPITestCase): class MandrillBackendSessionSharingTestCase(SessionSharingTestCases, MandrillBackendMockAPITestCase):
"""Requests session sharing tests""" """Requests session sharing tests"""
pass # tests are defined in the mixin pass # tests are defined in SessionSharingTestCases
@tag('mandrill') @tag('mandrill')
@override_settings(EMAIL_BACKEND="anymail.backends.mandrill.EmailBackend") @override_settings(EMAIL_BACKEND="anymail.backends.mandrill.EmailBackend")
class MandrillBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin): class MandrillBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase):
"""Test backend without required settings""" """Test backend without required settings"""
def test_missing_api_key(self): 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.to[1].addr_spec, 'other@example.com')
self.assertEqual(message.subject, 'Test subject') self.assertEqual(message.subject, 'Test subject')
self.assertEqual(message.date.isoformat(" "), "2017-10-12 18:03:30-07:00") 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.text, "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.html, """<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>\n""")
self.assertIsNone(message.envelope_sender) # Mandrill doesn't provide sender self.assertIsNone(message.envelope_sender) # Mandrill doesn't provide sender
self.assertEqual(message.envelope_recipient, 'delivered-to@example.com') 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") "Set MANDRILL_TEST_API_KEY environment variable to run integration tests")
@override_settings(MANDRILL_API_KEY=MANDRILL_TEST_API_KEY, @override_settings(MANDRILL_API_KEY=MANDRILL_TEST_API_KEY,
EMAIL_BACKEND="anymail.backends.mandrill.EmailBackend") EMAIL_BACKEND="anymail.backends.mandrill.EmailBackend")
class MandrillBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): class MandrillBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"""Mandrill API integration tests """Mandrill API integration tests
These tests run against the **live** Mandrill API, using the These tests run against the **live** Mandrill API, using the
@@ -30,7 +30,7 @@ class MandrillBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
""" """
def setUp(self): def setUp(self):
super(MandrillBackendIntegrationTests, self).setUp() super().setUp()
self.message = mail.EmailMultiAlternatives('Anymail Mandrill integration test', 'Text content', self.message = mail.EmailMultiAlternatives('Anymail Mandrill integration test', 'Text content',
'from@example.com', ['test+to1@anymail.info']) 'from@example.com', ['test+to1@anymail.info'])
self.message.attach_alternative('<p>HTML content</p>', "text/html") 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 hashlib
import hmac import hmac
import json
from base64 import b64encode from base64 import b64encode
from datetime import datetime
from urllib.parse import urljoin
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.test import override_settings, tag from django.test import override_settings, tag
from django.utils.timezone import utc from django.utils.timezone import utc
@@ -12,8 +12,7 @@ from mock import ANY
from anymail.signals import AnymailTrackingEvent from anymail.signals import AnymailTrackingEvent
from anymail.webhooks.mandrill import MandrillCombinedWebhookView, MandrillTrackingWebhookView from anymail.webhooks.mandrill import MandrillCombinedWebhookView, MandrillTrackingWebhookView
from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase
from .webhook_cases import WebhookTestCase, WebhookBasicAuthTestsMixin
TEST_WEBHOOK_KEY = 'TEST_WEBHOOK_KEY' TEST_WEBHOOK_KEY = 'TEST_WEBHOOK_KEY'
@@ -65,14 +64,14 @@ class MandrillWebhookSettingsTestCase(WebhookTestCase):
@tag('mandrill') @tag('mandrill')
@override_settings(ANYMAIL_MANDRILL_WEBHOOK_KEY=TEST_WEBHOOK_KEY) @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 should_warn_if_no_auth = False # because we check webhook signature
def call_webhook(self): def call_webhook(self):
kwargs = mandrill_args([{'event': 'send'}]) kwargs = mandrill_args([{'event': 'send'}])
return self.client.post(**kwargs) return self.client.post(**kwargs)
# Additional tests are in WebhookBasicAuthTestsMixin # Additional tests are in WebhookBasicAuthTestCase
def test_verifies_correct_signature(self): def test_verifies_correct_signature(self):
kwargs = mandrill_args([{'event': 'send'}]) kwargs = mandrill_args([{'event': 'send'}])
@@ -112,7 +111,7 @@ class MandrillWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixi
response = self.client.post(SERVER_NAME="127.0.0.1", **kwargs) response = self.client.post(SERVER_NAME="127.0.0.1", **kwargs)
self.assertEqual(response.status_code, 200) 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']}) @override_settings(ANYMAIL={'WEBHOOK_SECRET': ['cred1:pass1', 'cred2:pass2']})
def test_supports_credential_rotation(self): def test_supports_credential_rotation(self):
"""You can supply a list of basic auth credentials, and any is allowed""" """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): class InlineImageTests(AnymailTestMixin, SimpleTestCase):
def setUp(self): def setUp(self):
self.message = EmailMultiAlternatives() self.message = EmailMultiAlternatives()
super(InlineImageTests, self).setUp() super().setUp()
@patch("email.utils.socket.getfqdn") @patch("email.utils.socket.getfqdn")
def test_default_domain(self, mock_getfqdn): def test_default_domain(self, mock_getfqdn):

View File

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

View File

@@ -163,7 +163,7 @@ class PostmarkInboundTestCase(WebhookTestCase):
self.assertEqual(len(attachments), 2) self.assertEqual(len(attachments), 2)
self.assertEqual(attachments[0].get_filename(), 'test.txt') self.assertEqual(attachments[0].get_filename(), 'test.txt')
self.assertEqual(attachments[0].get_content_type(), 'text/plain') 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.assertEqual(attachments[1].get_content_type(), 'message/rfc822')
self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content) 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') @tag('postmark', 'live')
@override_settings(ANYMAIL_POSTMARK_SERVER_TOKEN="POSTMARK_API_TEST", @override_settings(ANYMAIL_POSTMARK_SERVER_TOKEN="POSTMARK_API_TEST",
EMAIL_BACKEND="anymail.backends.postmark.EmailBackend") EMAIL_BACKEND="anymail.backends.postmark.EmailBackend")
class PostmarkBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): class PostmarkBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"""Postmark API integration tests """Postmark API integration tests
These tests run against the **live** Postmark API, but using a These tests run against the **live** Postmark API, but using a
@@ -26,7 +26,7 @@ class PostmarkBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
""" """
def setUp(self): def setUp(self):
super(PostmarkBackendIntegrationTests, self).setUp() super().setUp()
self.message = AnymailMessage('Anymail Postmark integration test', 'Text content', self.message = AnymailMessage('Anymail Postmark integration test', 'Text content',
'from@example.com', ['test+to1@anymail.info']) 'from@example.com', ['test+to1@anymail.info'])
self.message.attach_alternative('<p>HTML content</p>', "text/html") 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.exceptions import AnymailConfigurationError
from anymail.signals import AnymailTrackingEvent from anymail.signals import AnymailTrackingEvent
from anymail.webhooks.postmark import PostmarkTrackingWebhookView from anymail.webhooks.postmark import PostmarkTrackingWebhookView
from .webhook_cases import WebhookBasicAuthTestsMixin, WebhookTestCase from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase
@tag('postmark') @tag('postmark')
class PostmarkWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin): class PostmarkWebhookSecurityTestCase(WebhookBasicAuthTestCase):
def call_webhook(self): def call_webhook(self):
return self.client.post('/anymail/postmark/tracking/', return self.client.post('/anymail/postmark/tracking/',
content_type='application/json', data=json.dumps({})) content_type='application/json', data=json.dumps({}))
# Actual tests are in WebhookBasicAuthTestsMixin # Actual tests are in WebhookBasicAuthTestCase
@tag('postmark') @tag('postmark')

View File

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

View File

@@ -1,9 +1,7 @@
# -*- coding: utf-8 -*-
import json import json
from io import BytesIO
from textwrap import dedent from textwrap import dedent
import six
from django.test import tag from django.test import tag
from mock import ANY from mock import ANY
@@ -91,13 +89,13 @@ class SendgridInboundTestCase(WebhookTestCase):
]) ])
def test_attachments(self): def test_attachments(self):
att1 = six.BytesIO('test attachment'.encode('utf-8')) att1 = BytesIO('test attachment'.encode('utf-8'))
att1.name = 'test.txt' att1.name = 'test.txt'
image_content = sample_image_content() image_content = sample_image_content()
att2 = six.BytesIO(image_content) att2 = BytesIO(image_content)
att2.name = 'image.png' att2.name = 'image.png'
email_content = sample_email_content() email_content = sample_email_content()
att3 = six.BytesIO(email_content) att3 = BytesIO(email_content)
att3.content_type = 'message/rfc822; charset="us-ascii"' att3.content_type = 'message/rfc822; charset="us-ascii"'
raw_event = { raw_event = {
'headers': '', 'headers': '',
@@ -124,7 +122,7 @@ class SendgridInboundTestCase(WebhookTestCase):
self.assertEqual(len(attachments), 2) self.assertEqual(len(attachments), 2)
self.assertEqual(attachments[0].get_filename(), 'test.txt') self.assertEqual(attachments[0].get_filename(), 'test.txt')
self.assertEqual(attachments[0].get_content_type(), 'text/plain') 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.assertEqual(attachments[1].get_content_type(), 'message/rfc822')
self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content) 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_sender, 'envelope-from@example.org')
self.assertEqual(message.envelope_recipient, 'test@inbound.example.com') self.assertEqual(message.envelope_recipient, 'test@inbound.example.com')
self.assertEqual(message.subject, 'Raw MIME test') self.assertEqual(message.subject, 'Raw MIME test')
self.assertEqual(message.text, u"It's a body\N{HORIZONTAL ELLIPSIS}\n") self.assertEqual(message.text, "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.html, """<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>\n""")
def test_inbound_charsets(self): def test_inbound_charsets(self):
# Captured (sanitized) from actual SendGrid inbound webhook payload 7/2020, # Captured (sanitized) from actual SendGrid inbound webhook payload 7/2020,
@@ -233,11 +231,11 @@ class SendgridInboundTestCase(WebhookTestCase):
event = kwargs['event'] event = kwargs['event']
message = event.message 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(message.from_email.addr_spec, "sender@example.com")
self.assertEqual(len(message.to), 1) 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.to[0].addr_spec, "inbound@sg.example.com")
self.assertEqual(message.subject, u"Como usted pidió") self.assertEqual(message.subject, "Como usted pidió")
self.assertEqual(message.text, u"Test the ESPs inbound charset handling…") self.assertEqual(message.text, "Test the ESPs inbound charset handling…")
self.assertEqual(message.html, u"<p>¿Esto se ve como esperabas?</p>") 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}}, "mail_settings": {"sandbox_mode": {"enable": True}},
}}, }},
EMAIL_BACKEND="anymail.backends.sendgrid.EmailBackend") EMAIL_BACKEND="anymail.backends.sendgrid.EmailBackend")
class SendGridBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): class SendGridBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"""SendGrid v3 API integration tests """SendGrid v3 API integration tests
These tests run against the **live** SendGrid API, using the These tests run against the **live** SendGrid API, using the
@@ -38,7 +38,7 @@ class SendGridBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
""" """
def setUp(self): def setUp(self):
super(SendGridBackendIntegrationTests, self).setUp() super().setUp()
self.message = AnymailMessage('Anymail SendGrid integration test', 'Text content', self.message = AnymailMessage('Anymail SendGrid integration test', 'Text content',
'from@example.com', ['to@sink.sendgrid.net']) 'from@example.com', ['to@sink.sendgrid.net'])
self.message.attach_alternative('<p>HTML content</p>', "text/html") 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.signals import AnymailTrackingEvent
from anymail.webhooks.sendgrid import SendGridTrackingWebhookView from anymail.webhooks.sendgrid import SendGridTrackingWebhookView
from .webhook_cases import WebhookBasicAuthTestsMixin, WebhookTestCase from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase
@tag('sendgrid') @tag('sendgrid')
class SendGridWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin): class SendGridWebhookSecurityTestCase(WebhookBasicAuthTestCase):
def call_webhook(self): def call_webhook(self):
return self.client.post('/anymail/sendgrid/tracking/', return self.client.post('/anymail/sendgrid/tracking/',
content_type='application/json', data=json.dumps([])) content_type='application/json', data=json.dumps([]))
# Actual tests are in WebhookBasicAuthTestsMixin # Actual tests are in WebhookBasicAuthTestCase
@tag('sendgrid') @tag('sendgrid')

View File

@@ -1,5 +1,3 @@
# -*- coding: utf-8 -*-
import json import json
from base64 import b64encode, b64decode from base64 import b64encode, b64decode
from datetime import datetime from datetime import datetime
@@ -7,7 +5,6 @@ from decimal import Decimal
from email.mime.base import MIMEBase from email.mime.base import MIMEBase
from email.mime.image import MIMEImage from email.mime.image import MIMEImage
import six
from django.core import mail from django.core import mail
from django.test import SimpleTestCase, override_settings, tag from django.test import SimpleTestCase, override_settings, tag
from django.utils.timezone import get_fixed_timezone, override as override_current_timezone 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, from anymail.exceptions import (AnymailAPIError, AnymailConfigurationError, AnymailSerializationError,
AnymailUnsupportedFeature) AnymailUnsupportedFeature)
from anymail.message import attach_inline_image_file 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 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') @tag('sendinblue')
@override_settings(EMAIL_BACKEND='anymail.backends.sendinblue.EmailBackend', @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) DEFAULT_STATUS_CODE = 201 # SendinBlue v3 uses '201 Created' for success (in most cases)
def setUp(self): def setUp(self):
super(SendinBlueBackendMockAPITestCase, self).setUp() super().setUp()
# Simple message useful for many tests # Simple message useful for many tests
self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com']) self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com'])
@@ -119,13 +113,12 @@ class SendinBlueBackendStandardEmailTests(SendinBlueBackendMockAPITestCase):
self.assertNotIn('textContent', data) self.assertNotIn('textContent', data)
def test_extra_headers(self): 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>'} 'Reply-To': '"Do Not Reply" <noreply@example.com>'}
self.message.send() self.message.send()
data = self.get_api_call_json() data = self.get_api_call_json()
self.assertEqual(data['headers']['X-Custom'], 'string') self.assertEqual(data['headers']['X-Custom'], 'string')
self.assertEqual(data['headers']['X-Num'], 123) self.assertEqual(data['headers']['X-Num'], 123)
self.assertEqual(data['headers']['X-Long'], 123)
# Reply-To must be moved to separate param # Reply-To must be moved to separate param
self.assertNotIn('Reply-To', data['headers']) self.assertNotIn('Reply-To', data['headers'])
self.assertEqual(data['replyTo'], {'name': "Do Not Reply", 'email': "noreply@example.com"}) 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')}) 'content': b64encode(pdf_content).decode('ascii')})
def test_unicode_attachment_correctly_decoded(self): 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() self.message.send()
attachment = self.get_api_call_json()['attachment'][0] attachment = self.get_api_call_json()['attachment'][0]
self.assertEqual(attachment['name'], u'Une pièce jointe.html') self.assertEqual(attachment['name'], 'Une pièce jointe.html')
self.assertEqual(b64decode(attachment['content']).decode('utf-8'), u'<p>\u2019</p>') self.assertEqual(b64decode(attachment['content']).decode('utf-8'), '<p>\u2019</p>')
def test_embedded_images(self): def test_embedded_images(self):
# SendinBlue doesn't support inline image # SendinBlue doesn't support inline image
@@ -284,7 +277,7 @@ class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase):
self.message.send() self.message.send()
def test_metadata(self): 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() self.message.send()
data = self.get_api_call_json() data = self.get_api_call_json()
@@ -293,7 +286,6 @@ class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase):
self.assertEqual(metadata['user_id'], "12345") self.assertEqual(metadata['user_id'], "12345")
self.assertEqual(metadata['items'], 6) self.assertEqual(metadata['items'], 6)
self.assertEqual(metadata['float'], 98.6) self.assertEqual(metadata['float'], 98.6)
self.assertEqual(metadata['long'], longtype(123))
def test_send_at(self): def test_send_at(self):
utc_plus_6 = get_fixed_timezone(6 * 60) utc_plus_6 = get_fixed_timezone(6 * 60)
@@ -451,14 +443,14 @@ class SendinBlueBackendRecipientsRefusedTests(SendinBlueBackendMockAPITestCase):
@tag('sendinblue') @tag('sendinblue')
class SendinBlueBackendSessionSharingTestCase(SessionSharingTestCasesMixin, SendinBlueBackendMockAPITestCase): class SendinBlueBackendSessionSharingTestCase(SessionSharingTestCases, SendinBlueBackendMockAPITestCase):
"""Requests session sharing tests""" """Requests session sharing tests"""
pass # tests are defined in the mixin pass # tests are defined in SessionSharingTestCases
@tag('sendinblue') @tag('sendinblue')
@override_settings(EMAIL_BACKEND="anymail.backends.sendinblue.EmailBackend") @override_settings(EMAIL_BACKEND="anymail.backends.sendinblue.EmailBackend")
class SendinBlueBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin): class SendinBlueBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase):
"""Test ESP backend without required settings in place""" """Test ESP backend without required settings in place"""
def test_missing_auth(self): 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, @override_settings(ANYMAIL_SENDINBLUE_API_KEY=SENDINBLUE_TEST_API_KEY,
ANYMAIL_SENDINBLUE_SEND_DEFAULTS=dict(), ANYMAIL_SENDINBLUE_SEND_DEFAULTS=dict(),
EMAIL_BACKEND="anymail.backends.sendinblue.EmailBackend") EMAIL_BACKEND="anymail.backends.sendinblue.EmailBackend")
class SendinBlueBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): class SendinBlueBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"""SendinBlue v3 API integration tests """SendinBlue v3 API integration tests
SendinBlue doesn't have sandbox so these tests run SendinBlue doesn't have sandbox so these tests run
@@ -31,7 +31,7 @@ class SendinBlueBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
""" """
def setUp(self): def setUp(self):
super(SendinBlueBackendIntegrationTests, self).setUp() super().setUp()
self.message = AnymailMessage('Anymail SendinBlue integration test', 'Text content', self.message = AnymailMessage('Anymail SendinBlue integration test', 'Text content',
'from@test-sb.anymail.info', ['test+to1@anymail.info']) '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.signals import AnymailTrackingEvent
from anymail.webhooks.sendinblue import SendinBlueTrackingWebhookView from anymail.webhooks.sendinblue import SendinBlueTrackingWebhookView
from .webhook_cases import WebhookBasicAuthTestsMixin, WebhookTestCase from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase
@tag('sendinblue') @tag('sendinblue')
class SendinBlueWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin): class SendinBlueWebhookSecurityTestCase(WebhookBasicAuthTestCase):
def call_webhook(self): def call_webhook(self):
return self.client.post('/anymail/sendinblue/tracking/', return self.client.post('/anymail/sendinblue/tracking/',
content_type='application/json', data=json.dumps({})) content_type='application/json', data=json.dumps({}))
# Actual tests are in WebhookBasicAuthTestsMixin # Actual tests are in WebhookBasicAuthTestCase
@tag('sendinblue') @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 = [ urlpatterns = [
url(r'^anymail/', include('anymail.urls')), re_path(r'^anymail/', include('anymail.urls')),
] ]

View File

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

View File

@@ -80,8 +80,8 @@ class SparkpostInboundTestCase(WebhookTestCase):
['cc@example.com']) ['cc@example.com'])
self.assertEqual(message.subject, 'Test subject') self.assertEqual(message.subject, 'Test subject')
self.assertEqual(message.date.isoformat(" "), "2017-10-11 18:31:04-07:00") 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.text, "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.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_sender, 'envelope-from@example.org')
self.assertEqual(message.envelope_recipient, 'test@inbound.example.com') self.assertEqual(message.envelope_recipient, 'test@inbound.example.com')
@@ -158,7 +158,7 @@ class SparkpostInboundTestCase(WebhookTestCase):
self.assertEqual(len(attachments), 2) self.assertEqual(len(attachments), 2)
self.assertEqual(attachments[0].get_filename(), 'test.txt') self.assertEqual(attachments[0].get_filename(), 'test.txt')
self.assertEqual(attachments[0].get_content_type(), 'text/plain') 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.assertEqual(attachments[1].get_content_type(), 'message/rfc822')
self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content) self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content)

View File

@@ -1,5 +1,6 @@
import os import os
import unittest import unittest
import warnings
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.test import SimpleTestCase, override_settings, tag 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") "to run SparkPost integration tests")
@override_settings(ANYMAIL_SPARKPOST_API_KEY=SPARKPOST_TEST_API_KEY, @override_settings(ANYMAIL_SPARKPOST_API_KEY=SPARKPOST_TEST_API_KEY,
EMAIL_BACKEND="anymail.backends.sparkpost.EmailBackend") EMAIL_BACKEND="anymail.backends.sparkpost.EmailBackend")
class SparkPostBackendIntegrationTests(SimpleTestCase, AnymailTestMixin): class SparkPostBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"""SparkPost API integration tests """SparkPost API integration tests
These tests run against the **live** SparkPost API, using the 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 SparkPost doesn't offer a test mode -- it tries to send everything
you ask. To avoid stacking up a pile of undeliverable @example.com you ask. To avoid stacking up a pile of undeliverable @example.com
emails, the tests use SparkPost's "sink domain" @*.sink.sparkpostmail.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). 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. We've set up @test-sp.anymail.info as a validated sending domain for these tests.
""" """
def setUp(self): def setUp(self):
super(SparkPostBackendIntegrationTests, self).setUp() super().setUp()
self.message = AnymailMessage('Anymail SparkPost integration test', 'Text content', self.message = AnymailMessage('Anymail SparkPost integration test', 'Text content',
'test@test-sp.anymail.info', ['to@test.sink.sparkpostmail.com']) 'test@test-sp.anymail.info', ['to@test.sink.sparkpostmail.com'])
self.message.attach_alternative('<p>HTML content</p>', "text/html") 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): def test_simple_send(self):
# Example of getting the SparkPost send status and transmission id from the message # Example of getting the SparkPost send status and transmission id from the message
sent_count = self.message.send() sent_count = self.message.send()

View File

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

View File

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

View File

@@ -1,6 +1,4 @@
# Anymail test utils # Anymail test utils
import collections
import logging
import os import os
import re import re
import sys import sys
@@ -8,10 +6,10 @@ import uuid
import warnings import warnings
from base64 import b64decode from base64 import b64decode
from contextlib import contextmanager from contextlib import contextmanager
from io import StringIO
from unittest import TestCase
import six
from django.test import Client from django.test import Client
from six.moves import StringIO
def decode_att(att): def decode_att(att):
@@ -71,36 +69,9 @@ def sample_email_content(filename=SAMPLE_EMAIL_FILENAME):
# TestCase helpers # TestCase helpers
# #
# noinspection PyUnresolvedReferences class AnymailTestMixin(TestCase):
class AnymailTestMixin:
"""Helpful additional methods for Anymail tests""" """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 @contextmanager
def assertDoesNotWarn(self, disallowed_warning=Warning): def assertDoesNotWarn(self, disallowed_warning=Warning):
"""Makes test error (rather than fail) if disallowed_warning occurs. """Makes test error (rather than fail) if disallowed_warning occurs.
@@ -115,31 +86,13 @@ class AnymailTestMixin:
finally: finally:
warnings.resetwarnings() 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): def assertEqualIgnoringHeaderFolding(self, first, second, msg=None):
# Unfold (per RFC-8222) all text first and second, then compare result. # Unfold (per RFC-8222) all text first and second, then compare result.
# Useful for message/rfc822 attachment tests, where various Python email # Useful for message/rfc822 attachment tests, where various Python email
# versions handled folding slightly differently. # versions handled folding slightly differently.
# (Technically, this is unfolding both headers and (incorrectly) bodies, # (Technically, this is unfolding both headers and (incorrectly) bodies,
# but that doesn't really affect the tests.) # 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') first = first.decode('utf-8')
second = second.decode('utf-8') second = second.decode('utf-8')
first = rfc822_unfold(first) first = rfc822_unfold(first)
@@ -190,131 +143,6 @@ class AnymailTestMixin:
sys.stdout = old_stdout 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): class ClientWithCsrfChecks(Client):
"""Django test Client that enforces CSRF checks """Django test Client that enforces CSRF checks
@@ -322,8 +150,7 @@ class ClientWithCsrfChecks(Client):
""" """
def __init__(self, **defaults): def __init__(self, **defaults):
super(ClientWithCsrfChecks, self).__init__( super().__init__(enforce_csrf_checks=True, **defaults)
enforce_csrf_checks=True, **defaults)
# dedent for bytestrs # dedent for bytestrs

View File

@@ -25,7 +25,7 @@ class WebhookTestCase(AnymailTestMixin, SimpleTestCase):
client_class = ClientWithCsrfChecks client_class = ClientWithCsrfChecks
def setUp(self): def setUp(self):
super(WebhookTestCase, self).setUp() super().setUp()
# Use correct basic auth by default (individual tests can override): # Use correct basic auth by default (individual tests can override):
self.set_basic_auth() self.set_basic_auth()
@@ -71,15 +71,24 @@ class WebhookTestCase(AnymailTestMixin, SimpleTestCase):
return actual_kwargs return actual_kwargs
# noinspection PyUnresolvedReferences class WebhookBasicAuthTestCase(WebhookTestCase):
class WebhookBasicAuthTestsMixin(object):
"""Common test cases for webhook basic authentication. """Common test cases for webhook basic authentication.
Instantiate for each ESP's webhooks by: Instantiate for each ESP's webhooks by:
- mixing into WebhookTestCase - subclassing
- defining call_webhook to invoke the ESP's webhook - 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 should_warn_if_no_auth = True # subclass set False if other webhook verification used
def call_webhook(self): def call_webhook(self):

15
tox.ini
View File

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