mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 03:41:05 -05:00
Drop Python 2 and Django 1.11 support
Minimum supported versions are now Django 2.0, Python 3.5. This touches a lot of code, to: * Remove obsolete portability code and workarounds (six, backports of email parsers, test utils, etc.) * Use Python 3 syntax (class defs, raise ... from, etc.) * Correct inheritance for mixin classes * Fix outdated docs content and links * Suppress Python 3 "unclosed SSLSocket" ResourceWarnings that are beyond our control (in integration tests due to boto3, python-sparkpost)
This commit is contained in:
23
.travis.yml
23
.travis.yml
@@ -1,5 +1,5 @@
|
||||
sudo: false
|
||||
language: python
|
||||
os: linux
|
||||
dist: xenial
|
||||
|
||||
branches:
|
||||
@@ -15,9 +15,9 @@ env:
|
||||
# Let Travis report failures that tox.ini would normally ignore:
|
||||
- TOX_FORCE_IGNORE_OUTCOME=false
|
||||
|
||||
matrix:
|
||||
jobs:
|
||||
include:
|
||||
- python: 3.6
|
||||
- python: 3.8
|
||||
env: TOXENV="lint,docs"
|
||||
|
||||
# Anymail supports the same Python versions as Django, plus PyPy.
|
||||
@@ -26,12 +26,6 @@ matrix:
|
||||
# Live API integration tests are only run on a few, representative Python/Django version
|
||||
# combinations, to avoid rapidly consuming the testing accounts' entire send allotments.
|
||||
|
||||
# Django 1.11: Python 2.7, 3.4, 3.5, or 3.6
|
||||
- { env: TOXENV=django111-py27-all RUN_LIVE_TESTS=true, python: 2.7 }
|
||||
- { env: TOXENV=django111-py34-all, python: 3.4 }
|
||||
- { env: TOXENV=django111-py35-all, python: 3.5 }
|
||||
- { env: TOXENV=django111-py36-all, python: 3.6 }
|
||||
- { env: TOXENV=django111-pypy-all, python: pypy2.7-6.0 }
|
||||
# Django 2.0: Python 3.5+
|
||||
- { env: TOXENV=django20-py35-all, python: 3.5 }
|
||||
- { env: TOXENV=django20-py36-all, python: 3.6 }
|
||||
@@ -44,7 +38,7 @@ matrix:
|
||||
# Django 2.2: Python 3.5, 3.6, or 3.7
|
||||
- { env: TOXENV=django22-py35-all, python: 3.5 }
|
||||
- { env: TOXENV=django22-py36-all, python: 3.6 }
|
||||
- { env: TOXENV=django22-py37-all RUN_LIVE_TESTS=true, python: 3.7 }
|
||||
- { env: TOXENV=django22-py37-all, python: 3.7 }
|
||||
- { env: TOXENV=django22-pypy3-all, python: pypy3 }
|
||||
# Django 3.0: Python 3.6, 3.7, or 3.8
|
||||
- { env: TOXENV=django30-py36-all, python: 3.6 }
|
||||
@@ -54,16 +48,15 @@ matrix:
|
||||
# Django 3.1: Python 3.6, 3.7, or 3.8
|
||||
- { env: TOXENV=django31-py36-all, python: 3.6 }
|
||||
- { env: TOXENV=django31-py37-all, python: 3.7 }
|
||||
- { env: TOXENV=django31-py38-all, python: 3.8 }
|
||||
- { env: TOXENV=django31-py38-all RUN_LIVE_TESTS=true, python: 3.8 }
|
||||
- { env: TOXENV=django31-pypy3-all, python: pypy3 }
|
||||
# Django current development (direct from GitHub source main branch):
|
||||
- { env: TOXENV=djangoDev-py37-all, python: 3.7 }
|
||||
# Install without optional extras (don't need to cover entire matrix)
|
||||
- { env: TOXENV=django22-py37-none, python: 3.7 }
|
||||
- { env: TOXENV=django22-py37-amazon_ses, python: 3.7 }
|
||||
- { env: TOXENV=django22-py37-sparkpost, python: 3.7 }
|
||||
- { env: TOXENV=django31-py37-none, python: 3.7 }
|
||||
- { env: TOXENV=django31-py37-amazon_ses, python: 3.7 }
|
||||
- { env: TOXENV=django31-py37-sparkpost, python: 3.7 }
|
||||
# Test some specific older package versions
|
||||
- { env: TOXENV=django111-py27-all-old_urllib3, python: 3.7 }
|
||||
- { env: TOXENV=django22-py37-all-old_urllib3, python: 3.7 }
|
||||
|
||||
allow_failures:
|
||||
|
||||
@@ -25,6 +25,30 @@ Release history
|
||||
^^^^^^^^^^^^^^^
|
||||
.. This extra heading level keeps the ToC from becoming unmanageably long
|
||||
|
||||
vNext
|
||||
-----
|
||||
|
||||
*Unreleased changes in development*
|
||||
|
||||
Breaking changes
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
||||
* Drop support for Django versions older than Django 2.0, and for Python 2.7.
|
||||
(For compatibility with Django 1.11, stay on the Anymail `v7.2 LTS`_
|
||||
extended support branch by setting your requirements to `django-anymail~=7.2`.)
|
||||
|
||||
* Remove Anymail internal code related to supporting Python 2 and older Django
|
||||
versions. This does not change the documented API, but may affect you if your
|
||||
code borrowed from Anymail's undocumented internals. (You should be able to switch
|
||||
to the Python standard library equivalents, as Anymail has done.)
|
||||
|
||||
* AnymailMessageMixin now correctly subclasses Django's EmailMessage. If you use it
|
||||
as part of your own custom EmailMessage-derived class, and you start getting errors
|
||||
about "consistent method resolution order," you probably need to change your class's
|
||||
inheritance. (For some helpful background, see this comment about
|
||||
`mixin superclass ordering <https://nedbatchelder.com/blog/201210/multiple_inheritance_is_hard.html#comment_13805>`__.)
|
||||
|
||||
|
||||
v7.2 LTS
|
||||
--------
|
||||
|
||||
|
||||
1
Pipfile
1
Pipfile
@@ -10,7 +10,6 @@ name = "pypi"
|
||||
boto3 = "*"
|
||||
django = "*"
|
||||
requests = "*"
|
||||
six = "*"
|
||||
sparkpost = "*"
|
||||
|
||||
[dev-packages]
|
||||
|
||||
15
README.rst
15
README.rst
@@ -40,9 +40,14 @@ built-in `django.core.mail` package. It includes:
|
||||
* Inbound message support, to receive email through your ESP's webhooks,
|
||||
with simplified, portable access to attachments and other inbound content
|
||||
|
||||
Anymail is released under the BSD license. It is extensively tested against
|
||||
Django 1.11--3.0 on all Python versions supported by Django.
|
||||
Anymail releases follow `semantic versioning <http://semver.org/>`_.
|
||||
Anymail maintains compatibility with all Django versions that are in mainstream
|
||||
or extended support, plus (usually) a few older Django versions, and is extensively
|
||||
tested on all Python versions supported by Django. (Even-older Django versions
|
||||
may still be covered by an Anymail extended support release; consult the
|
||||
`changelog <https://anymail.readthedocs.io/en/stable/changelog/>`_ for details.)
|
||||
|
||||
Anymail releases follow `semantic versioning <https://semver.org/>`_.
|
||||
The package is released under the BSD license.
|
||||
|
||||
.. END shared-intro
|
||||
|
||||
@@ -124,7 +129,7 @@ or SparkPost or any other supported ESP where you see "mailgun":
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject="Please activate your account",
|
||||
body="Click to activate your account: http://example.com/activate",
|
||||
body="Click to activate your account: https://example.com/activate",
|
||||
from_email="Example <admin@example.com>",
|
||||
to=["New User <user1@example.com>", "account.manager@example.com"],
|
||||
reply_to=["Helpdesk <support@example.com>"])
|
||||
@@ -132,7 +137,7 @@ or SparkPost or any other supported ESP where you see "mailgun":
|
||||
# Include an inline image in the html:
|
||||
logo_cid = attach_inline_image_file(msg, "/path/to/logo.jpg")
|
||||
html = """<img alt="Logo" src="cid:{logo_cid}">
|
||||
<p>Please <a href="http://example.com/activate">activate</a>
|
||||
<p>Please <a href="https://example.com/activate">activate</a>
|
||||
your account</p>""".format(logo_cid=logo_cid)
|
||||
msg.attach_alternative(html, "text/html")
|
||||
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
# Work around bugs in older versions of email.parser.Parser
|
||||
#
|
||||
# This module implements two classes:
|
||||
# EmailParser
|
||||
# EmailBytesParser
|
||||
# which can be used like the Python 3.3+ email.parser.Parser
|
||||
# and email.parser.BytesParser (with email.policy.default).
|
||||
#
|
||||
# On Python 2.7, they attempt to work around some bugs/limitations
|
||||
# in email.parser.Parser, without trying to back-port the whole
|
||||
# Python 3 email package.
|
||||
|
||||
__all__ = ['EmailParser', 'EmailBytesParser']
|
||||
|
||||
|
||||
from email.parser import Parser
|
||||
|
||||
try:
|
||||
# With Python 3.3+ (email6) package, using `policy=email.policy.default`
|
||||
# avoids earlier bugs. (Note that Parser defaults to policy=compat32,
|
||||
# which *preserves* earlier bugs.)
|
||||
from email.policy import default
|
||||
from email.parser import BytesParser
|
||||
|
||||
class EmailParser(Parser):
|
||||
def __init__(self, _class=None, policy=default): # don't default to compat32 policy
|
||||
super(EmailParser, self).__init__(_class, policy=policy)
|
||||
|
||||
class EmailBytesParser(BytesParser):
|
||||
def __init__(self, _class=None, policy=default): # don't default to compat32 policy
|
||||
super(EmailBytesParser, self).__init__(_class, policy=policy)
|
||||
|
||||
except ImportError:
|
||||
# Pre-Python 3.3 email package: try to work around some bugs
|
||||
from email.header import decode_header
|
||||
from collections import deque
|
||||
|
||||
class EmailParser(Parser):
|
||||
def parse(self, fp, headersonly=False):
|
||||
# Older Parser doesn't correctly unfold headers (RFC5322 section 2.2.3).
|
||||
# Help it out by pre-unfolding the headers for it.
|
||||
fp = HeaderUnfoldingWrapper(fp)
|
||||
message = Parser.parse(self, fp, headersonly=headersonly)
|
||||
|
||||
# Older Parser doesn't decode RFC2047 headers, so fix them up here.
|
||||
# (Since messsage is fully parsed, can decode headers in all MIME subparts.)
|
||||
for part in message.walk():
|
||||
part._headers = [ # doesn't seem to be a public API to easily replace all headers
|
||||
(name, _decode_rfc2047(value))
|
||||
for name, value in part._headers]
|
||||
return message
|
||||
|
||||
class EmailBytesParser(EmailParser):
|
||||
def parsebytes(self, text, headersonly=False):
|
||||
# In Python 2, bytes is str, and Parser.parsestr uses bytes-friendly cStringIO.StringIO.
|
||||
return self.parsestr(text, headersonly)
|
||||
|
||||
class HeaderUnfoldingWrapper:
|
||||
"""
|
||||
A wrapper for file-like objects passed to email.parser.Parser.parse which works
|
||||
around older Parser bugs with folded email headers by pre-unfolding them.
|
||||
|
||||
This only works for headers at the message root, not ones within a MIME subpart.
|
||||
(Accurately recognizing subpart headers would require parsing mixed-content boundaries.)
|
||||
"""
|
||||
|
||||
def __init__(self, fp):
|
||||
self.fp = fp
|
||||
self._in_headers = True
|
||||
self._pushback = deque()
|
||||
|
||||
def _readline(self, limit=-1):
|
||||
try:
|
||||
line = self._pushback.popleft()
|
||||
except IndexError:
|
||||
line = self.fp.readline(limit)
|
||||
# cStringIO.readline doesn't recognize universal newlines; splitlines does
|
||||
lines = line.splitlines(True)
|
||||
if len(lines) > 1:
|
||||
line = lines[0]
|
||||
self._pushback.extend(lines[1:])
|
||||
return line
|
||||
|
||||
def _peekline(self, limit=-1):
|
||||
try:
|
||||
line = self._pushback[0]
|
||||
except IndexError:
|
||||
line = self._readline(limit)
|
||||
self._pushback.appendleft(line)
|
||||
return line
|
||||
|
||||
def readline(self, limit=-1):
|
||||
line = self._readline(limit)
|
||||
if self._in_headers:
|
||||
line_without_end = line.rstrip("\r\n") # CRLF, CR, or LF -- "universal newlines"
|
||||
if len(line_without_end) == 0:
|
||||
# RFC5322 section 2.1: "The body ... is separated from the header section
|
||||
# by an empty line (i.e., a line with nothing preceding the CRLF)."
|
||||
self._in_headers = False
|
||||
else:
|
||||
# Is this header line folded? Need to check next line...
|
||||
# RFC5322 section 2.2.3: "Unfolding is accomplished by simply removing any CRLF
|
||||
# that is immediately followed by WSP." (WSP is space or tab)
|
||||
next_line = self._peekline(limit)
|
||||
if next_line.startswith((' ', '\t')):
|
||||
line = line_without_end
|
||||
return line
|
||||
|
||||
def read(self, size):
|
||||
if self._in_headers:
|
||||
# For simplicity, just read a line at a time while in the header section.
|
||||
# (This works because we know email.parser.Parser doesn't really care if it reads
|
||||
# more or less data than it asked for -- it just pushes it into FeedParser either way.)
|
||||
return self.readline(size)
|
||||
elif len(self._pushback):
|
||||
buf = ''.join(self._pushback)
|
||||
self._pushback.clear()
|
||||
return buf
|
||||
else:
|
||||
return self.fp.read(size)
|
||||
|
||||
def _decode_rfc2047(value):
|
||||
result = value
|
||||
decoded_segments = decode_header(value)
|
||||
if any(charset is not None for raw, charset in decoded_segments):
|
||||
# At least one segment is an RFC2047 encoded-word.
|
||||
# Reassemble the segments into a single decoded string.
|
||||
unicode_segments = []
|
||||
prev_charset = None
|
||||
for raw, charset in decoded_segments:
|
||||
if (charset is None or prev_charset is None) and unicode_segments:
|
||||
# Transitioning to, from, or between *non*-encoded segments:
|
||||
# add back inter-segment whitespace that decode_header consumed
|
||||
unicode_segments.append(u" ")
|
||||
decoded = raw.decode(charset, 'replace') if charset is not None else raw
|
||||
unicode_segments.append(decoded)
|
||||
prev_charset = charset
|
||||
result = u"".join(unicode_segments)
|
||||
return result
|
||||
@@ -1,3 +1,3 @@
|
||||
VERSION = (7, 2)
|
||||
VERSION = (8, 0, 0, 'dev0')
|
||||
__version__ = '.'.join([str(x) for x in VERSION]) # major.minor.patch or major.minor.devN
|
||||
__minor_version__ = '.'.join([str(x) for x in VERSION[:2]]) # Sphinx's X.Y "version"
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
from email.charset import Charset, QP
|
||||
from email.header import Header
|
||||
from email.mime.base import MIMEBase
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
from django.core.mail import BadHeaderError
|
||||
|
||||
from .base import AnymailBaseBackend, BasePayload
|
||||
from .._version import __version__
|
||||
from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled
|
||||
@@ -15,42 +11,14 @@ try:
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
from botocore.exceptions import BotoCoreError, ClientError, ConnectionError
|
||||
except ImportError:
|
||||
raise AnymailImproperlyInstalled(missing_package='boto3', backend='amazon_ses')
|
||||
except ImportError as err:
|
||||
raise AnymailImproperlyInstalled(missing_package='boto3', backend='amazon_ses') from err
|
||||
|
||||
|
||||
# boto3 has several root exception classes; this is meant to cover all of them
|
||||
BOTO_BASE_ERRORS = (BotoCoreError, ClientError, ConnectionError)
|
||||
|
||||
|
||||
# Work around Python 2 bug in email.message.Message.to_string, where long headers
|
||||
# containing commas or semicolons get an extra space inserted after every ',' or ';'
|
||||
# not already followed by a space. https://bugs.python.org/issue25257
|
||||
if Header("test,Python2,header,comma,bug", maxlinelen=20).encode() == "test,Python2,header,comma,bug":
|
||||
# no workaround needed
|
||||
HeaderBugWorkaround = None
|
||||
|
||||
def add_header(message, name, val):
|
||||
message[name] = val
|
||||
|
||||
else:
|
||||
# workaround: custom Header subclass that won't consider ',' and ';' as folding candidates
|
||||
|
||||
class HeaderBugWorkaround(Header):
|
||||
def encode(self, splitchars=' ', **kwargs): # only split on spaces, rather than splitchars=';, '
|
||||
return Header.encode(self, splitchars, **kwargs)
|
||||
|
||||
def add_header(message, name, val):
|
||||
# Must bypass Django's SafeMIMEMessage.__set_item__, because its call to
|
||||
# forbid_multi_line_headers converts the val back to a str, undoing this
|
||||
# workaround. That makes this code responsible for sanitizing val:
|
||||
if '\n' in val or '\r' in val:
|
||||
raise BadHeaderError("Header values can't contain newlines (got %r for header %r)" % (val, name))
|
||||
val = HeaderBugWorkaround(val, header_name=name)
|
||||
assert isinstance(message, MIMEBase)
|
||||
MIMEBase.__setitem__(message, name, val)
|
||||
|
||||
|
||||
class EmailBackend(AnymailBaseBackend):
|
||||
"""
|
||||
Amazon SES Email Backend (using boto3)
|
||||
@@ -60,7 +28,7 @@ class EmailBackend(AnymailBaseBackend):
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Init options from Django settings"""
|
||||
super(EmailBackend, self).__init__(**kwargs)
|
||||
super().__init__(**kwargs)
|
||||
# AMAZON_SES_CLIENT_PARAMS is optional - boto3 can find credentials several other ways
|
||||
self.session_params, self.client_params = _get_anymail_boto3_params(kwargs=kwargs)
|
||||
self.configuration_set_name = get_anymail_setting("configuration_set_name", esp_name=self.esp_name,
|
||||
@@ -77,6 +45,8 @@ class EmailBackend(AnymailBaseBackend):
|
||||
except BOTO_BASE_ERRORS:
|
||||
if not self.fail_silently:
|
||||
raise
|
||||
else:
|
||||
return True # created client
|
||||
|
||||
def close(self):
|
||||
if self.client is None:
|
||||
@@ -98,7 +68,7 @@ class EmailBackend(AnymailBaseBackend):
|
||||
except BOTO_BASE_ERRORS as err:
|
||||
# ClientError has a response attr with parsed json error response (other errors don't)
|
||||
raise AnymailAPIError(str(err), backend=self, email_message=message, payload=payload,
|
||||
response=getattr(err, 'response', None), raised_from=err)
|
||||
response=getattr(err, 'response', None)) from err
|
||||
return response
|
||||
|
||||
def parse_recipient_status(self, response, payload, message):
|
||||
@@ -125,12 +95,9 @@ class AmazonSESBasePayload(BasePayload):
|
||||
|
||||
class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
|
||||
def init_payload(self):
|
||||
super(AmazonSESSendRawEmailPayload, self).init_payload()
|
||||
super().init_payload()
|
||||
self.all_recipients = []
|
||||
self.mime_message = self.message.message()
|
||||
if HeaderBugWorkaround and "Subject" in self.mime_message:
|
||||
# (message.message() will have already checked subject for BadHeaderError)
|
||||
self.mime_message.replace_header("Subject", HeaderBugWorkaround(self.message.subject))
|
||||
|
||||
# Work around an Amazon SES bug where, if all of:
|
||||
# - the message body (text or html) contains non-ASCII characters
|
||||
@@ -165,7 +132,7 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
|
||||
except (KeyError, TypeError) as err:
|
||||
raise AnymailAPIError(
|
||||
"%s parsing Amazon SES send result %r" % (str(err), response),
|
||||
backend=self.backend, email_message=self.message, payload=self)
|
||||
backend=self.backend, email_message=self.message, payload=self) from None
|
||||
|
||||
recipient_status = AnymailRecipientStatus(message_id=message_id, status="queued")
|
||||
return {recipient.addr_spec: recipient_status for recipient in self.all_recipients}
|
||||
@@ -248,14 +215,14 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
|
||||
# (See "How do message tags work?" in https://aws.amazon.com/blogs/ses/introducing-sending-metrics/
|
||||
# and https://forums.aws.amazon.com/thread.jspa?messageID=782922.)
|
||||
# To support reliable retrieval in webhooks, just use custom headers for metadata.
|
||||
add_header(self.mime_message, "X-Metadata", self.serialize_json(metadata))
|
||||
self.mime_message["X-Metadata"] = self.serialize_json(metadata)
|
||||
|
||||
def set_tags(self, tags):
|
||||
# See note about Amazon SES Message Tags and custom headers in set_metadata above.
|
||||
# To support reliable retrieval in webhooks, use custom headers for tags.
|
||||
# (There are no restrictions on number or content for custom header tags.)
|
||||
for tag in tags:
|
||||
add_header(self.mime_message, "X-Tag", tag) # creates multiple X-Tag headers, one per tag
|
||||
self.mime_message.add_header("X-Tag", tag) # creates multiple X-Tag headers, one per tag
|
||||
|
||||
# Also *optionally* pass a single Message Tag if the AMAZON_SES_MESSAGE_TAG_NAME
|
||||
# Anymail setting is set (default no). The AWS API restricts tag content in this case.
|
||||
@@ -278,7 +245,7 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
|
||||
|
||||
class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload):
|
||||
def init_payload(self):
|
||||
super(AmazonSESSendBulkTemplatedEmailPayload, self).init_payload()
|
||||
super().init_payload()
|
||||
# late-bind recipients and merge_data in call_send_api
|
||||
self.recipients = {"to": [], "cc": [], "bcc": []}
|
||||
self.merge_data = {}
|
||||
@@ -311,7 +278,7 @@ class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload):
|
||||
except (KeyError, TypeError) as err:
|
||||
raise AnymailAPIError(
|
||||
"%s parsing Amazon SES send result %r" % (str(err), response),
|
||||
backend=self.backend, email_message=self.message, payload=self)
|
||||
backend=self.backend, email_message=self.message, payload=self) from None
|
||||
|
||||
to_addrs = [to.addr_spec for to in self.recipients["to"]]
|
||||
if len(anymail_statuses) != len(to_addrs):
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
|
||||
import six
|
||||
from django.conf import settings
|
||||
from django.core.mail.backends.base import BaseEmailBackend
|
||||
from django.utils.timezone import is_naive, get_current_timezone, make_aware, utc
|
||||
@@ -23,7 +22,7 @@ class AnymailBaseBackend(BaseEmailBackend):
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(AnymailBaseBackend, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.ignore_unsupported_features = get_anymail_setting('ignore_unsupported_features',
|
||||
kwargs=kwargs, default=False)
|
||||
@@ -207,7 +206,7 @@ class AnymailBaseBackend(BaseEmailBackend):
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
|
||||
|
||||
class BasePayload(object):
|
||||
class BasePayload:
|
||||
# Listing of EmailMessage/EmailMultiAlternatives attributes
|
||||
# to process into Payload. Each item is in the form:
|
||||
# (attr, combiner, converter)
|
||||
@@ -365,7 +364,7 @@ class BasePayload(object):
|
||||
# TypeError: must be str, not list
|
||||
# TypeError: can only concatenate list (not "str") to list
|
||||
# TypeError: Can't convert 'list' object to str implicitly
|
||||
if isinstance(value, six.string_types) or is_lazy(value):
|
||||
if isinstance(value, str) or is_lazy(value):
|
||||
raise TypeError('"{attr}" attribute must be a list or other iterable'.format(attr=attr))
|
||||
|
||||
#
|
||||
@@ -538,7 +537,7 @@ class BasePayload(object):
|
||||
except TypeError as err:
|
||||
# Add some context to the "not JSON serializable" message
|
||||
raise AnymailSerializationError(orig_err=err, email_message=self.message,
|
||||
backend=self.backend, payload=self)
|
||||
backend=self.backend, payload=self) from None
|
||||
|
||||
@staticmethod
|
||||
def _json_default(o):
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
from __future__ import print_function
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
import six
|
||||
from six.moves.urllib.parse import urljoin
|
||||
|
||||
from anymail.utils import get_anymail_setting
|
||||
from .base import AnymailBaseBackend, BasePayload
|
||||
from ..exceptions import AnymailRequestsAPIError
|
||||
from .._version import __version__
|
||||
from ..exceptions import AnymailRequestsAPIError
|
||||
|
||||
|
||||
class AnymailRequestsBackend(AnymailBaseBackend):
|
||||
@@ -19,7 +17,7 @@ class AnymailRequestsBackend(AnymailBaseBackend):
|
||||
"""Init options from Django settings"""
|
||||
self.api_url = api_url
|
||||
self.timeout = get_anymail_setting('requests_timeout', kwargs=kwargs, default=30)
|
||||
super(AnymailRequestsBackend, self).__init__(**kwargs)
|
||||
super().__init__(**kwargs)
|
||||
self.session = None
|
||||
|
||||
def open(self):
|
||||
@@ -57,7 +55,7 @@ class AnymailRequestsBackend(AnymailBaseBackend):
|
||||
"Session has not been opened in {class_name}._send. "
|
||||
"(This is either an implementation error in {class_name}, "
|
||||
"or you are incorrectly calling _send directly.)".format(class_name=class_name))
|
||||
return super(AnymailRequestsBackend, self)._send(message)
|
||||
return super()._send(message)
|
||||
|
||||
def post_to_esp(self, payload, message):
|
||||
"""Post payload to ESP send API endpoint, and return the raw response.
|
||||
@@ -78,7 +76,7 @@ class AnymailRequestsBackend(AnymailBaseBackend):
|
||||
exc_class = type('AnymailRequestsAPIError', (AnymailRequestsAPIError, type(err)), {})
|
||||
raise exc_class(
|
||||
"Error posting to %s:" % params.get('url', '<missing url>'),
|
||||
raised_from=err, email_message=message, payload=payload)
|
||||
email_message=message, payload=payload) from err
|
||||
self.raise_for_status(response, payload, message)
|
||||
return response
|
||||
|
||||
@@ -100,10 +98,10 @@ class AnymailRequestsBackend(AnymailBaseBackend):
|
||||
"""
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError:
|
||||
except ValueError as err:
|
||||
raise AnymailRequestsAPIError("Invalid JSON in %s API response" % self.esp_name,
|
||||
email_message=message, payload=payload, response=response,
|
||||
backend=self)
|
||||
backend=self) from err
|
||||
|
||||
@staticmethod
|
||||
def _dump_api_request(response, **kwargs):
|
||||
@@ -113,21 +111,21 @@ class AnymailRequestsBackend(AnymailBaseBackend):
|
||||
# If you need the raw bytes, configure HTTPConnection logging as shown
|
||||
# in http://docs.python-requests.org/en/v3.0.0/api/#api-changes)
|
||||
request = response.request # a PreparedRequest
|
||||
print(u"\n===== Anymail API request")
|
||||
print(u"{method} {url}\n{headers}".format(
|
||||
print("\n===== Anymail API request")
|
||||
print("{method} {url}\n{headers}".format(
|
||||
method=request.method, url=request.url,
|
||||
headers=u"".join(u"{header}: {value}\n".format(header=header, value=value)
|
||||
headers="".join("{header}: {value}\n".format(header=header, value=value)
|
||||
for (header, value) in request.headers.items()),
|
||||
))
|
||||
if request.body is not None:
|
||||
body_text = (request.body if isinstance(request.body, six.text_type)
|
||||
body_text = (request.body if isinstance(request.body, str)
|
||||
else request.body.decode("utf-8", errors="replace")
|
||||
).replace("\r\n", "\n")
|
||||
print(body_text)
|
||||
print(u"\n----- Response")
|
||||
print(u"HTTP {status} {reason}\n{headers}\n{body}".format(
|
||||
print("\n----- Response")
|
||||
print("HTTP {status} {reason}\n{headers}\n{body}".format(
|
||||
status=response.status_code, reason=response.reason,
|
||||
headers=u"".join(u"{header}: {value}\n".format(header=header, value=value)
|
||||
headers="".join("{header}: {value}\n".format(header=header, value=value)
|
||||
for (header, value) in response.headers.items()),
|
||||
body=response.text, # Let Requests decode body content for us
|
||||
))
|
||||
@@ -145,7 +143,7 @@ class RequestsPayload(BasePayload):
|
||||
self.headers = headers
|
||||
self.files = files
|
||||
self.auth = auth
|
||||
super(RequestsPayload, self).__init__(message, defaults, backend)
|
||||
super().__init__(message, defaults, backend)
|
||||
|
||||
def get_request_params(self, api_url):
|
||||
"""Returns a dict of requests.request params that will send payload to the ESP.
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
from datetime import datetime
|
||||
from email.utils import encode_rfc2231
|
||||
from six.moves.urllib.parse import quote
|
||||
from urllib.parse import quote
|
||||
|
||||
from requests import Request
|
||||
|
||||
from ..exceptions import AnymailRequestsAPIError, AnymailError
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
from ..exceptions import AnymailError, AnymailRequestsAPIError
|
||||
from ..message import AnymailRecipientStatus
|
||||
from ..utils import get_anymail_setting, rfc2822date
|
||||
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
|
||||
|
||||
# Feature-detect whether requests (urllib3) correctly uses RFC 7578 encoding for non-
|
||||
# ASCII filenames in Content-Disposition headers. (This was fixed in urllib3 v1.25.)
|
||||
@@ -17,7 +16,7 @@ from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
# (Note: when this workaround is removed, please also remove the "old_urllib3" tox envs.)
|
||||
def is_requests_rfc_5758_compliant():
|
||||
request = Request(method='POST', url='https://www.example.com',
|
||||
files=[('attachment', (u'\N{NOT SIGN}.txt', 'test', 'text/plain'))])
|
||||
files=[('attachment', ('\N{NOT SIGN}.txt', 'test', 'text/plain'))])
|
||||
prepared = request.prepare()
|
||||
form_data = prepared.body # bytes
|
||||
return b'filename*=' not in form_data
|
||||
@@ -43,7 +42,7 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
default="https://api.mailgun.net/v3")
|
||||
if not api_url.endswith("/"):
|
||||
api_url += "/"
|
||||
super(EmailBackend, self).__init__(api_url, **kwargs)
|
||||
super().__init__(api_url, **kwargs)
|
||||
|
||||
def build_message_payload(self, message, defaults):
|
||||
return MailgunPayload(message, defaults, self)
|
||||
@@ -62,10 +61,10 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
try:
|
||||
message_id = parsed_response["id"]
|
||||
mailgun_message = parsed_response["message"]
|
||||
except (KeyError, TypeError):
|
||||
except (KeyError, TypeError) as err:
|
||||
raise AnymailRequestsAPIError("Invalid Mailgun API response format",
|
||||
email_message=message, payload=payload, response=response,
|
||||
backend=self)
|
||||
backend=self) from err
|
||||
if not mailgun_message.startswith("Queued"):
|
||||
raise AnymailRequestsAPIError("Unrecognized Mailgun API message '%s'" % mailgun_message,
|
||||
email_message=message, payload=payload, response=response,
|
||||
@@ -89,7 +88,7 @@ class MailgunPayload(RequestsPayload):
|
||||
self.merge_metadata = {}
|
||||
self.to_emails = []
|
||||
|
||||
super(MailgunPayload, self).__init__(message, defaults, backend, auth=auth, *args, **kwargs)
|
||||
super().__init__(message, defaults, backend, auth=auth, *args, **kwargs)
|
||||
|
||||
def get_api_endpoint(self):
|
||||
if self.sender_domain is None:
|
||||
@@ -105,7 +104,7 @@ class MailgunPayload(RequestsPayload):
|
||||
return "%s/messages" % quote(self.sender_domain, safe='')
|
||||
|
||||
def get_request_params(self, api_url):
|
||||
params = super(MailgunPayload, self).get_request_params(api_url)
|
||||
params = super().get_request_params(api_url)
|
||||
non_ascii_filenames = [filename
|
||||
for (field, (filename, content, mimetype)) in params["files"]
|
||||
if filename is not None and not isascii(filename)]
|
||||
@@ -122,9 +121,7 @@ class MailgunPayload(RequestsPayload):
|
||||
prepared = Request(**params).prepare()
|
||||
form_data = prepared.body # bytes
|
||||
for filename in non_ascii_filenames: # text
|
||||
rfc2231_filename = encode_rfc2231( # wants a str (text in PY3, bytes in PY2)
|
||||
filename if isinstance(filename, str) else filename.encode("utf-8"),
|
||||
charset="utf-8")
|
||||
rfc2231_filename = encode_rfc2231(filename, charset="utf-8")
|
||||
form_data = form_data.replace(
|
||||
b'filename*=' + rfc2231_filename.encode("utf-8"),
|
||||
b'filename="' + filename.encode("utf-8") + b'"')
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
from email.header import Header
|
||||
|
||||
from six.moves.urllib.parse import quote
|
||||
|
||||
from ..exceptions import AnymailRequestsAPIError
|
||||
from ..message import AnymailRecipientStatus, ANYMAIL_STATUSES
|
||||
from ..utils import get_anymail_setting, EmailAddress, parse_address_list
|
||||
from urllib.parse import quote
|
||||
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
from ..exceptions import AnymailRequestsAPIError
|
||||
from ..message import ANYMAIL_STATUSES, AnymailRecipientStatus
|
||||
from ..utils import EmailAddress, get_anymail_setting, parse_address_list
|
||||
|
||||
|
||||
class EmailBackend(AnymailRequestsBackend):
|
||||
@@ -25,7 +23,7 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
default="https://api.mailjet.com/v3")
|
||||
if not api_url.endswith("/"):
|
||||
api_url += "/"
|
||||
super(EmailBackend, self).__init__(api_url, **kwargs)
|
||||
super().__init__(api_url, **kwargs)
|
||||
|
||||
def build_message_payload(self, message, defaults):
|
||||
return MailjetPayload(message, defaults, self)
|
||||
@@ -36,7 +34,7 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
raise AnymailRequestsAPIError(
|
||||
"Invalid Mailjet API key or secret",
|
||||
email_message=message, payload=payload, response=response, backend=self)
|
||||
super(EmailBackend, self).raise_for_status(response, payload, message)
|
||||
super().raise_for_status(response, payload, message)
|
||||
|
||||
def parse_recipient_status(self, response, payload, message):
|
||||
# Mailjet's (v3.0) transactional send API is not covered in their reference docs.
|
||||
@@ -61,10 +59,10 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
message_id = str(item['MessageID'])
|
||||
email = item['Email']
|
||||
recipient_status[email] = AnymailRecipientStatus(message_id=message_id, status=status)
|
||||
except (KeyError, TypeError):
|
||||
except (KeyError, TypeError) as err:
|
||||
raise AnymailRequestsAPIError("Invalid Mailjet API response format",
|
||||
email_message=message, payload=payload, response=response,
|
||||
backend=self)
|
||||
backend=self) from err
|
||||
# Make sure we ended up with a status for every original recipient
|
||||
# (Mailjet only communicates "Sent")
|
||||
for recipients in payload.recipients.values():
|
||||
@@ -88,8 +86,7 @@ class MailjetPayload(RequestsPayload):
|
||||
self.metadata = None
|
||||
self.merge_data = {}
|
||||
self.merge_metadata = {}
|
||||
super(MailjetPayload, self).__init__(message, defaults, backend,
|
||||
auth=auth, headers=http_headers, *args, **kwargs)
|
||||
super().__init__(message, defaults, backend, auth=auth, headers=http_headers, *args, **kwargs)
|
||||
|
||||
def get_api_endpoint(self):
|
||||
return "send"
|
||||
@@ -153,9 +150,10 @@ class MailjetPayload(RequestsPayload):
|
||||
parsed.addr_spec)
|
||||
else:
|
||||
parsed = EmailAddress(headers["SenderName"], headers["SenderEmail"])
|
||||
except KeyError:
|
||||
except KeyError as err:
|
||||
raise AnymailRequestsAPIError("Invalid Mailjet template API response",
|
||||
email_message=self.message, response=response, backend=self.backend)
|
||||
email_message=self.message, response=response,
|
||||
backend=self.backend) from err
|
||||
self.set_from_email(parsed)
|
||||
|
||||
def _format_email_for_mailjet(self, email):
|
||||
|
||||
@@ -23,7 +23,7 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
default="https://mandrillapp.com/api/1.0")
|
||||
if not api_url.endswith("/"):
|
||||
api_url += "/"
|
||||
super(EmailBackend, self).__init__(api_url, **kwargs)
|
||||
super().__init__(api_url, **kwargs)
|
||||
|
||||
def build_message_payload(self, message, defaults):
|
||||
return MandrillPayload(message, defaults, self)
|
||||
@@ -40,10 +40,10 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
status = 'unknown'
|
||||
message_id = item.get('_id', None) # can be missing for invalid/rejected recipients
|
||||
recipient_status[email] = AnymailRecipientStatus(message_id=message_id, status=status)
|
||||
except (KeyError, TypeError):
|
||||
except (KeyError, TypeError) as err:
|
||||
raise AnymailRequestsAPIError("Invalid Mandrill API response format",
|
||||
email_message=message, payload=payload, response=response,
|
||||
backend=self)
|
||||
backend=self) from err
|
||||
return recipient_status
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ class MandrillPayload(RequestsPayload):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.esp_extra = {} # late-bound in serialize_data
|
||||
super(MandrillPayload, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def get_api_endpoint(self):
|
||||
if 'template_name' in self.data:
|
||||
|
||||
@@ -22,7 +22,7 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
default="https://api.postmarkapp.com/")
|
||||
if not api_url.endswith("/"):
|
||||
api_url += "/"
|
||||
super(EmailBackend, self).__init__(api_url, **kwargs)
|
||||
super().__init__(api_url, **kwargs)
|
||||
|
||||
def build_message_payload(self, message, defaults):
|
||||
return PostmarkPayload(message, defaults, self)
|
||||
@@ -30,7 +30,7 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
def raise_for_status(self, response, payload, message):
|
||||
# We need to handle 422 responses in parse_recipient_status
|
||||
if response.status_code != 422:
|
||||
super(EmailBackend, self).raise_for_status(response, payload, message)
|
||||
super().raise_for_status(response, payload, message)
|
||||
|
||||
def parse_recipient_status(self, response, payload, message):
|
||||
# Default to "unknown" status for each recipient, unless/until we find otherwise.
|
||||
@@ -51,19 +51,19 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
# these fields should always be present
|
||||
error_code = one_response["ErrorCode"]
|
||||
msg = one_response["Message"]
|
||||
except (KeyError, TypeError):
|
||||
except (KeyError, TypeError) as err:
|
||||
raise AnymailRequestsAPIError("Invalid Postmark API response format",
|
||||
email_message=message, payload=payload, response=response,
|
||||
backend=self)
|
||||
backend=self) from err
|
||||
|
||||
if error_code == 0:
|
||||
# At least partial success, and (some) email was sent.
|
||||
try:
|
||||
message_id = one_response["MessageID"]
|
||||
except KeyError:
|
||||
except KeyError as err:
|
||||
raise AnymailRequestsAPIError("Invalid Postmark API success response format",
|
||||
email_message=message, payload=payload,
|
||||
response=response, backend=self)
|
||||
response=response, backend=self) from err
|
||||
|
||||
# Assume all To recipients are "sent" unless proven otherwise below.
|
||||
# (Must use "To" from API response to get correct individual MessageIDs in batch send.)
|
||||
@@ -157,7 +157,7 @@ class PostmarkPayload(RequestsPayload):
|
||||
self.cc_and_bcc_emails = [] # need to track (separately) for parse_recipient_status
|
||||
self.merge_data = None
|
||||
self.merge_metadata = None
|
||||
super(PostmarkPayload, self).__init__(message, defaults, backend, headers=headers, *args, **kwargs)
|
||||
super().__init__(message, defaults, backend, headers=headers, *args, **kwargs)
|
||||
|
||||
def get_api_endpoint(self):
|
||||
batch_send = self.is_batch() and len(self.to_emails) > 1
|
||||
@@ -174,7 +174,7 @@ class PostmarkPayload(RequestsPayload):
|
||||
return "email"
|
||||
|
||||
def get_request_params(self, api_url):
|
||||
params = super(PostmarkPayload, self).get_request_params(api_url)
|
||||
params = super().get_request_params(api_url)
|
||||
params['headers']['X-Postmark-Server-Token'] = self.server_token
|
||||
return params
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from requests.structures import CaseInsensitiveDict
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
from ..exceptions import AnymailConfigurationError, AnymailRequestsAPIError, AnymailWarning
|
||||
from ..message import AnymailRecipientStatus
|
||||
from ..utils import BASIC_NUMERIC_TYPES, Mapping, get_anymail_setting, timestamp, update_deep
|
||||
from ..utils import BASIC_NUMERIC_TYPES, Mapping, get_anymail_setting, update_deep
|
||||
|
||||
|
||||
class EmailBackend(AnymailRequestsBackend):
|
||||
@@ -47,7 +47,7 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
default="https://api.sendgrid.com/v3/")
|
||||
if not api_url.endswith("/"):
|
||||
api_url += "/"
|
||||
super(EmailBackend, self).__init__(api_url, **kwargs)
|
||||
super().__init__(api_url, **kwargs)
|
||||
|
||||
def build_message_payload(self, message, defaults):
|
||||
return SendGridPayload(message, defaults, self)
|
||||
@@ -84,9 +84,7 @@ class SendGridPayload(RequestsPayload):
|
||||
http_headers['Authorization'] = 'Bearer %s' % backend.api_key
|
||||
http_headers['Content-Type'] = 'application/json'
|
||||
http_headers['Accept'] = 'application/json'
|
||||
super(SendGridPayload, self).__init__(message, defaults, backend,
|
||||
headers=http_headers,
|
||||
*args, **kwargs)
|
||||
super().__init__(message, defaults, backend, headers=http_headers, *args, **kwargs)
|
||||
|
||||
def get_api_endpoint(self):
|
||||
return "mail/send"
|
||||
@@ -294,7 +292,7 @@ class SendGridPayload(RequestsPayload):
|
||||
def set_send_at(self, send_at):
|
||||
# Backend has converted pretty much everything to
|
||||
# a datetime by here; SendGrid expects unix timestamp
|
||||
self.data["send_at"] = int(timestamp(send_at)) # strip microseconds
|
||||
self.data["send_at"] = int(send_at.timestamp()) # strip microseconds
|
||||
|
||||
def set_tags(self, tags):
|
||||
self.data["categories"] = tags
|
||||
|
||||
@@ -30,7 +30,7 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
)
|
||||
if not api_url.endswith("/"):
|
||||
api_url += "/"
|
||||
super(EmailBackend, self).__init__(api_url, **kwargs)
|
||||
super().__init__(api_url, **kwargs)
|
||||
|
||||
def build_message_payload(self, message, defaults):
|
||||
return SendinBluePayload(message, defaults, self)
|
||||
@@ -53,10 +53,10 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
parsed_response = self.deserialize_json_response(response, payload, message)
|
||||
try:
|
||||
message_id = parsed_response['messageId']
|
||||
except (KeyError, TypeError):
|
||||
except (KeyError, TypeError) as err:
|
||||
raise AnymailRequestsAPIError("Invalid SendinBlue API response format",
|
||||
email_message=message, payload=payload, response=response,
|
||||
backend=self)
|
||||
backend=self) from err
|
||||
|
||||
status = AnymailRecipientStatus(message_id=message_id, status="queued")
|
||||
return {recipient.addr_spec: status for recipient in payload.all_recipients}
|
||||
@@ -71,7 +71,7 @@ class SendinBluePayload(RequestsPayload):
|
||||
http_headers['api-key'] = backend.api_key
|
||||
http_headers['Content-Type'] = 'application/json'
|
||||
|
||||
super(SendinBluePayload, self).__init__(message, defaults, backend, headers=http_headers, *args, **kwargs)
|
||||
super().__init__(message, defaults, backend, headers=http_headers, *args, **kwargs)
|
||||
|
||||
def get_api_endpoint(self):
|
||||
return "smtp/email"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import absolute_import # we want the sparkpost package, not our own module
|
||||
|
||||
from .base import AnymailBaseBackend, BasePayload
|
||||
from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled, AnymailConfigurationError
|
||||
from ..message import AnymailRecipientStatus
|
||||
@@ -7,8 +5,8 @@ from ..utils import get_anymail_setting
|
||||
|
||||
try:
|
||||
from sparkpost import SparkPost, SparkPostException
|
||||
except ImportError:
|
||||
raise AnymailImproperlyInstalled(missing_package='sparkpost', backend='sparkpost')
|
||||
except ImportError as err:
|
||||
raise AnymailImproperlyInstalled(missing_package='sparkpost', backend='sparkpost') from err
|
||||
|
||||
|
||||
class EmailBackend(AnymailBaseBackend):
|
||||
@@ -20,7 +18,7 @@ class EmailBackend(AnymailBaseBackend):
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Init options from Django settings"""
|
||||
super(EmailBackend, self).__init__(**kwargs)
|
||||
super().__init__(**kwargs)
|
||||
# SPARKPOST_API_KEY is optional - library reads from env by default
|
||||
self.api_key = get_anymail_setting('api_key', esp_name=self.esp_name,
|
||||
kwargs=kwargs, allow_bare=True, default=None)
|
||||
@@ -43,7 +41,7 @@ class EmailBackend(AnymailBaseBackend):
|
||||
"You may need to set ANYMAIL = {'SPARKPOST_API_KEY': ...} "
|
||||
"or ANYMAIL_SPARKPOST_API_KEY in your Django settings, "
|
||||
"or SPARKPOST_API_KEY in your environment." % str(err)
|
||||
)
|
||||
) from err
|
||||
|
||||
# Note: SparkPost python API doesn't expose requests session sharing
|
||||
# (so there's no need to implement open/close connection management here)
|
||||
@@ -60,7 +58,7 @@ class EmailBackend(AnymailBaseBackend):
|
||||
str(err), backend=self, email_message=message, payload=payload,
|
||||
response=getattr(err, 'response', None), # SparkPostAPIException requests.Response
|
||||
status_code=getattr(err, 'status', None), # SparkPostAPIException HTTP status_code
|
||||
)
|
||||
) from err
|
||||
return response
|
||||
|
||||
def parse_recipient_status(self, response, payload, message):
|
||||
@@ -72,7 +70,7 @@ class EmailBackend(AnymailBaseBackend):
|
||||
raise AnymailAPIError(
|
||||
"%s in SparkPost.transmissions.send result %r" % (str(err), response),
|
||||
backend=self, email_message=message, payload=payload,
|
||||
)
|
||||
) from err
|
||||
|
||||
# SparkPost doesn't (yet*) tell us *which* recipients were accepted or rejected.
|
||||
# (* looks like undocumented 'rcpt_to_errors' might provide this info.)
|
||||
|
||||
@@ -25,7 +25,7 @@ class EmailBackend(AnymailBaseBackend):
|
||||
# Allow replacing the payload, for testing.
|
||||
# (Real backends would generally not implement this option.)
|
||||
self._payload_class = kwargs.pop('payload_class', TestPayload)
|
||||
super(EmailBackend, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
if not hasattr(mail, 'outbox'):
|
||||
mail.outbox = [] # see django.core.mail.backends.locmem
|
||||
|
||||
@@ -60,8 +60,8 @@ class EmailBackend(AnymailBaseBackend):
|
||||
def parse_recipient_status(self, response, payload, message):
|
||||
try:
|
||||
return response['recipient_status']
|
||||
except KeyError:
|
||||
raise AnymailAPIError('Unparsable test response')
|
||||
except KeyError as err:
|
||||
raise AnymailAPIError('Unparsable test response') from err
|
||||
|
||||
|
||||
class TestPayload(BasePayload):
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import json
|
||||
from traceback import format_exception_only
|
||||
|
||||
import six
|
||||
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||
from requests import HTTPError
|
||||
|
||||
@@ -23,14 +20,12 @@ class AnymailError(Exception):
|
||||
backend: the backend instance involved
|
||||
payload: data arg (*not* json-stringified) for the ESP send call
|
||||
response: requests.Response from the send call
|
||||
raised_from: original/wrapped Exception
|
||||
esp_name: what to call the ESP (read from backend if provided)
|
||||
"""
|
||||
self.backend = kwargs.pop('backend', None)
|
||||
self.email_message = kwargs.pop('email_message', None)
|
||||
self.payload = kwargs.pop('payload', None)
|
||||
self.status_code = kwargs.pop('status_code', None)
|
||||
self.raised_from = kwargs.pop('raised_from', None)
|
||||
self.esp_name = kwargs.pop('esp_name',
|
||||
self.backend.esp_name if self.backend else None)
|
||||
if isinstance(self, HTTPError):
|
||||
@@ -38,12 +33,12 @@ class AnymailError(Exception):
|
||||
self.response = kwargs.get('response', None)
|
||||
else:
|
||||
self.response = kwargs.pop('response', None)
|
||||
super(AnymailError, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
parts = [
|
||||
" ".join([six.text_type(arg) for arg in self.args]),
|
||||
self.describe_raised_from(),
|
||||
" ".join([str(arg) for arg in self.args]),
|
||||
self.describe_cause(),
|
||||
self.describe_send(),
|
||||
self.describe_response(),
|
||||
]
|
||||
@@ -71,7 +66,7 @@ class AnymailError(Exception):
|
||||
|
||||
# Decode response.reason to text -- borrowed from requests.Response.raise_for_status:
|
||||
reason = self.response.reason
|
||||
if isinstance(reason, six.binary_type):
|
||||
if isinstance(reason, bytes):
|
||||
try:
|
||||
reason = reason.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
@@ -88,11 +83,11 @@ class AnymailError(Exception):
|
||||
pass
|
||||
return description
|
||||
|
||||
def describe_raised_from(self):
|
||||
"""Return the original exception"""
|
||||
if self.raised_from is None:
|
||||
def describe_cause(self):
|
||||
"""Describe the original exception"""
|
||||
if self.__cause__ is None:
|
||||
return None
|
||||
return ''.join(format_exception_only(type(self.raised_from), self.raised_from)).strip()
|
||||
return ''.join(format_exception_only(type(self.__cause__), self.__cause__)).strip()
|
||||
|
||||
|
||||
class AnymailAPIError(AnymailError):
|
||||
@@ -103,7 +98,7 @@ class AnymailRequestsAPIError(AnymailAPIError, HTTPError):
|
||||
"""Exception for unsuccessful response from a requests API."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(AnymailRequestsAPIError, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.response is not None:
|
||||
self.status_code = self.response.status_code
|
||||
|
||||
@@ -114,7 +109,7 @@ class AnymailRecipientsRefused(AnymailError):
|
||||
def __init__(self, message=None, *args, **kwargs):
|
||||
if message is None:
|
||||
message = "All message recipients were rejected or invalid"
|
||||
super(AnymailRecipientsRefused, self).__init__(message, *args, **kwargs)
|
||||
super().__init__(message, *args, **kwargs)
|
||||
|
||||
|
||||
class AnymailInvalidAddress(AnymailError, ValueError):
|
||||
@@ -154,7 +149,7 @@ class AnymailSerializationError(AnymailError, TypeError):
|
||||
"Try converting it to a string or number first." % esp_name
|
||||
if orig_err is not None:
|
||||
message += "\n%s" % str(orig_err)
|
||||
super(AnymailSerializationError, self).__init__(message, *args, **kwargs)
|
||||
super().__init__(message, *args, **kwargs)
|
||||
|
||||
|
||||
class AnymailCancelSend(AnymailError):
|
||||
@@ -182,7 +177,7 @@ class AnymailImproperlyInstalled(AnymailConfigurationError, ImportError):
|
||||
message = "The %s package is required to use this ESP, but isn't installed.\n" \
|
||||
"(Be sure to use `pip install django-anymail[%s]` " \
|
||||
"with your desired ESPs.)" % (missing_package, backend)
|
||||
super(AnymailImproperlyInstalled, self).__init__(message)
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
# Warnings
|
||||
@@ -201,7 +196,7 @@ class AnymailDeprecationWarning(AnymailWarning, DeprecationWarning):
|
||||
|
||||
# Helpers
|
||||
|
||||
class _LazyError(object):
|
||||
class _LazyError:
|
||||
"""An object that sits inert unless/until used, then raises an error"""
|
||||
def __init__(self, error):
|
||||
self._error = error
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
from base64 import b64decode
|
||||
from email.message import Message
|
||||
from email.parser import BytesParser, Parser
|
||||
from email.policy import default as default_policy
|
||||
from email.utils import unquote
|
||||
|
||||
import six
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
|
||||
from ._email_compat import EmailParser, EmailBytesParser
|
||||
from .utils import angle_wrap, get_content_disposition, parse_address_list, parse_rfc2822date
|
||||
from .utils import angle_wrap, parse_address_list, parse_rfc2822date
|
||||
|
||||
|
||||
class AnymailInboundMessage(Message, object): # `object` ensures new-style class in Python 2)
|
||||
class AnymailInboundMessage(Message):
|
||||
"""
|
||||
A normalized, parsed inbound email message.
|
||||
|
||||
@@ -31,7 +31,7 @@ class AnymailInboundMessage(Message, object): # `object` ensures new-style clas
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Note: this must accept zero arguments, for use with message_from_string (email.parser)
|
||||
super(AnymailInboundMessage, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Additional attrs provided by some ESPs:
|
||||
self.envelope_sender = None
|
||||
@@ -125,14 +125,7 @@ class AnymailInboundMessage(Message, object): # `object` ensures new-style clas
|
||||
return part.get_content_text()
|
||||
return None
|
||||
|
||||
# Backport from Python 3.5 email.message.Message
|
||||
def get_content_disposition(self):
|
||||
try:
|
||||
return super(AnymailInboundMessage, self).get_content_disposition()
|
||||
except AttributeError:
|
||||
return get_content_disposition(self)
|
||||
|
||||
# Backport from Python 3.4.2 email.message.MIMEPart
|
||||
# Hoisted from email.message.MIMEPart
|
||||
def is_attachment(self):
|
||||
return self.get_content_disposition() == 'attachment'
|
||||
|
||||
@@ -148,10 +141,7 @@ class AnymailInboundMessage(Message, object): # `object` ensures new-style clas
|
||||
# (Note that self.is_multipart() misleadingly returns True in this case.)
|
||||
payload = self.get_payload()
|
||||
assert len(payload) == 1 # should be exactly one message
|
||||
try:
|
||||
return payload[0].as_bytes() # Python 3
|
||||
except AttributeError:
|
||||
return payload[0].as_string().encode('utf-8')
|
||||
return payload[0].as_bytes()
|
||||
elif maintype == 'multipart':
|
||||
# The attachment itself is multipart; the payload is a list of parts,
|
||||
# and it's not clear which one is the "content".
|
||||
@@ -199,24 +189,24 @@ class AnymailInboundMessage(Message, object): # `object` ensures new-style clas
|
||||
@classmethod
|
||||
def parse_raw_mime(cls, s):
|
||||
"""Returns a new AnymailInboundMessage parsed from str s"""
|
||||
if isinstance(s, six.text_type):
|
||||
if isinstance(s, str):
|
||||
# Avoid Python 3.x issue https://bugs.python.org/issue18271
|
||||
# (See test_inbound: test_parse_raw_mime_8bit_utf8)
|
||||
return cls.parse_raw_mime_bytes(s.encode('utf-8'))
|
||||
return EmailParser(cls).parsestr(s)
|
||||
return Parser(cls, policy=default_policy).parsestr(s)
|
||||
|
||||
@classmethod
|
||||
def parse_raw_mime_bytes(cls, b):
|
||||
"""Returns a new AnymailInboundMessage parsed from bytes b"""
|
||||
return EmailBytesParser(cls).parsebytes(b)
|
||||
return BytesParser(cls, policy=default_policy).parsebytes(b)
|
||||
|
||||
@classmethod
|
||||
def parse_raw_mime_file(cls, fp):
|
||||
"""Returns a new AnymailInboundMessage parsed from file-like object fp"""
|
||||
if isinstance(fp.read(0), six.binary_type):
|
||||
return EmailBytesParser(cls).parse(fp)
|
||||
if isinstance(fp.read(0), bytes):
|
||||
return BytesParser(cls, policy=default_policy).parse(fp)
|
||||
else:
|
||||
return EmailParser(cls).parse(fp)
|
||||
return Parser(cls, policy=default_policy).parse(fp)
|
||||
|
||||
@classmethod
|
||||
def construct(cls, raw_headers=None, from_email=None, to=None, cc=None, subject=None, headers=None,
|
||||
@@ -242,7 +232,7 @@ class AnymailInboundMessage(Message, object): # `object` ensures new-style clas
|
||||
:return: {AnymailInboundMessage}
|
||||
"""
|
||||
if raw_headers is not None:
|
||||
msg = EmailParser(cls).parsestr(raw_headers, headersonly=True)
|
||||
msg = Parser(cls, policy=default_policy).parsestr(raw_headers, headersonly=True)
|
||||
msg.set_payload(None) # headersonly forces an empty string payload, which breaks things later
|
||||
else:
|
||||
msg = cls()
|
||||
@@ -336,7 +326,7 @@ class AnymailInboundMessage(Message, object): # `object` ensures new-style clas
|
||||
if part.get_content_maintype() == 'message':
|
||||
# email.Message parses message/rfc822 parts as a "multipart" (list) payload
|
||||
# whose single item is the recursively-parsed message attachment
|
||||
if isinstance(content, six.binary_type):
|
||||
if isinstance(content, bytes):
|
||||
content = content.decode()
|
||||
payload = [cls.parse_raw_mime(content)]
|
||||
charset = None
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.core.mail import EmailMessage, EmailMultiAlternatives, make_msgid
|
||||
from .utils import UNSET
|
||||
|
||||
|
||||
class AnymailMessageMixin(object):
|
||||
class AnymailMessageMixin(EmailMessage):
|
||||
"""Mixin for EmailMessage that exposes Anymail features.
|
||||
|
||||
Use of this mixin is optional. You can always just set Anymail
|
||||
@@ -32,8 +32,7 @@ class AnymailMessageMixin(object):
|
||||
self.merge_metadata = kwargs.pop('merge_metadata', UNSET)
|
||||
self.anymail_status = AnymailStatus()
|
||||
|
||||
# noinspection PyArgumentList
|
||||
super(AnymailMessageMixin, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def attach_inline_image_file(self, path, subtype=None, idstring="img", domain=None):
|
||||
"""Add inline image from file path to an EmailMessage, and return its content id"""
|
||||
@@ -82,7 +81,7 @@ ANYMAIL_STATUSES = [
|
||||
]
|
||||
|
||||
|
||||
class AnymailRecipientStatus(object):
|
||||
class AnymailRecipientStatus:
|
||||
"""Information about an EmailMessage's send status for a single recipient"""
|
||||
|
||||
def __init__(self, message_id, status):
|
||||
@@ -90,7 +89,7 @@ class AnymailRecipientStatus(object):
|
||||
self.status = status # one of ANYMAIL_STATUSES, or None for not yet sent to ESP
|
||||
|
||||
|
||||
class AnymailStatus(object):
|
||||
class AnymailStatus:
|
||||
"""Information about an EmailMessage's send status for all recipients"""
|
||||
|
||||
def __init__(self):
|
||||
|
||||
@@ -2,19 +2,23 @@ from django.dispatch import Signal
|
||||
|
||||
|
||||
# Outbound message, before sending
|
||||
pre_send = Signal(providing_args=['message', 'esp_name'])
|
||||
# provides args: message, esp_name
|
||||
pre_send = Signal()
|
||||
|
||||
# Outbound message, after sending
|
||||
post_send = Signal(providing_args=['message', 'status', 'esp_name'])
|
||||
# provides args: message, status, esp_name
|
||||
post_send = Signal()
|
||||
|
||||
# Delivery and tracking events for sent messages
|
||||
tracking = Signal(providing_args=['event', 'esp_name'])
|
||||
# provides args: event, esp_name
|
||||
tracking = Signal()
|
||||
|
||||
# Event for receiving inbound messages
|
||||
inbound = Signal(providing_args=['event', 'esp_name'])
|
||||
# provides args: event, esp_name
|
||||
inbound = Signal()
|
||||
|
||||
|
||||
class AnymailEvent(object):
|
||||
class AnymailEvent:
|
||||
"""Base class for normalized Anymail webhook events"""
|
||||
|
||||
def __init__(self, event_type, timestamp=None, event_id=None, esp_event=None, **kwargs):
|
||||
@@ -28,7 +32,7 @@ class AnymailTrackingEvent(AnymailEvent):
|
||||
"""Normalized delivery and tracking event for sent messages"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super(AnymailTrackingEvent, self).__init__(**kwargs)
|
||||
super().__init__(**kwargs)
|
||||
self.click_url = kwargs.pop('click_url', None) # str
|
||||
self.description = kwargs.pop('description', None) # str, usually human-readable, not normalized
|
||||
self.message_id = kwargs.pop('message_id', None) # str, format may vary
|
||||
@@ -44,7 +48,7 @@ class AnymailInboundEvent(AnymailEvent):
|
||||
"""Normalized inbound message event"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super(AnymailInboundEvent, self).__init__(**kwargs)
|
||||
super().__init__(**kwargs)
|
||||
self.message = kwargs.pop('message', None) # anymail.inbound.AnymailInboundMessage
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from django.conf.urls import url
|
||||
from django.urls import re_path
|
||||
|
||||
from .webhooks.amazon_ses import AmazonSESInboundWebhookView, AmazonSESTrackingWebhookView
|
||||
from .webhooks.mailgun import MailgunInboundWebhookView, MailgunTrackingWebhookView
|
||||
@@ -12,23 +12,23 @@ from .webhooks.sparkpost import SparkPostInboundWebhookView, SparkPostTrackingWe
|
||||
|
||||
app_name = 'anymail'
|
||||
urlpatterns = [
|
||||
url(r'^amazon_ses/inbound/$', AmazonSESInboundWebhookView.as_view(), name='amazon_ses_inbound_webhook'),
|
||||
url(r'^mailgun/inbound(_mime)?/$', MailgunInboundWebhookView.as_view(), name='mailgun_inbound_webhook'),
|
||||
url(r'^mailjet/inbound/$', MailjetInboundWebhookView.as_view(), name='mailjet_inbound_webhook'),
|
||||
url(r'^postmark/inbound/$', PostmarkInboundWebhookView.as_view(), name='postmark_inbound_webhook'),
|
||||
url(r'^sendgrid/inbound/$', SendGridInboundWebhookView.as_view(), name='sendgrid_inbound_webhook'),
|
||||
url(r'^sparkpost/inbound/$', SparkPostInboundWebhookView.as_view(), name='sparkpost_inbound_webhook'),
|
||||
re_path(r'^amazon_ses/inbound/$', AmazonSESInboundWebhookView.as_view(), name='amazon_ses_inbound_webhook'),
|
||||
re_path(r'^mailgun/inbound(_mime)?/$', MailgunInboundWebhookView.as_view(), name='mailgun_inbound_webhook'),
|
||||
re_path(r'^mailjet/inbound/$', MailjetInboundWebhookView.as_view(), name='mailjet_inbound_webhook'),
|
||||
re_path(r'^postmark/inbound/$', PostmarkInboundWebhookView.as_view(), name='postmark_inbound_webhook'),
|
||||
re_path(r'^sendgrid/inbound/$', SendGridInboundWebhookView.as_view(), name='sendgrid_inbound_webhook'),
|
||||
re_path(r'^sparkpost/inbound/$', SparkPostInboundWebhookView.as_view(), name='sparkpost_inbound_webhook'),
|
||||
|
||||
url(r'^amazon_ses/tracking/$', AmazonSESTrackingWebhookView.as_view(), name='amazon_ses_tracking_webhook'),
|
||||
url(r'^mailgun/tracking/$', MailgunTrackingWebhookView.as_view(), name='mailgun_tracking_webhook'),
|
||||
url(r'^mailjet/tracking/$', MailjetTrackingWebhookView.as_view(), name='mailjet_tracking_webhook'),
|
||||
url(r'^postmark/tracking/$', PostmarkTrackingWebhookView.as_view(), name='postmark_tracking_webhook'),
|
||||
url(r'^sendgrid/tracking/$', SendGridTrackingWebhookView.as_view(), name='sendgrid_tracking_webhook'),
|
||||
url(r'^sendinblue/tracking/$', SendinBlueTrackingWebhookView.as_view(), name='sendinblue_tracking_webhook'),
|
||||
url(r'^sparkpost/tracking/$', SparkPostTrackingWebhookView.as_view(), name='sparkpost_tracking_webhook'),
|
||||
re_path(r'^amazon_ses/tracking/$', AmazonSESTrackingWebhookView.as_view(), name='amazon_ses_tracking_webhook'),
|
||||
re_path(r'^mailgun/tracking/$', MailgunTrackingWebhookView.as_view(), name='mailgun_tracking_webhook'),
|
||||
re_path(r'^mailjet/tracking/$', MailjetTrackingWebhookView.as_view(), name='mailjet_tracking_webhook'),
|
||||
re_path(r'^postmark/tracking/$', PostmarkTrackingWebhookView.as_view(), name='postmark_tracking_webhook'),
|
||||
re_path(r'^sendgrid/tracking/$', SendGridTrackingWebhookView.as_view(), name='sendgrid_tracking_webhook'),
|
||||
re_path(r'^sendinblue/tracking/$', SendinBlueTrackingWebhookView.as_view(), name='sendinblue_tracking_webhook'),
|
||||
re_path(r'^sparkpost/tracking/$', SparkPostTrackingWebhookView.as_view(), name='sparkpost_tracking_webhook'),
|
||||
|
||||
# Anymail uses a combined Mandrill webhook endpoint, to simplify Mandrill's key-validation scheme:
|
||||
url(r'^mandrill/$', MandrillCombinedWebhookView.as_view(), name='mandrill_webhook'),
|
||||
re_path(r'^mandrill/$', MandrillCombinedWebhookView.as_view(), name='mandrill_webhook'),
|
||||
# This url is maintained for backwards compatibility with earlier Anymail releases:
|
||||
url(r'^mandrill/tracking/$', MandrillCombinedWebhookView.as_view(), name='mandrill_tracking_webhook'),
|
||||
re_path(r'^mandrill/tracking/$', MandrillCombinedWebhookView.as_view(), name='mandrill_tracking_webhook'),
|
||||
]
|
||||
|
||||
108
anymail/utils.py
108
anymail/utils.py
@@ -1,33 +1,20 @@
|
||||
import base64
|
||||
import mimetypes
|
||||
from base64 import b64encode
|
||||
from datetime import datetime
|
||||
from collections.abc import Mapping, MutableMapping
|
||||
from email.mime.base import MIMEBase
|
||||
from email.utils import formatdate, getaddresses, unquote
|
||||
from time import mktime
|
||||
from email.utils import formatdate, getaddresses, parsedate_to_datetime, unquote
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import six
|
||||
from django.conf import settings
|
||||
from django.core.mail.message import DEFAULT_ATTACHMENT_MIME_TYPE, sanitize_address
|
||||
from django.utils.encoding import force_str
|
||||
from django.utils.functional import Promise
|
||||
from django.utils.timezone import get_fixed_timezone, utc
|
||||
from requests.structures import CaseInsensitiveDict
|
||||
from six.moves.urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from .exceptions import AnymailConfigurationError, AnymailInvalidAddress
|
||||
|
||||
if six.PY2:
|
||||
from django.utils.encoding import force_text as force_str
|
||||
else:
|
||||
from django.utils.encoding import force_str
|
||||
|
||||
try:
|
||||
from collections.abc import Mapping, MutableMapping # Python 3.3+
|
||||
except ImportError:
|
||||
from collections import Mapping, MutableMapping
|
||||
|
||||
|
||||
BASIC_NUMERIC_TYPES = six.integer_types + (float,) # int, float, and (on Python 2) long
|
||||
BASIC_NUMERIC_TYPES = (int, float)
|
||||
|
||||
|
||||
UNSET = type('UNSET', (object,), {}) # Used as non-None default value
|
||||
@@ -141,7 +128,7 @@ def parse_address_list(address_list, field=None):
|
||||
:return list[:class:`EmailAddress`]:
|
||||
:raises :exc:`AnymailInvalidAddress`:
|
||||
"""
|
||||
if isinstance(address_list, six.string_types) or is_lazy(address_list):
|
||||
if isinstance(address_list, str) or is_lazy(address_list):
|
||||
address_list = [address_list]
|
||||
|
||||
if address_list is None or address_list == [None]:
|
||||
@@ -162,13 +149,13 @@ def parse_address_list(address_list, field=None):
|
||||
for address in parsed:
|
||||
if address.username == '' or address.domain == '':
|
||||
# Django SMTP allows username-only emails, but they're not meaningful with an ESP
|
||||
errmsg = u"Invalid email address '{problem}' parsed from '{source}'{where}.".format(
|
||||
errmsg = "Invalid email address '{problem}' parsed from '{source}'{where}.".format(
|
||||
problem=address.addr_spec,
|
||||
source=u", ".join(address_list_strings),
|
||||
where=u" in `%s`" % field if field else "",
|
||||
source=", ".join(address_list_strings),
|
||||
where=" in `%s`" % field if field else "",
|
||||
)
|
||||
if len(parsed) > len(address_list):
|
||||
errmsg += u" (Maybe missing quotes around a display-name?)"
|
||||
errmsg += " (Maybe missing quotes around a display-name?)"
|
||||
raise AnymailInvalidAddress(errmsg)
|
||||
|
||||
return parsed
|
||||
@@ -192,7 +179,7 @@ def parse_single_address(address, field=None):
|
||||
return parsed[0]
|
||||
|
||||
|
||||
class EmailAddress(object):
|
||||
class EmailAddress:
|
||||
"""A sanitized, complete email address with easy access
|
||||
to display-name, addr-spec (email), etc.
|
||||
|
||||
@@ -249,9 +236,8 @@ class EmailAddress(object):
|
||||
This is essentially the same as :func:`email.utils.formataddr`
|
||||
on the EmailAddress's name and email properties, but uses
|
||||
Django's :func:`~django.core.mail.message.sanitize_address`
|
||||
for improved PY2/3 compatibility, consistent handling of
|
||||
encoding (a.k.a. charset), and proper handling of IDN
|
||||
domain portions.
|
||||
for consistent handling of encoding (a.k.a. charset) and
|
||||
proper handling of IDN domain portions.
|
||||
|
||||
:param str|None encoding:
|
||||
the charset to use for the display-name portion;
|
||||
@@ -264,7 +250,7 @@ class EmailAddress(object):
|
||||
return self.address
|
||||
|
||||
|
||||
class Attachment(object):
|
||||
class Attachment:
|
||||
"""A normalized EmailMessage.attachments item with additional functionality
|
||||
|
||||
Normalized to have these properties:
|
||||
@@ -289,14 +275,10 @@ class Attachment(object):
|
||||
self.name = attachment.get_filename()
|
||||
self.content = attachment.get_payload(decode=True)
|
||||
if self.content is None:
|
||||
if hasattr(attachment, 'as_bytes'):
|
||||
self.content = attachment.as_bytes()
|
||||
else:
|
||||
# Python 2.7 fallback
|
||||
self.content = attachment.as_string().encode(self.encoding)
|
||||
self.mimetype = attachment.get_content_type()
|
||||
|
||||
content_disposition = get_content_disposition(attachment)
|
||||
content_disposition = attachment.get_content_disposition()
|
||||
if content_disposition == 'inline' or (not content_disposition and 'Content-ID' in attachment):
|
||||
self.inline = True
|
||||
self.content_id = attachment["Content-ID"] # probably including the <...>
|
||||
@@ -319,23 +301,11 @@ class Attachment(object):
|
||||
def b64content(self):
|
||||
"""Content encoded as a base64 ascii string"""
|
||||
content = self.content
|
||||
if isinstance(content, six.text_type):
|
||||
if isinstance(content, str):
|
||||
content = content.encode(self.encoding)
|
||||
return b64encode(content).decode("ascii")
|
||||
|
||||
|
||||
def get_content_disposition(mimeobj):
|
||||
"""Return the message's content-disposition if it exists, or None.
|
||||
|
||||
Backport of py3.5 :func:`~email.message.Message.get_content_disposition`
|
||||
"""
|
||||
value = mimeobj.get('content-disposition')
|
||||
if value is None:
|
||||
return None
|
||||
# _splitparam(value)[0].lower() :
|
||||
return str(value).partition(';')[0].strip().lower()
|
||||
|
||||
|
||||
def get_anymail_setting(name, default=UNSET, esp_name=None, kwargs=None, allow_bare=False):
|
||||
"""Returns an Anymail option from kwargs or Django settings.
|
||||
|
||||
@@ -388,7 +358,7 @@ def get_anymail_setting(name, default=UNSET, esp_name=None, kwargs=None, allow_b
|
||||
if allow_bare:
|
||||
message += " or %s" % setting
|
||||
message += " in your Django settings"
|
||||
raise AnymailConfigurationError(message)
|
||||
raise AnymailConfigurationError(message) from None
|
||||
else:
|
||||
return default
|
||||
|
||||
@@ -442,26 +412,11 @@ def querydict_getfirst(qdict, field, default=UNSET):
|
||||
return qdict[field] # raise appropriate KeyError
|
||||
|
||||
|
||||
EPOCH = datetime(1970, 1, 1, tzinfo=utc)
|
||||
|
||||
|
||||
def timestamp(dt):
|
||||
"""Return the unix timestamp (seconds past the epoch) for datetime dt"""
|
||||
# This is the equivalent of Python 3.3's datetime.timestamp
|
||||
try:
|
||||
return dt.timestamp()
|
||||
except AttributeError:
|
||||
if dt.tzinfo is None:
|
||||
return mktime(dt.timetuple())
|
||||
else:
|
||||
return (dt - EPOCH).total_seconds()
|
||||
|
||||
|
||||
def rfc2822date(dt):
|
||||
"""Turn a datetime into a date string as specified in RFC 2822."""
|
||||
# This is almost the equivalent of Python 3.3's email.utils.format_datetime,
|
||||
# This is almost the equivalent of Python's email.utils.format_datetime,
|
||||
# but treats naive datetimes as local rather than "UTC with no information ..."
|
||||
timeval = timestamp(dt)
|
||||
timeval = dt.timestamp()
|
||||
return formatdate(timeval, usegmt=True)
|
||||
|
||||
|
||||
@@ -480,7 +435,7 @@ def angle_wrap(s):
|
||||
def is_lazy(obj):
|
||||
"""Return True if obj is a Django lazy object."""
|
||||
# See django.utils.functional.lazy. (This appears to be preferred
|
||||
# to checking for `not isinstance(obj, six.text_type)`.)
|
||||
# to checking for `not isinstance(obj, str)`.)
|
||||
return isinstance(obj, Promise)
|
||||
|
||||
|
||||
@@ -490,7 +445,7 @@ def force_non_lazy(obj):
|
||||
(Similar to django.utils.encoding.force_text, but doesn't alter non-text objects.)
|
||||
"""
|
||||
if is_lazy(obj):
|
||||
return six.text_type(obj)
|
||||
return str(obj)
|
||||
|
||||
return obj
|
||||
|
||||
@@ -541,27 +496,6 @@ def get_request_uri(request):
|
||||
return url
|
||||
|
||||
|
||||
try:
|
||||
from email.utils import parsedate_to_datetime # Python 3.3+
|
||||
except ImportError:
|
||||
from email.utils import parsedate_tz
|
||||
|
||||
# Backport Python 3.3+ email.utils.parsedate_to_datetime
|
||||
def parsedate_to_datetime(s):
|
||||
# *dtuple, tz = _parsedate_tz(data)
|
||||
dtuple = parsedate_tz(s)
|
||||
tz = dtuple[-1]
|
||||
# if tz is None: # parsedate_tz returns 0 for "-0000"
|
||||
if tz is None or (tz == 0 and "-0000" in s):
|
||||
# "... indicates that the date-time contains no information
|
||||
# about the local time zone" (RFC 2822 #3.3)
|
||||
return datetime(*dtuple[:6])
|
||||
else:
|
||||
# tzinfo = datetime.timezone(datetime.timedelta(seconds=tz)) # Python 3.2+ only
|
||||
tzinfo = get_fixed_timezone(tz // 60) # don't use timedelta (avoid Django bug #28739)
|
||||
return datetime(*dtuple[:6], tzinfo=tzinfo)
|
||||
|
||||
|
||||
def parse_rfc2822date(s):
|
||||
"""Parses an RFC-2822 formatted date string into a datetime.datetime
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from ..exceptions import (
|
||||
_LazyError)
|
||||
from ..inbound import AnymailInboundMessage
|
||||
from ..signals import AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason, inbound, tracking
|
||||
from ..utils import combine, get_anymail_setting, getfirst
|
||||
from ..utils import get_anymail_setting, getfirst
|
||||
|
||||
try:
|
||||
import boto3
|
||||
@@ -37,7 +37,7 @@ class AmazonSESBaseWebhookView(AnymailBaseWebhookView):
|
||||
"auto_confirm_sns_subscriptions", esp_name=self.esp_name, kwargs=kwargs, default=True)
|
||||
# boto3 params for connecting to S3 (inbound downloads) and SNS (auto-confirm subscriptions):
|
||||
self.session_params, self.client_params = _get_anymail_boto3_params(kwargs=kwargs)
|
||||
super(AmazonSESBaseWebhookView, self).__init__(**kwargs)
|
||||
super().__init__(**kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _parse_sns_message(request):
|
||||
@@ -47,7 +47,7 @@ class AmazonSESBaseWebhookView(AnymailBaseWebhookView):
|
||||
body = request.body.decode(request.encoding or 'utf-8')
|
||||
request._sns_message = json.loads(body)
|
||||
except (TypeError, ValueError, UnicodeDecodeError) as err:
|
||||
raise AnymailAPIError("Malformed SNS message body %r" % request.body, raised_from=err)
|
||||
raise AnymailAPIError("Malformed SNS message body %r" % request.body) from err
|
||||
return request._sns_message
|
||||
|
||||
def validate_request(self, request):
|
||||
@@ -80,7 +80,7 @@ class AmazonSESBaseWebhookView(AnymailBaseWebhookView):
|
||||
response = HttpResponse(status=401)
|
||||
response["WWW-Authenticate"] = 'Basic realm="Anymail WEBHOOK_SECRET"'
|
||||
return response
|
||||
return super(AmazonSESBaseWebhookView, self).post(request, *args, **kwargs)
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
def parse_events(self, request):
|
||||
# request *has* been validated by now
|
||||
@@ -91,11 +91,11 @@ class AmazonSESBaseWebhookView(AnymailBaseWebhookView):
|
||||
message_string = sns_message.get("Message")
|
||||
try:
|
||||
ses_event = json.loads(message_string)
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError) as err:
|
||||
if message_string == "Successfully validated SNS topic for Amazon SES event publishing.":
|
||||
pass # this Notification is generated after SubscriptionConfirmation
|
||||
else:
|
||||
raise AnymailAPIError("Unparsable SNS Message %r" % message_string)
|
||||
raise AnymailAPIError("Unparsable SNS Message %r" % message_string) from err
|
||||
else:
|
||||
events = self.esp_to_anymail_events(ses_event, sns_message)
|
||||
elif sns_type == "SubscriptionConfirmation":
|
||||
@@ -258,8 +258,7 @@ class AmazonSESTrackingWebhookView(AmazonSESBaseWebhookView):
|
||||
)
|
||||
|
||||
return [
|
||||
# AnymailTrackingEvent(**common_props, **recipient_props) # Python 3.5+ (PEP-448 syntax)
|
||||
AnymailTrackingEvent(**combine(common_props, recipient_props))
|
||||
AnymailTrackingEvent(**common_props, **recipient_props)
|
||||
for recipient_props in per_recipient_props
|
||||
]
|
||||
|
||||
@@ -306,7 +305,7 @@ class AmazonSESInboundWebhookView(AmazonSESBaseWebhookView):
|
||||
raise AnymailBotoClientAPIError(
|
||||
"Anymail AmazonSESInboundWebhookView couldn't download S3 object '{bucket_name}:{object_key}'"
|
||||
"".format(bucket_name=bucket_name, object_key=object_key),
|
||||
raised_from=err)
|
||||
client_error=err) from err
|
||||
finally:
|
||||
content.close()
|
||||
else:
|
||||
@@ -341,13 +340,9 @@ class AmazonSESInboundWebhookView(AmazonSESBaseWebhookView):
|
||||
|
||||
class AnymailBotoClientAPIError(AnymailAPIError, ClientError):
|
||||
"""An AnymailAPIError that is also a Boto ClientError"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
raised_from = kwargs.pop('raised_from')
|
||||
assert isinstance(raised_from, ClientError)
|
||||
assert len(kwargs) == 0 # can't support other kwargs
|
||||
def __init__(self, *args, client_error):
|
||||
assert isinstance(client_error, ClientError)
|
||||
# init self as boto ClientError (which doesn't cooperatively subclass):
|
||||
super(AnymailBotoClientAPIError, self).__init__(
|
||||
error_response=raised_from.response, operation_name=raised_from.operation_name)
|
||||
super().__init__(error_response=client_error.response, operation_name=client_error.operation_name)
|
||||
# emulate AnymailError init:
|
||||
self.args = args
|
||||
self.raised_from = raised_from
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import warnings
|
||||
|
||||
import six
|
||||
from django.http import HttpResponse
|
||||
from django.utils.crypto import constant_time_compare
|
||||
from django.utils.decorators import method_decorator
|
||||
@@ -11,62 +10,21 @@ from ..exceptions import AnymailInsecureWebhookWarning, AnymailWebhookValidation
|
||||
from ..utils import get_anymail_setting, collect_all_methods, get_request_basic_auth
|
||||
|
||||
|
||||
class AnymailBasicAuthMixin(object):
|
||||
"""Implements webhook basic auth as mixin to AnymailBaseWebhookView."""
|
||||
|
||||
# Whether to warn if basic auth is not configured.
|
||||
# For most ESPs, basic auth is the only webhook security,
|
||||
# so the default is True. Subclasses can set False if
|
||||
# they enforce other security (like signed webhooks).
|
||||
warn_if_no_basic_auth = True
|
||||
|
||||
# List of allowable HTTP basic-auth 'user:pass' strings.
|
||||
basic_auth = None # (Declaring class attr allows override by kwargs in View.as_view.)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.basic_auth = get_anymail_setting('webhook_secret', default=[],
|
||||
kwargs=kwargs) # no esp_name -- auth is shared between ESPs
|
||||
|
||||
# Allow a single string:
|
||||
if isinstance(self.basic_auth, six.string_types):
|
||||
self.basic_auth = [self.basic_auth]
|
||||
if self.warn_if_no_basic_auth and len(self.basic_auth) < 1:
|
||||
warnings.warn(
|
||||
"Your Anymail webhooks are insecure and open to anyone on the web. "
|
||||
"You should set WEBHOOK_SECRET in your ANYMAIL settings. "
|
||||
"See 'Securing webhooks' in the Anymail docs.",
|
||||
AnymailInsecureWebhookWarning)
|
||||
# noinspection PyArgumentList
|
||||
super(AnymailBasicAuthMixin, self).__init__(**kwargs)
|
||||
|
||||
def validate_request(self, request):
|
||||
"""If configured for webhook basic auth, validate request has correct auth."""
|
||||
if self.basic_auth:
|
||||
request_auth = get_request_basic_auth(request)
|
||||
# Use constant_time_compare to avoid timing attack on basic auth. (It's OK that any()
|
||||
# can terminate early: we're not trying to protect how many auth strings are allowed,
|
||||
# just the contents of each individual auth string.)
|
||||
auth_ok = any(constant_time_compare(request_auth, allowed_auth)
|
||||
for allowed_auth in self.basic_auth)
|
||||
if not auth_ok:
|
||||
# noinspection PyUnresolvedReferences
|
||||
raise AnymailWebhookValidationFailure(
|
||||
"Missing or invalid basic auth in Anymail %s webhook" % self.esp_name)
|
||||
|
||||
|
||||
# Mixin note: Django's View.__init__ doesn't cooperate with chaining,
|
||||
# so all mixins that need __init__ must appear before View in MRO.
|
||||
class AnymailBaseWebhookView(AnymailBasicAuthMixin, View):
|
||||
"""Base view for processing ESP event webhooks
|
||||
class AnymailCoreWebhookView(View):
|
||||
"""Common view for processing ESP event webhooks
|
||||
|
||||
ESP-specific implementations should subclass
|
||||
and implement parse_events. They may also
|
||||
want to implement validate_request
|
||||
ESP-specific implementations will need to implement parse_events.
|
||||
|
||||
ESP-specific implementations should generally subclass
|
||||
AnymailBaseWebhookView instead, to pick up basic auth.
|
||||
They may also want to implement validate_request
|
||||
if additional security is available.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super(AnymailBaseWebhookView, self).__init__(**kwargs)
|
||||
super().__init__(**kwargs)
|
||||
self.validators = collect_all_methods(self.__class__, 'validate_request')
|
||||
|
||||
# Subclass implementation:
|
||||
@@ -106,7 +64,7 @@ class AnymailBaseWebhookView(AnymailBasicAuthMixin, View):
|
||||
|
||||
@method_decorator(csrf_exempt)
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
return super(AnymailBaseWebhookView, self).dispatch(request, *args, **kwargs)
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def head(self, request, *args, **kwargs):
|
||||
# Some ESPs verify the webhook with a HEAD request at configuration time
|
||||
@@ -143,3 +101,51 @@ class AnymailBaseWebhookView(AnymailBasicAuthMixin, View):
|
||||
"""
|
||||
raise NotImplementedError("%s.%s must declare esp_name class attr" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
|
||||
|
||||
class AnymailBasicAuthMixin(AnymailCoreWebhookView):
|
||||
"""Implements webhook basic auth as mixin to AnymailCoreWebhookView."""
|
||||
|
||||
# Whether to warn if basic auth is not configured.
|
||||
# For most ESPs, basic auth is the only webhook security,
|
||||
# so the default is True. Subclasses can set False if
|
||||
# they enforce other security (like signed webhooks).
|
||||
warn_if_no_basic_auth = True
|
||||
|
||||
# List of allowable HTTP basic-auth 'user:pass' strings.
|
||||
basic_auth = None # (Declaring class attr allows override by kwargs in View.as_view.)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.basic_auth = get_anymail_setting('webhook_secret', default=[],
|
||||
kwargs=kwargs) # no esp_name -- auth is shared between ESPs
|
||||
|
||||
# Allow a single string:
|
||||
if isinstance(self.basic_auth, str):
|
||||
self.basic_auth = [self.basic_auth]
|
||||
if self.warn_if_no_basic_auth and len(self.basic_auth) < 1:
|
||||
warnings.warn(
|
||||
"Your Anymail webhooks are insecure and open to anyone on the web. "
|
||||
"You should set WEBHOOK_SECRET in your ANYMAIL settings. "
|
||||
"See 'Securing webhooks' in the Anymail docs.",
|
||||
AnymailInsecureWebhookWarning)
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def validate_request(self, request):
|
||||
"""If configured for webhook basic auth, validate request has correct auth."""
|
||||
if self.basic_auth:
|
||||
request_auth = get_request_basic_auth(request)
|
||||
# Use constant_time_compare to avoid timing attack on basic auth. (It's OK that any()
|
||||
# can terminate early: we're not trying to protect how many auth strings are allowed,
|
||||
# just the contents of each individual auth string.)
|
||||
auth_ok = any(constant_time_compare(request_auth, allowed_auth)
|
||||
for allowed_auth in self.basic_auth)
|
||||
if not auth_ok:
|
||||
raise AnymailWebhookValidationFailure(
|
||||
"Missing or invalid basic auth in Anymail %s webhook" % self.esp_name)
|
||||
|
||||
|
||||
class AnymailBaseWebhookView(AnymailBasicAuthMixin, AnymailCoreWebhookView):
|
||||
"""
|
||||
Abstract base class for most webhook views, enforcing HTTP basic auth security
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -30,11 +30,11 @@ class MailgunBaseWebhookView(AnymailBaseWebhookView):
|
||||
kwargs=kwargs, allow_bare=True, default=None)
|
||||
webhook_signing_key = get_anymail_setting('webhook_signing_key', esp_name=self.esp_name,
|
||||
kwargs=kwargs, default=UNSET if api_key is None else api_key)
|
||||
self.webhook_signing_key = webhook_signing_key.encode('ascii') # hmac.new requires bytes key in python 3
|
||||
super(MailgunBaseWebhookView, self).__init__(**kwargs)
|
||||
self.webhook_signing_key = webhook_signing_key.encode('ascii') # hmac.new requires bytes key
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def validate_request(self, request):
|
||||
super(MailgunBaseWebhookView, self).validate_request(request) # first check basic auth if enabled
|
||||
super().validate_request(request) # first check basic auth if enabled
|
||||
if request.content_type == "application/json":
|
||||
# New-style webhook: json payload with separate signature block
|
||||
try:
|
||||
@@ -45,8 +45,7 @@ class MailgunBaseWebhookView(AnymailBaseWebhookView):
|
||||
signature = signature_block['signature']
|
||||
except (KeyError, ValueError, UnicodeDecodeError) as err:
|
||||
raise AnymailWebhookValidationFailure(
|
||||
"Mailgun webhook called with invalid payload format",
|
||||
raised_from=err)
|
||||
"Mailgun webhook called with invalid payload format") from err
|
||||
else:
|
||||
# Legacy webhook: signature fields are interspersed with other POST data
|
||||
try:
|
||||
@@ -54,9 +53,10 @@ class MailgunBaseWebhookView(AnymailBaseWebhookView):
|
||||
# (Fortunately, Django QueryDict is specced to return the last value.)
|
||||
token = request.POST['token']
|
||||
timestamp = request.POST['timestamp']
|
||||
signature = str(request.POST['signature']) # force to same type as hexdigest() (for python2)
|
||||
except KeyError:
|
||||
raise AnymailWebhookValidationFailure("Mailgun webhook called without required security fields")
|
||||
signature = request.POST['signature']
|
||||
except KeyError as err:
|
||||
raise AnymailWebhookValidationFailure(
|
||||
"Mailgun webhook called without required security fields") from err
|
||||
|
||||
expected_signature = hmac.new(key=self.webhook_signing_key, msg='{}{}'.format(timestamp, token).encode('ascii'),
|
||||
digestmod=hashlib.sha256).hexdigest()
|
||||
|
||||
@@ -7,14 +7,14 @@ from base64 import b64encode
|
||||
from django.utils.crypto import constant_time_compare
|
||||
from django.utils.timezone import utc
|
||||
|
||||
from .base import AnymailBaseWebhookView
|
||||
from .base import AnymailBaseWebhookView, AnymailCoreWebhookView
|
||||
from ..exceptions import AnymailWebhookValidationFailure
|
||||
from ..inbound import AnymailInboundMessage
|
||||
from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType
|
||||
from ..utils import get_anymail_setting, getfirst, get_request_uri
|
||||
|
||||
|
||||
class MandrillSignatureMixin(object):
|
||||
class MandrillSignatureMixin(AnymailCoreWebhookView):
|
||||
"""Validates Mandrill webhook signature"""
|
||||
|
||||
# These can be set from kwargs in View.as_view, or pulled from settings in init:
|
||||
@@ -22,29 +22,26 @@ class MandrillSignatureMixin(object):
|
||||
webhook_url = None # optional; defaults to actual url used
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
# noinspection PyUnresolvedReferences
|
||||
esp_name = self.esp_name
|
||||
# webhook_key is required for POST, but not for HEAD when Mandrill validates webhook url.
|
||||
# Defer "missing setting" error until we actually try to use it in the POST...
|
||||
webhook_key = get_anymail_setting('webhook_key', esp_name=esp_name, default=None,
|
||||
kwargs=kwargs, allow_bare=True)
|
||||
if webhook_key is not None:
|
||||
self.webhook_key = webhook_key.encode('ascii') # hmac.new requires bytes key in python 3
|
||||
self.webhook_key = webhook_key.encode('ascii') # hmac.new requires bytes key
|
||||
self.webhook_url = get_anymail_setting('webhook_url', esp_name=esp_name, default=None,
|
||||
kwargs=kwargs, allow_bare=True)
|
||||
# noinspection PyArgumentList
|
||||
super(MandrillSignatureMixin, self).__init__(**kwargs)
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def validate_request(self, request):
|
||||
if self.webhook_key is None:
|
||||
# issue deferred "missing setting" error (re-call get-setting without a default)
|
||||
# noinspection PyUnresolvedReferences
|
||||
get_anymail_setting('webhook_key', esp_name=self.esp_name, allow_bare=True)
|
||||
|
||||
try:
|
||||
signature = request.META["HTTP_X_MANDRILL_SIGNATURE"]
|
||||
except KeyError:
|
||||
raise AnymailWebhookValidationFailure("X-Mandrill-Signature header missing from webhook POST")
|
||||
raise AnymailWebhookValidationFailure("X-Mandrill-Signature header missing from webhook POST") from None
|
||||
|
||||
# Mandrill signs the exact URL (including basic auth, if used) plus the sorted POST params:
|
||||
url = self.webhook_url or get_request_uri(request)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from email.parser import BytesParser
|
||||
from email.policy import default as default_policy
|
||||
|
||||
from django.utils.timezone import utc
|
||||
|
||||
from .base import AnymailBaseWebhookView
|
||||
from .._email_compat import EmailBytesParser
|
||||
from ..inbound import AnymailInboundMessage
|
||||
from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason
|
||||
from ..signals import AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason, inbound, tracking
|
||||
|
||||
|
||||
class SendGridTrackingWebhookView(AnymailBaseWebhookView):
|
||||
@@ -204,7 +205,7 @@ class SendGridInboundWebhookView(AnymailBaseWebhookView):
|
||||
b"\r\n\r\n",
|
||||
request.body
|
||||
])
|
||||
parsed_parts = EmailBytesParser().parsebytes(raw_data).get_payload()
|
||||
parsed_parts = BytesParser(policy=default_policy).parsebytes(raw_data).get_payload()
|
||||
for part in parsed_parts:
|
||||
name = part.get_param('name', header='content-disposition')
|
||||
if name == 'text':
|
||||
|
||||
@@ -40,7 +40,8 @@ class SparkPostBaseWebhookView(AnymailBaseWebhookView):
|
||||
# Empty event (SparkPost sometimes sends as a "ping")
|
||||
event_class = event = None
|
||||
else:
|
||||
raise TypeError("Invalid SparkPost webhook event has multiple event classes: %r" % raw_event)
|
||||
raise TypeError(
|
||||
"Invalid SparkPost webhook event has multiple event classes: %r" % raw_event) from None
|
||||
return event_class, event, raw_event
|
||||
|
||||
def esp_to_anymail_event(self, event_class, event, raw_event):
|
||||
|
||||
27
docs/conf.py
27
docs/conf.py
@@ -1,5 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Anymail documentation build configuration file, created by
|
||||
# sphinx-quickstart
|
||||
#
|
||||
@@ -50,9 +48,9 @@ source_suffix = '.rst'
|
||||
master_doc = 'index'
|
||||
|
||||
# General information about the project.
|
||||
project = u'Anymail'
|
||||
project = 'Anymail'
|
||||
# noinspection PyShadowingBuiltins
|
||||
copyright = u'Anymail contributors (see AUTHORS.txt)'
|
||||
copyright = 'Anymail contributors (see AUTHORS.txt)'
|
||||
|
||||
# The version info for the project you're documenting, acts as replacement for
|
||||
# |version| and |release|, also used in various other places throughout the
|
||||
@@ -203,8 +201,8 @@ latex_elements = {
|
||||
# Grouping the document tree into LaTeX files. List of tuples
|
||||
# (source start file, target name, title, author, documentclass [howto/manual]).
|
||||
latex_documents = [
|
||||
('index', 'Anymail.tex', u'Anymail Documentation',
|
||||
u'Anymail contributors (see AUTHORS.txt)', 'manual'),
|
||||
('index', 'Anymail.tex', 'Anymail Documentation',
|
||||
'Anymail contributors (see AUTHORS.txt)', 'manual'),
|
||||
]
|
||||
|
||||
# The name of an image file (relative to this directory) to place at the top of
|
||||
@@ -233,8 +231,8 @@ latex_documents = [
|
||||
# One entry per manual page. List of tuples
|
||||
# (source start file, name, description, authors, manual section).
|
||||
man_pages = [
|
||||
('index', 'anymail', u'Anymail Documentation',
|
||||
[u'Anymail contributors (see AUTHORS.txt)'], 1)
|
||||
('index', 'anymail', 'Anymail Documentation',
|
||||
['Anymail contributors (see AUTHORS.txt)'], 1)
|
||||
]
|
||||
|
||||
# If true, show URL addresses after external links.
|
||||
@@ -247,8 +245,8 @@ man_pages = [
|
||||
# (source start file, target name, title, author,
|
||||
# dir menu entry, description, category)
|
||||
texinfo_documents = [
|
||||
('index', 'Anymail', u'Anymail Documentation',
|
||||
u'Anymail contributors (see AUTHORS.txt)', 'Anymail', 'Multi-ESP transactional email for Django.',
|
||||
('index', 'Anymail', 'Anymail Documentation',
|
||||
'Anymail contributors (see AUTHORS.txt)', 'Anymail', 'Multi-ESP transactional email for Django.',
|
||||
'Miscellaneous'),
|
||||
]
|
||||
|
||||
@@ -270,14 +268,9 @@ extlinks = {
|
||||
# -- Options for Intersphinx ------------------------------------------------
|
||||
|
||||
intersphinx_mapping = {
|
||||
'python': ('https://docs.python.org/3.6', None),
|
||||
'python': ('https://docs.python.org/3.7', None),
|
||||
'django': ('https://docs.djangoproject.com/en/stable/', 'https://docs.djangoproject.com/en/stable/_objects/'),
|
||||
# Requests docs may be moving (Sep 2019):
|
||||
# see https://github.com/psf/requests/issues/5212
|
||||
# and https://github.com/psf/requests/issues/5214
|
||||
'requests': ('https://docs.python-requests.org/en/latest/',
|
||||
('https://docs.python-requests.org/en/latest/objects.inv',
|
||||
'https://requests.kennethreitz.org/en/latest/objects.inv')),
|
||||
'requests': ('https://requests.readthedocs.io/en/stable/', None),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ and Python versions. Tests are run at least once a week, to check whether ESP AP
|
||||
and other dependencies have changed out from under Anymail.
|
||||
|
||||
For local development, the recommended test command is
|
||||
:shell:`tox -e django22-py37-all,django111-py27-all,lint`, which tests a representative
|
||||
:shell:`tox -e django31-py38-all,django20-py35-all,lint`, which tests a representative
|
||||
combination of Python and Django versions. It also runs :pypi:`flake8` and other
|
||||
code-style checkers. Some other test options are covered below, but using this
|
||||
tox command catches most problems, and is a good pre-pull-request check.
|
||||
@@ -98,16 +98,16 @@ Or:
|
||||
$ python runtests.py tests.test_mailgun_backend tests.test_mailgun_webhooks
|
||||
|
||||
Or to test against multiple versions of Python and Django all at once, use :pypi:`tox`.
|
||||
You'll need at least Python 2.7 and Python 3.6 available. (If your system doesn't come
|
||||
with those, `pyenv`_ is a helpful way to install and manage multiple Python versions.)
|
||||
You'll need some version of Python 3 available. (If your system doesn't come
|
||||
with that, `pyenv`_ is a helpful way to install and manage multiple Python versions.)
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ pip install tox # (if you haven't already)
|
||||
$ tox -e django21-py36-all,django111-py27-all,lint # test recommended environments
|
||||
$ tox -e django31-py38-all,django20-py35-all,lint # test recommended environments
|
||||
|
||||
## you can also run just some test cases, e.g.:
|
||||
$ tox -e django21-py36-all,django111-py27-all tests.test_mailgun_backend tests.test_utils
|
||||
$ tox -e django31-py38-all,django20-py35-all tests.test_mailgun_backend tests.test_utils
|
||||
|
||||
## to test more Python/Django versions:
|
||||
$ tox --parallel auto # ALL 20+ envs! (in parallel if possible)
|
||||
@@ -121,7 +121,7 @@ API keys or other settings. For example:
|
||||
|
||||
$ export MAILGUN_TEST_API_KEY='your-Mailgun-API-key'
|
||||
$ export MAILGUN_TEST_DOMAIN='mail.example.com' # sending domain for that API key
|
||||
$ tox -e django21-py36-all tests.test_mailgun_integration
|
||||
$ tox -e django31-py38-all tests.test_mailgun_integration
|
||||
|
||||
Check the ``*_integration_tests.py`` files in the `tests source`_ to see which variables
|
||||
are required for each ESP. Depending on the supported features, the integration tests for
|
||||
@@ -180,7 +180,7 @@ Anymail's Sphinx conf sets up a few enhancements you can use in the docs:
|
||||
|
||||
.. _Django's added markup:
|
||||
https://docs.djangoproject.com/en/stable/internals/contributing/writing-documentation/#django-specific-markup
|
||||
.. _extlinks: http://www.sphinx-doc.org/en/stable/ext/extlinks.html
|
||||
.. _intersphinx: http://www.sphinx-doc.org/en/master/ext/intersphinx.html
|
||||
.. _extlinks: https://www.sphinx-doc.org/en/stable/usage/extensions/extlinks.html
|
||||
.. _intersphinx: https://www.sphinx-doc.org/en/stable/usage/extensions/intersphinx.html
|
||||
.. _Writing Documentation:
|
||||
https://docs.djangoproject.com/en/stable/internals/contributing/writing-documentation/
|
||||
|
||||
@@ -425,9 +425,9 @@ The event's :attr:`~anymail.signals.AnymailTrackingEvent.esp_event` field will b
|
||||
the parsed `Mailgun webhook payload`_ as a Python `dict` with ``"signature"`` and
|
||||
``"event-data"`` keys.
|
||||
|
||||
Anymail uses Mailgun's webhook `token` as its normalized
|
||||
Anymail uses Mailgun's webhook ``token`` as its normalized
|
||||
:attr:`~anymail.signals.AnymailTrackingEvent.event_id`, rather than Mailgun's
|
||||
event-data `id` (which is only guaranteed to be unique during a single day).
|
||||
event-data ``id`` (which is only guaranteed to be unique during a single day).
|
||||
If you need the event-data id, it can be accessed in your webhook handler as
|
||||
``event.esp_event["event-data"]["id"]``. (This can be helpful for working with
|
||||
Mailgun's other event APIs.)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Mandrill
|
||||
========
|
||||
|
||||
Anymail integrates with the `Mandrill <http://mandrill.com/>`__
|
||||
Anymail integrates with the `Mandrill <https://mandrill.com/>`__
|
||||
transactional email service from MailChimp.
|
||||
|
||||
.. note:: **Limited Support for Mandrill**
|
||||
|
||||
@@ -29,9 +29,10 @@ often help you pinpoint the problem...
|
||||
**Double-check common issues**
|
||||
|
||||
* Did you add any required settings for your ESP to the `ANYMAIL` dict in your
|
||||
settings.py? (E.g., ``"SENDGRID_API_KEY"`` for SendGrid.) See :ref:`supported-esps`.
|
||||
settings.py? (E.g., ``"SENDGRID_API_KEY"`` for SendGrid.) Check the instructions
|
||||
for the ESP you're using under :ref:`supported-esps`.
|
||||
* Did you add ``'anymail'`` to the list of :setting:`INSTALLED_APPS` in settings.py?
|
||||
* Are you using a valid from address? Django's default is "webmaster@localhost",
|
||||
* Are you using a valid *from* address? Django's default is "webmaster@localhost",
|
||||
which most ESPs reject. Either specify the ``from_email`` explicitly on every message
|
||||
you send, or add :setting:`DEFAULT_FROM_EMAIL` to your settings.py.
|
||||
|
||||
@@ -61,8 +62,8 @@ Support
|
||||
|
||||
If you've gone through the troubleshooting above and still aren't sure what's wrong,
|
||||
the Anymail community is happy to help. Anymail is supported and maintained by the
|
||||
people who use it---like you! (The vast majority of Anymail contributors volunteer
|
||||
their time, and are not employees of any ESP.)
|
||||
people who use it---like you! (Anymail contributors volunteer their time, and are
|
||||
not employees of any ESP.)
|
||||
|
||||
Here's how to contact the Anymail community:
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ If you didn't set up webhooks when first installing Anymail, you'll need to
|
||||
(You should also review :ref:`securing-webhooks`.)
|
||||
|
||||
Once you've enabled webhooks, Anymail will send a ``anymail.signals.inbound``
|
||||
custom Django :mod:`signal <django.dispatch>` for each ESP inbound message it receives.
|
||||
custom Django :doc:`signal <django:topics/signals>` for each ESP inbound message it receives.
|
||||
You can connect your own receiver function to this signal for further processing.
|
||||
(This is very much like how Anymail handles :ref:`status tracking <event-tracking>`
|
||||
events for sent messages. Inbound events just use a different signal receiver
|
||||
@@ -73,7 +73,7 @@ invoke your signal receiver once, separately, for each message in the batch.
|
||||
:ref:`user-supplied content security <django:user-uploaded-content-security>`.
|
||||
|
||||
.. _using python-magic:
|
||||
http://blog.hayleyanderson.us/2015/07/18/validating-file-types-in-django/
|
||||
https://blog.hayleyanderson.us/2015/07/18/validating-file-types-in-django/
|
||||
|
||||
|
||||
.. _inbound-event:
|
||||
@@ -90,7 +90,7 @@ Normalized inbound event
|
||||
.. attribute:: message
|
||||
|
||||
An :class:`~anymail.inbound.AnymailInboundMessage` representing the email
|
||||
that was received. Most of what you're interested in will be on this `message`
|
||||
that was received. Most of what you're interested in will be on this :attr:`!message`
|
||||
attribute. See the full details :ref:`below <inbound-message>`.
|
||||
|
||||
.. attribute:: event_type
|
||||
@@ -290,8 +290,6 @@ Handling Inbound Attachments
|
||||
|
||||
Anymail converts each inbound attachment to a specialized MIME object with
|
||||
additional methods for handling attachments and integrating with Django.
|
||||
It also backports some helpful MIME methods from newer versions of Python
|
||||
to all versions supported by Anymail.
|
||||
|
||||
The attachment objects in an AnymailInboundMessage's
|
||||
:attr:`~AnymailInboundMessage.attachments` list and
|
||||
@@ -346,8 +344,6 @@ have these methods:
|
||||
.. method:: is_attachment()
|
||||
|
||||
Returns `True` for a (non-inline) attachment, `False` otherwise.
|
||||
(Anymail back-ports Python 3.4.2's :meth:`~email.message.EmailMessage.is_attachment` method
|
||||
to all supported versions.)
|
||||
|
||||
.. method:: is_inline_attachment()
|
||||
|
||||
@@ -360,9 +356,6 @@ have these methods:
|
||||
:mailheader:`Content-Disposition` header. The return value should be either "inline"
|
||||
or "attachment", or `None` if the attachment is somehow missing that header.
|
||||
|
||||
(Anymail back-ports Python 3.5's :meth:`~email.message.Message.get_content_disposition`
|
||||
method to all supported versions.)
|
||||
|
||||
.. method:: get_content_text(charset=None, errors='replace')
|
||||
|
||||
Returns the content of the attachment decoded to Unicode text.
|
||||
@@ -453,7 +446,7 @@ And they may then retry sending these "failed" events, which could
|
||||
cause duplicate processing in your code.
|
||||
If your signal receiver code might be slow, you should instead
|
||||
queue the event for later, asynchronous processing (e.g., using
|
||||
something like `Celery`_).
|
||||
something like :pypi:`celery`).
|
||||
|
||||
If your signal receiver function is defined within some other
|
||||
function or instance method, you *must* use the `weak=False`
|
||||
@@ -461,5 +454,3 @@ option when connecting it. Otherwise, it might seem to work at first,
|
||||
but will unpredictably stop being called at some point---typically
|
||||
on your production server, in a hard-to-debug way. See Django's
|
||||
docs on :doc:`signals <django:topics/signals>` for more information.
|
||||
|
||||
.. _Celery: http://www.celeryproject.org/
|
||||
|
||||
@@ -142,15 +142,15 @@ If you want to use Anymail's inbound or tracking webhooks:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from django.conf.urls import include, url
|
||||
from django.urls import include, re_path
|
||||
|
||||
urlpatterns = [
|
||||
...
|
||||
url(r'^anymail/', include('anymail.urls')),
|
||||
re_path(r'^anymail/', include('anymail.urls')),
|
||||
]
|
||||
|
||||
(You can change the "anymail" prefix in the first parameter to
|
||||
:func:`~django.conf.urls.url` if you'd like the webhooks to be served
|
||||
:func:`~django.urls.re_path` if you'd like the webhooks to be served
|
||||
at some other URL. Just match whatever you use in the webhook URL you give
|
||||
your ESP in the next step.)
|
||||
|
||||
@@ -186,7 +186,7 @@ See :ref:`event-tracking` for information on creating signal handlers and the
|
||||
status tracking events you can receive. See :ref:`inbound` for information on
|
||||
receiving inbound message events.
|
||||
|
||||
.. _mod_wsgi: http://modwsgi.readthedocs.io/en/latest/configuration-directives/WSGIPassAuthorization.html
|
||||
.. _mod_wsgi: https://modwsgi.readthedocs.io/en/latest/configuration-directives/WSGIPassAuthorization.html
|
||||
|
||||
|
||||
.. setting:: ANYMAIL
|
||||
@@ -227,10 +227,11 @@ if you are using other Django apps that work with the same ESP.)
|
||||
Finally, for complex use cases, you can override most settings on a per-instance
|
||||
basis by providing keyword args where the instance is initialized (e.g., in a
|
||||
:func:`~django.core.mail.get_connection` call to create an email backend instance,
|
||||
or in `View.as_view()` call to set up webhooks in a custom urls.py). To get the kwargs
|
||||
or in a `View.as_view()` call to set up webhooks in a custom urls.py). To get the kwargs
|
||||
parameter for a setting, drop "ANYMAIL" and the ESP name, and lowercase the rest:
|
||||
e.g., you can override ANYMAIL_MAILGUN_API_KEY by passing `api_key="abc"` to
|
||||
:func:`~django.core.mail.get_connection`. See :ref:`multiple-backends` for an example.
|
||||
e.g., you can override ANYMAIL_MAILGUN_API_KEY for a particular connection by calling
|
||||
``get_connection("anymail.backends.mailgun.EmailBackend", api_key="abc")``.
|
||||
See :ref:`multiple-backends` for an example.
|
||||
|
||||
There are specific Anymail settings for each ESP (like API keys and urls).
|
||||
See the :ref:`supported ESPs <supported-esps>` section for details.
|
||||
@@ -253,7 +254,7 @@ See :ref:`recipients-refused`.
|
||||
}
|
||||
|
||||
|
||||
.. rubric:: SEND_DEFAULTS and *ESP*\ _SEND_DEFAULTS`
|
||||
.. rubric:: SEND_DEFAULTS and *ESP*\ _SEND_DEFAULTS
|
||||
|
||||
A `dict` of default options to apply to all messages sent through Anymail.
|
||||
See :ref:`send-defaults`.
|
||||
|
||||
@@ -26,18 +26,18 @@ The first approach is usually the simplest. The other two can be
|
||||
helpful if you are working with Python development tools that
|
||||
offer type checking or other static code analysis.
|
||||
|
||||
Availability of these features varies by ESP, and there may be additional
|
||||
limitations even when an ESP does support a particular feature. Be sure
|
||||
to check Anymail's docs for your :ref:`specific ESP <supported-esps>`.
|
||||
If you try to use a feature your ESP does not offer, Anymail will raise
|
||||
an :ref:`unsupported feature <unsupported-features>` error.
|
||||
|
||||
|
||||
.. _anymail-send-options:
|
||||
|
||||
ESP send options (AnymailMessage)
|
||||
---------------------------------
|
||||
|
||||
Availability of each of these features varies by ESP, and there may be additional
|
||||
limitations even when an ESP does support a particular feature. Be sure
|
||||
to check Anymail's docs for your :ref:`specific ESP <supported-esps>`.
|
||||
If you try to use a feature your ESP does not offer, Anymail will raise
|
||||
an :ref:`unsupported feature <unsupported-features>` error.
|
||||
|
||||
.. class:: AnymailMessage
|
||||
|
||||
A subclass of Django's :class:`~django.core.mail.EmailMultiAlternatives`
|
||||
@@ -167,7 +167,7 @@ ESP send options (AnymailMessage)
|
||||
|
||||
ESPs have differing restrictions on tags. For portability,
|
||||
it's best to stick with strings that start with an alphanumeric
|
||||
character. (Also, Postmark only allows a single tag per message.)
|
||||
character. (Also, a few ESPs allow only a single tag per message.)
|
||||
|
||||
|
||||
.. caution::
|
||||
@@ -359,7 +359,7 @@ ESP send status
|
||||
* `'queued'` the ESP has accepted the message
|
||||
and will try to send it asynchronously
|
||||
* `'invalid'` the ESP considers the sender or recipient email invalid
|
||||
* `'rejected'` the recipient is on an ESP blacklist
|
||||
* `'rejected'` the recipient is on an ESP suppression list
|
||||
(unsubscribe, previous bounces, etc.)
|
||||
* `'failed'` the attempt to send failed for some other reason
|
||||
* `'unknown'` anything else
|
||||
@@ -402,7 +402,8 @@ ESP send status
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# This will work with a requests-based backend:
|
||||
# This will work with a requests-based backend,
|
||||
# for an ESP whose send API provides a JSON response:
|
||||
message.anymail_status.esp_response.json()
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ email using Django's default SMTP :class:`~django.core.mail.backends.smtp.EmailB
|
||||
switching to Anymail will be easy. Anymail is designed to "just work" with Django.
|
||||
|
||||
If you're not familiar with Django's email functions, please take a look at
|
||||
":mod:`sending email <django.core.mail>`" in the Django docs first.
|
||||
:doc:`django:topics/email` in the Django docs first.
|
||||
|
||||
Anymail supports most of the functionality of Django's :class:`~django.core.mail.EmailMessage`
|
||||
and :class:`~django.core.mail.EmailMultiAlternatives` classes.
|
||||
@@ -39,8 +39,8 @@ function with the ``html_message`` parameter:
|
||||
send_mail("Subject", "text body", "from@example.com",
|
||||
["to@example.com"], html_message="<html>html body</html>")
|
||||
|
||||
However, many Django email capabilities -- and additional Anymail features --
|
||||
are only available when working with an :class:`~django.core.mail.EmailMultiAlternatives`
|
||||
However, many Django email capabilities---and additional Anymail features---are only
|
||||
available when working with an :class:`~django.core.mail.EmailMultiAlternatives`
|
||||
object. Use its :meth:`~django.core.mail.EmailMultiAlternatives.attach_alternative`
|
||||
method to send HTML:
|
||||
|
||||
@@ -168,7 +168,8 @@ raise :exc:`~exceptions.AnymailUnsupportedFeature`.
|
||||
.. setting:: ANYMAIL_IGNORE_UNSUPPORTED_FEATURES
|
||||
|
||||
If you'd like to silently ignore :exc:`~exceptions.AnymailUnsupportedFeature`
|
||||
errors and send the messages anyway, set :setting:`!ANYMAIL_IGNORE_UNSUPPORTED_FEATURES`
|
||||
errors and send the messages anyway, set
|
||||
:setting:`"IGNORE_UNSUPPORTED_FEATURES" <ANYMAIL_IGNORE_UNSUPPORTED_FEATURES>`
|
||||
to `True` in your settings.py:
|
||||
|
||||
.. code-block:: python
|
||||
@@ -197,15 +198,16 @@ If a single message is sent to multiple recipients, and *any* recipient is valid
|
||||
You can still examine the message's :attr:`~message.AnymailMessage.anymail_status`
|
||||
property after the send to determine the status of each recipient.
|
||||
|
||||
You can disable this exception by setting :setting:`ANYMAIL_IGNORE_RECIPIENT_STATUS`
|
||||
to `True` in your settings.py, which will cause Anymail to treat any non-API-error response
|
||||
from your ESP as a successful send.
|
||||
You can disable this exception by setting
|
||||
:setting:`"IGNORE_RECIPIENT_STATUS" <ANYMAIL_IGNORE_RECIPIENT_STATUS>` to `True` in
|
||||
your settings.py `ANYMAIL` dict, which will cause Anymail to treat *any*
|
||||
response from your ESP (other than an API error) as a successful send.
|
||||
|
||||
.. note::
|
||||
|
||||
Many ESPs don't check recipient status during the send API call. For example,
|
||||
Most ESPs don't check recipient status during the send API call. For example,
|
||||
Mailgun always queues sent messages, so you'll never catch
|
||||
:exc:`AnymailRecipientsRefused` with the Mailgun backend.
|
||||
|
||||
For those ESPs, use Anymail's :ref:`delivery event tracking <event-tracking>`
|
||||
if you need to be notified of sends to blacklisted or invalid emails.
|
||||
You can use Anymail's :ref:`delivery event tracking <event-tracking>`
|
||||
if you need to be notified of sends to suppression-listed or invalid emails.
|
||||
|
||||
@@ -211,7 +211,7 @@ for use as merge data:
|
||||
# Do something this instead:
|
||||
message.merge_global_data = {
|
||||
'PRODUCT': product.name, # assuming name is a CharField
|
||||
'TOTAL_COST': "%.2f" % total_cost,
|
||||
'TOTAL_COST': "{cost:0.2f}".format(cost=total_cost),
|
||||
'SHIP_DATE': ship_date.strftime('%B %d, %Y') # US-style "March 15, 2015"
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Webhook support is optional. If you haven't yet, you'll need to
|
||||
project. (You may also want to review :ref:`securing-webhooks`.)
|
||||
|
||||
Once you've enabled webhooks, Anymail will send an ``anymail.signals.tracking``
|
||||
custom Django :mod:`signal <django.dispatch>` for each ESP tracking event it receives.
|
||||
custom Django :doc:`signal <django:topics/signals>` for each ESP tracking event it receives.
|
||||
You can connect your own receiver function to this signal for further processing.
|
||||
|
||||
Be sure to read Django's `listening to signals`_ docs for information on defining
|
||||
@@ -40,7 +40,7 @@ Example:
|
||||
event.recipient, event.click_url))
|
||||
|
||||
You can define individual signal receivers, or create one big one for all
|
||||
event types, which ever you prefer. You can even handle the same event
|
||||
event types, whichever you prefer. You can even handle the same event
|
||||
in multiple receivers, if that makes your code cleaner. These
|
||||
:ref:`signal receiver functions <signal-receivers>` are documented
|
||||
in more detail below.
|
||||
@@ -189,8 +189,8 @@ Normalized tracking event
|
||||
.. attribute:: mta_response
|
||||
|
||||
If available, a `str` with a raw (intended for email administrators) response
|
||||
from the receiving MTA. Otherwise `None`. Often includes SMTP response codes,
|
||||
but the exact format varies by ESP (and sometimes receiving MTA).
|
||||
from the receiving mail transfer agent. Otherwise `None`. Often includes SMTP
|
||||
response codes, but the exact format varies by ESP (and sometimes receiving MTA).
|
||||
|
||||
.. attribute:: user_agent
|
||||
|
||||
@@ -203,7 +203,7 @@ Normalized tracking event
|
||||
|
||||
.. attribute:: esp_event
|
||||
|
||||
The "raw" event data from the ESP, deserialized into a python data structure.
|
||||
The "raw" event data from the ESP, deserialized into a Python data structure.
|
||||
For most ESPs this is either parsed JSON (as a `dict`), or HTTP POST fields
|
||||
(as a Django :class:`~django.http.QueryDict`).
|
||||
|
||||
@@ -230,7 +230,7 @@ Your Anymail signal receiver must be a function with this signature:
|
||||
:param AnymailTrackingEvent event: The normalized tracking event.
|
||||
Almost anything you'd be interested in
|
||||
will be in here.
|
||||
:param str esp_name: e.g., "SendMail" or "Postmark". If you are working
|
||||
:param str esp_name: e.g., "SendGrid" or "Postmark". If you are working
|
||||
with multiple ESPs, you can use this to distinguish
|
||||
ESP-specific handling in your shared event processing.
|
||||
:param \**kwargs: Required by Django's signal mechanism
|
||||
@@ -259,7 +259,7 @@ And will retry sending the "failed" events, which could cause duplicate
|
||||
processing in your code.
|
||||
If your signal receiver code might be slow, you should instead
|
||||
queue the event for later, asynchronous processing (e.g., using
|
||||
something like `Celery`_).
|
||||
something like :pypi:`celery`).
|
||||
|
||||
If your signal receiver function is defined within some other
|
||||
function or instance method, you *must* use the `weak=False`
|
||||
@@ -268,7 +268,6 @@ but will unpredictably stop being called at some point---typically
|
||||
on your production server, in a hard-to-debug way. See Django's
|
||||
`listening to signals`_ docs for more information.
|
||||
|
||||
.. _Celery: http://www.celeryproject.org/
|
||||
.. _listening to signals:
|
||||
https://docs.djangoproject.com/en/stable/topics/signals/#listening-to-signals
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ ESP's templating languages and merge capabilities are generally not compatible
|
||||
with each other, which can make it hard to move email templates between them.
|
||||
|
||||
But since you're working in Django, you already have access to the
|
||||
extremely-full-featured :mod:`Django templating system <django.template>`.
|
||||
extremely-full-featured :doc:`Django templating system <django:topics/templates>`.
|
||||
You don't even have to use Django's template syntax: it supports other
|
||||
template languages (like Jinja2).
|
||||
|
||||
@@ -15,7 +15,7 @@ You're probably already using Django's templating system for your HTML pages,
|
||||
so it can be an easy decision to use it for your email, too.
|
||||
|
||||
To compose email using *Django* templates, you can use Django's
|
||||
:func:`~django.template.loaders.django.template.loader.render_to_string`
|
||||
:func:`~django.template.loader.render_to_string`
|
||||
template shortcut to build the body and html.
|
||||
|
||||
Example that builds an email from the templates ``message_subject.txt``,
|
||||
@@ -24,16 +24,14 @@ Example that builds an email from the templates ``message_subject.txt``,
|
||||
.. code-block:: python
|
||||
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.template import Context
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
merge_data = {
|
||||
'ORDERNO': "12345", 'TRACKINGNO': "1Z987"
|
||||
}
|
||||
|
||||
plaintext_context = Context(autoescape=False) # HTML escaping not appropriate in plaintext
|
||||
subject = render_to_string("message_subject.txt", merge_data, plaintext_context)
|
||||
text_body = render_to_string("message_body.txt", merge_data, plaintext_context)
|
||||
subject = render_to_string("message_subject.txt", merge_data).strip()
|
||||
text_body = render_to_string("message_body.txt", merge_data)
|
||||
html_body = render_to_string("message_body.html", merge_data)
|
||||
|
||||
msg = EmailMultiAlternatives(subject=subject, from_email="store@example.com",
|
||||
@@ -41,6 +39,9 @@ Example that builds an email from the templates ``message_subject.txt``,
|
||||
msg.attach_alternative(html_body, "text/html")
|
||||
msg.send()
|
||||
|
||||
Tip: use Django's :ttag:`{% autoescape off %}<autoescape>` template tag in your
|
||||
plaintext ``.txt`` templates to avoid inappropriate HTML escaping.
|
||||
|
||||
|
||||
Helpful add-ons
|
||||
---------------
|
||||
@@ -48,8 +49,6 @@ Helpful add-ons
|
||||
These (third-party) packages can be helpful for building your email
|
||||
in Django:
|
||||
|
||||
.. TODO: flesh this out
|
||||
|
||||
* :pypi:`django-templated-mail`, :pypi:`django-mail-templated`, or :pypi:`django-mail-templated-simple`
|
||||
for building messages from sets of Django templates.
|
||||
* :pypi:`premailer` for inlining css before sending
|
||||
|
||||
@@ -73,10 +73,10 @@ Basic usage is covered in the
|
||||
:ref:`webhooks configuration <webhooks-configuration>` docs.
|
||||
|
||||
If something posts to your webhooks without the required shared
|
||||
secret as basic auth in the HTTP_AUTHORIZATION header, Anymail will
|
||||
secret as basic auth in the HTTP *Authorization* header, Anymail will
|
||||
raise an :exc:`AnymailWebhookValidationFailure` error, which is
|
||||
a subclass of Django's :exc:`~django.core.exceptions.SuspiciousOperation`.
|
||||
This will result in an HTTP 400 response, without further processing
|
||||
This will result in an HTTP 400 "bad request" response, without further processing
|
||||
the data or calling your signal receiver function.
|
||||
|
||||
In addition to a single "random:random" string, you can give a list
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
# or
|
||||
# runtests.py [tests.test_x tests.test_y.SomeTestCase ...]
|
||||
|
||||
from __future__ import print_function
|
||||
import sys
|
||||
from distutils.util import strtobool
|
||||
|
||||
@@ -33,7 +32,6 @@ def setup_and_run_tests(test_labels=None):
|
||||
|
||||
warnings.simplefilter('default') # show DeprecationWarning and other default-ignored warnings
|
||||
|
||||
# noinspection PyStringFormat
|
||||
os.environ['DJANGO_SETTINGS_MODULE'] = \
|
||||
'tests.test_settings.settings_%d_%d' % django.VERSION[:2]
|
||||
django.setup()
|
||||
|
||||
10
setup.py
10
setup.py
@@ -43,7 +43,7 @@ setup(
|
||||
license="BSD License",
|
||||
packages=["anymail"],
|
||||
zip_safe=False,
|
||||
install_requires=["django>=1.11", "requests>=2.4.3", "six"],
|
||||
install_requires=["django>=2.0", "requests>=2.4.3"],
|
||||
extras_require={
|
||||
# This can be used if particular backends have unique dependencies.
|
||||
# For simplicity, requests is included in the base requirements.
|
||||
@@ -64,21 +64,21 @@ setup(
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: Implementation :: PyPy",
|
||||
"Programming Language :: Python :: Implementation :: CPython",
|
||||
"Programming Language :: Python :: 2",
|
||||
"Programming Language :: Python :: 2.7",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.4",
|
||||
"Programming Language :: Python :: 3.5",
|
||||
"Programming Language :: Python :: 3.6",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"License :: OSI Approved :: BSD License",
|
||||
"Topic :: Communications :: Email",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"Intended Audience :: Developers",
|
||||
"Framework :: Django",
|
||||
"Framework :: Django :: 1.11",
|
||||
"Framework :: Django :: 2.0",
|
||||
"Framework :: Django :: 2.1",
|
||||
"Framework :: Django :: 2.2",
|
||||
"Framework :: Django :: 3.0",
|
||||
"Framework :: Django :: 3.1",
|
||||
"Environment :: Web Environment",
|
||||
],
|
||||
long_description=long_description,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import json
|
||||
from io import BytesIO
|
||||
|
||||
from django.core import mail
|
||||
from django.test import SimpleTestCase
|
||||
import requests
|
||||
import six
|
||||
from mock import patch
|
||||
|
||||
from anymail.exceptions import AnymailAPIError
|
||||
@@ -13,7 +13,7 @@ from .utils import AnymailTestMixin
|
||||
UNSET = object()
|
||||
|
||||
|
||||
class RequestsBackendMockAPITestCase(SimpleTestCase, AnymailTestMixin):
|
||||
class RequestsBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
|
||||
"""TestCase that mocks API calls through requests"""
|
||||
|
||||
DEFAULT_RAW_RESPONSE = b"""{"subclass": "should override"}"""
|
||||
@@ -22,15 +22,14 @@ class RequestsBackendMockAPITestCase(SimpleTestCase, AnymailTestMixin):
|
||||
class MockResponse(requests.Response):
|
||||
"""requests.request return value mock sufficient for testing"""
|
||||
def __init__(self, status_code=200, raw=b"RESPONSE", encoding='utf-8', reason=None):
|
||||
super(RequestsBackendMockAPITestCase.MockResponse, self).__init__()
|
||||
super().__init__()
|
||||
self.status_code = status_code
|
||||
self.encoding = encoding
|
||||
self.reason = reason or ("OK" if 200 <= status_code < 300 else "ERROR")
|
||||
# six.BytesIO(None) returns b'None' in PY2 (rather than b'')
|
||||
self.raw = six.BytesIO(raw) if raw is not None else six.BytesIO()
|
||||
self.raw = BytesIO(raw)
|
||||
|
||||
def setUp(self):
|
||||
super(RequestsBackendMockAPITestCase, self).setUp()
|
||||
super().setUp()
|
||||
self.patch_request = patch('requests.Session.request', autospec=True)
|
||||
self.mock_request = self.patch_request.start()
|
||||
self.addCleanup(self.patch_request.stop)
|
||||
@@ -127,17 +126,25 @@ class RequestsBackendMockAPITestCase(SimpleTestCase, AnymailTestMixin):
|
||||
raise AssertionError(msg or "ESP API was called and shouldn't have been")
|
||||
|
||||
|
||||
# noinspection PyUnresolvedReferences
|
||||
class SessionSharingTestCasesMixin(object):
|
||||
"""Mixin that tests connection sharing in any RequestsBackendMockAPITestCase
|
||||
class SessionSharingTestCases(RequestsBackendMockAPITestCase):
|
||||
"""Common test cases for requests backend connection sharing.
|
||||
|
||||
(Contains actual test cases, so can't be included in RequestsBackendMockAPITestCase
|
||||
itself, as that would re-run these tests several times for each backend, in
|
||||
each TestCase for the backend.)
|
||||
Instantiate for each ESP by:
|
||||
- subclassing
|
||||
- adding or overriding any tests as appropriate
|
||||
"""
|
||||
|
||||
def __init__(self, methodName='runTest'):
|
||||
if self.__class__ is SessionSharingTestCases:
|
||||
# don't run these tests on the abstract base implementation
|
||||
methodName = 'runNoTestsInBaseClass'
|
||||
super().__init__(methodName)
|
||||
|
||||
def runNoTestsInBaseClass(self):
|
||||
pass
|
||||
|
||||
def setUp(self):
|
||||
super(SessionSharingTestCasesMixin, self).setUp()
|
||||
super().setUp()
|
||||
self.patch_close = patch('requests.Session.close', autospec=True)
|
||||
self.mock_close = self.patch_close.start()
|
||||
self.addCleanup(self.patch_close.stop)
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from email.mime.application import MIMEApplication
|
||||
|
||||
import six
|
||||
from django.core import mail
|
||||
from django.core.mail import BadHeaderError
|
||||
from django.test import SimpleTestCase, override_settings, tag
|
||||
@@ -19,11 +15,11 @@ from .utils import AnymailTestMixin, SAMPLE_IMAGE_FILENAME, sample_image_content
|
||||
|
||||
@tag('amazon_ses')
|
||||
@override_settings(EMAIL_BACKEND='anymail.backends.amazon_ses.EmailBackend')
|
||||
class AmazonSESBackendMockAPITestCase(SimpleTestCase, AnymailTestMixin):
|
||||
class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
|
||||
"""TestCase that uses the Amazon SES EmailBackend with a mocked boto3 client"""
|
||||
|
||||
def setUp(self):
|
||||
super(AmazonSESBackendMockAPITestCase, self).setUp()
|
||||
super().setUp()
|
||||
|
||||
# Mock boto3.session.Session().client('ses').send_raw_email (and any other client operations)
|
||||
# (We could also use botocore.stub.Stubber, but mock works well with our test structure)
|
||||
@@ -122,7 +118,7 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase):
|
||||
# send_raw_email takes a fully-formatted MIME message.
|
||||
# This is a simple (if inexact) way to check for expected headers and body:
|
||||
raw_mime = params['RawMessage']['Data']
|
||||
self.assertIsInstance(raw_mime, six.binary_type) # SendRawEmail expects Data as bytes
|
||||
self.assertIsInstance(raw_mime, bytes) # SendRawEmail expects Data as bytes
|
||||
self.assertIn(b"\nFrom: from@example.com\n", raw_mime)
|
||||
self.assertIn(b"\nTo: to@example.com\n", raw_mime)
|
||||
self.assertIn(b"\nSubject: Subject here\n", raw_mime)
|
||||
@@ -259,7 +255,7 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase):
|
||||
self.assertEqual(params['Source'], "from1@example.com")
|
||||
|
||||
def test_commas_in_subject(self):
|
||||
"""Anymail works around a Python 2 email header bug that adds unwanted spaces after commas in long subjects"""
|
||||
"""There used to be a Python email header bug that added unwanted spaces after commas in long subjects"""
|
||||
self.message.subject = "100,000,000 isn't a number you'd really want to break up in this email subject, right?"
|
||||
self.message.send()
|
||||
sent_message = self.get_sent_message()
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import json
|
||||
from base64 import b64encode
|
||||
from datetime import datetime
|
||||
@@ -22,7 +20,7 @@ from .webhook_cases import WebhookTestCase
|
||||
class AmazonSESInboundTests(WebhookTestCase, AmazonSESWebhookTestsMixin):
|
||||
|
||||
def setUp(self):
|
||||
super(AmazonSESInboundTests, self).setUp()
|
||||
super().setUp()
|
||||
# Mock boto3.session.Session().client('s3').download_fileobj
|
||||
# (We could also use botocore.stub.Stubber, but mock works well with our test structure)
|
||||
self.patch_boto3_session = patch('anymail.webhooks.amazon_ses.boto3.session.Session', autospec=True)
|
||||
@@ -263,9 +261,8 @@ class AmazonSESInboundTests(WebhookTestCase, AmazonSESWebhookTestsMixin):
|
||||
self.assertEqual([str(to) for to in message.to],
|
||||
['Recipient <inbound@example.com>', 'someone-else@example.org'])
|
||||
self.assertEqual(message.subject, 'Test inbound message')
|
||||
# rstrip below because the Python 3 EmailBytesParser converts \r\n to \n, but the Python 2 version doesn't
|
||||
self.assertEqual(message.text.rstrip(), "It's a body\N{HORIZONTAL ELLIPSIS}")
|
||||
self.assertEqual(message.html.rstrip(), """<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>""")
|
||||
self.assertEqual(message.text, "It's a body\N{HORIZONTAL ELLIPSIS}\n")
|
||||
self.assertEqual(message.html, """<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>\n""")
|
||||
self.assertIsNone(message.spam_detected)
|
||||
|
||||
def test_inbound_s3_failure_message(self):
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
import unittest
|
||||
import warnings
|
||||
@@ -12,11 +9,6 @@ from anymail.message import AnymailMessage
|
||||
|
||||
from .utils import AnymailTestMixin, sample_image_path
|
||||
|
||||
try:
|
||||
ResourceWarning
|
||||
except NameError:
|
||||
ResourceWarning = Warning # Python 2
|
||||
|
||||
|
||||
AMAZON_SES_TEST_ACCESS_KEY_ID = os.getenv("AMAZON_SES_TEST_ACCESS_KEY_ID")
|
||||
AMAZON_SES_TEST_SECRET_ACCESS_KEY = os.getenv("AMAZON_SES_TEST_SECRET_ACCESS_KEY")
|
||||
@@ -42,7 +34,7 @@ AMAZON_SES_TEST_REGION_NAME = os.getenv("AMAZON_SES_TEST_REGION_NAME", "us-east-
|
||||
"AMAZON_SES_CONFIGURATION_SET_NAME": "TestConfigurationSet", # actual config set in Anymail test account
|
||||
})
|
||||
@tag('amazon_ses', 'live')
|
||||
class AmazonSESBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
|
||||
class AmazonSESBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
||||
"""Amazon SES API integration tests
|
||||
|
||||
These tests run against the **live** Amazon SES API, using the environment
|
||||
@@ -63,14 +55,14 @@ class AmazonSESBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(AmazonSESBackendIntegrationTests, self).setUp()
|
||||
super().setUp()
|
||||
self.message = AnymailMessage('Anymail Amazon SES integration test', 'Text content',
|
||||
'test@test-ses.anymail.info', ['success@simulator.amazonses.com'])
|
||||
self.message.attach_alternative('<p>HTML content</p>', "text/html")
|
||||
|
||||
# boto3 relies on GC to close connections. Python 3 warns about unclosed ssl.SSLSocket during cleanup.
|
||||
# We don't care. (It might not be a real problem worth warning, but in any case it's not our problem.)
|
||||
# https://www.google.com/search?q=unittest+boto3+ResourceWarning+unclosed+ssl.SSLSocket
|
||||
# We don't care. (It may be a false positive, or it may be a botocore problem, but it's not *our* problem.)
|
||||
# https://github.com/boto/boto3/issues/454#issuecomment-586033745
|
||||
# Filter in TestCase.setUp because unittest resets the warning filters for each test.
|
||||
# https://stackoverflow.com/a/26620811/647002
|
||||
warnings.filterwarnings("ignore", message=r"unclosed <ssl\.SSLSocket", category=ResourceWarning)
|
||||
@@ -154,8 +146,9 @@ class AmazonSESBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
|
||||
}
|
||||
})
|
||||
def test_invalid_aws_credentials(self):
|
||||
with self.assertRaises(AnymailAPIError) as cm:
|
||||
self.message.send()
|
||||
err = cm.exception
|
||||
# Make sure the exception message includes AWS's response:
|
||||
self.assertIn("The security token included in the request is invalid", str(err))
|
||||
with self.assertRaisesMessage(
|
||||
AnymailAPIError,
|
||||
"The security token included in the request is invalid"
|
||||
):
|
||||
self.message.send()
|
||||
|
||||
@@ -2,7 +2,7 @@ import json
|
||||
import warnings
|
||||
from datetime import datetime
|
||||
|
||||
from django.test import override_settings, tag
|
||||
from django.test import SimpleTestCase, override_settings, tag
|
||||
from django.utils.timezone import utc
|
||||
from mock import ANY, patch
|
||||
|
||||
@@ -10,12 +10,11 @@ from anymail.exceptions import AnymailConfigurationError, AnymailInsecureWebhook
|
||||
from anymail.signals import AnymailTrackingEvent
|
||||
from anymail.webhooks.amazon_ses import AmazonSESTrackingWebhookView
|
||||
|
||||
from .webhook_cases import WebhookBasicAuthTestsMixin, WebhookTestCase
|
||||
from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase
|
||||
|
||||
|
||||
class AmazonSESWebhookTestsMixin(object):
|
||||
class AmazonSESWebhookTestsMixin(SimpleTestCase):
|
||||
def post_from_sns(self, path, raw_sns_message, **kwargs):
|
||||
# noinspection PyUnresolvedReferences
|
||||
return self.client.post(
|
||||
path,
|
||||
content_type='text/plain; charset=UTF-8', # SNS posts JSON as text/plain
|
||||
@@ -27,12 +26,12 @@ class AmazonSESWebhookTestsMixin(object):
|
||||
|
||||
|
||||
@tag('amazon_ses')
|
||||
class AmazonSESWebhookSecurityTests(WebhookTestCase, AmazonSESWebhookTestsMixin, WebhookBasicAuthTestsMixin):
|
||||
class AmazonSESWebhookSecurityTests(AmazonSESWebhookTestsMixin, WebhookBasicAuthTestCase):
|
||||
def call_webhook(self):
|
||||
return self.post_from_sns('/anymail/amazon_ses/tracking/',
|
||||
{"Type": "Notification", "MessageId": "123", "Message": "{}"})
|
||||
|
||||
# Most actual tests are in WebhookBasicAuthTestsMixin
|
||||
# Most actual tests are in WebhookBasicAuthTestCase
|
||||
|
||||
def test_verifies_missing_auth(self):
|
||||
# Must handle missing auth header slightly differently from Anymail default 400 SuspiciousOperation:
|
||||
@@ -412,7 +411,7 @@ class AmazonSESSubscriptionManagementTests(WebhookTestCase, AmazonSESWebhookTest
|
||||
# (Note that WebhookTestCase sets up ANYMAIL WEBHOOK_SECRET.)
|
||||
|
||||
def setUp(self):
|
||||
super(AmazonSESSubscriptionManagementTests, self).setUp()
|
||||
super().setUp()
|
||||
# Mock boto3.session.Session().client('sns').confirm_subscription (and any other client operations)
|
||||
# (We could also use botocore.stub.Stubber, but mock works well with our test structure)
|
||||
self.patch_boto3_session = patch('anymail.webhooks.amazon_ses.boto3.session.Session', autospec=True)
|
||||
|
||||
@@ -14,7 +14,7 @@ class MinimalRequestsBackend(AnymailRequestsBackend):
|
||||
api_url = "https://httpbin.org/post" # helpful echoback endpoint for live testing
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super(MinimalRequestsBackend, self).__init__(self.api_url, **kwargs)
|
||||
super().__init__(self.api_url, **kwargs)
|
||||
|
||||
def build_message_payload(self, message, defaults):
|
||||
_payload_init = getattr(message, "_payload_init", {})
|
||||
@@ -46,7 +46,7 @@ class RequestsBackendBaseTestCase(RequestsBackendMockAPITestCase):
|
||||
"""Test common functionality in AnymailRequestsBackend"""
|
||||
|
||||
def setUp(self):
|
||||
super(RequestsBackendBaseTestCase, self).setUp()
|
||||
super().setUp()
|
||||
self.message = AnymailMessage('Subject', 'Text Body', 'from@example.com', ['to@example.com'])
|
||||
|
||||
def test_minimal_requests_backend(self):
|
||||
@@ -70,7 +70,7 @@ class RequestsBackendBaseTestCase(RequestsBackendMockAPITestCase):
|
||||
|
||||
@tag('live')
|
||||
@override_settings(EMAIL_BACKEND='tests.test_base_backends.MinimalRequestsBackend')
|
||||
class RequestsBackendLiveTestCase(SimpleTestCase, AnymailTestMixin):
|
||||
class RequestsBackendLiveTestCase(AnymailTestMixin, SimpleTestCase):
|
||||
@override_settings(ANYMAIL_DEBUG_API_REQUESTS=True)
|
||||
def test_debug_logging(self):
|
||||
message = AnymailMessage('Subject', 'Text Body', 'from@example.com', ['to@example.com'])
|
||||
|
||||
@@ -7,7 +7,7 @@ from anymail.checks import check_deprecated_settings, check_insecure_settings
|
||||
from .utils import AnymailTestMixin
|
||||
|
||||
|
||||
class DeprecatedSettingsTests(SimpleTestCase, AnymailTestMixin):
|
||||
class DeprecatedSettingsTests(AnymailTestMixin, SimpleTestCase):
|
||||
@override_settings(ANYMAIL={"WEBHOOK_AUTHORIZATION": "abcde:12345"})
|
||||
def test_webhook_authorization(self):
|
||||
errors = check_deprecated_settings(None)
|
||||
@@ -27,7 +27,7 @@ class DeprecatedSettingsTests(SimpleTestCase, AnymailTestMixin):
|
||||
)])
|
||||
|
||||
|
||||
class InsecureSettingsTests(SimpleTestCase, AnymailTestMixin):
|
||||
class InsecureSettingsTests(AnymailTestMixin, SimpleTestCase):
|
||||
@override_settings(ANYMAIL={"DEBUG_API_REQUESTS": True})
|
||||
def test_debug_api_requests_deployed(self):
|
||||
errors = check_insecure_settings(None)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from datetime import datetime
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
import six
|
||||
from django.core import mail
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.core.mail import get_connection, send_mail
|
||||
@@ -9,7 +8,7 @@ from django.test import SimpleTestCase
|
||||
from django.test.utils import override_settings
|
||||
from django.utils.functional import Promise
|
||||
from django.utils.timezone import utc
|
||||
from django.utils.translation import ugettext_lazy
|
||||
from django.utils.translation import gettext_lazy
|
||||
|
||||
from anymail.backends.test import EmailBackend as TestBackend, TestPayload
|
||||
from anymail.exceptions import AnymailConfigurationError, AnymailInvalidAddress, AnymailUnsupportedFeature
|
||||
@@ -29,15 +28,15 @@ class SettingsTestBackend(TestBackend):
|
||||
default=None, allow_bare=True)
|
||||
self.password = get_anymail_setting('password', esp_name=esp_name, kwargs=kwargs,
|
||||
default=None, allow_bare=True)
|
||||
super(SettingsTestBackend, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@override_settings(EMAIL_BACKEND='anymail.backends.test.EmailBackend')
|
||||
class TestBackendTestCase(SimpleTestCase, AnymailTestMixin):
|
||||
class TestBackendTestCase(AnymailTestMixin, SimpleTestCase):
|
||||
"""Base TestCase using Anymail's Test EmailBackend"""
|
||||
|
||||
def setUp(self):
|
||||
super(TestBackendTestCase, self).setUp()
|
||||
super().setUp()
|
||||
# Simple message useful for many tests
|
||||
self.message = AnymailMessage('Subject', 'Text Body', 'from@example.com', ['to@example.com'])
|
||||
|
||||
@@ -237,7 +236,7 @@ class SendDefaultsTests(TestBackendTestCase):
|
||||
|
||||
class LazyStringsTest(TestBackendTestCase):
|
||||
"""
|
||||
Tests ugettext_lazy strings forced real before passing to ESP transport.
|
||||
Tests gettext_lazy strings forced real before passing to ESP transport.
|
||||
|
||||
Docs notwithstanding, Django lazy strings *don't* work anywhere regular
|
||||
strings would. In particular, they aren't instances of unicode/str.
|
||||
@@ -251,38 +250,38 @@ class LazyStringsTest(TestBackendTestCase):
|
||||
|
||||
def assertNotLazy(self, s, msg=None):
|
||||
self.assertNotIsInstance(s, Promise,
|
||||
msg=msg or "String %r is lazy" % six.text_type(s))
|
||||
msg=msg or "String %r is lazy" % str(s))
|
||||
|
||||
def test_lazy_from(self):
|
||||
# This sometimes ends up lazy when settings.DEFAULT_FROM_EMAIL is meant to be localized
|
||||
self.message.from_email = ugettext_lazy(u'"Global Sales" <sales@example.com>')
|
||||
self.message.from_email = gettext_lazy('"Global Sales" <sales@example.com>')
|
||||
self.message.send()
|
||||
params = self.get_send_params()
|
||||
self.assertNotLazy(params['from'].address)
|
||||
|
||||
def test_lazy_subject(self):
|
||||
self.message.subject = ugettext_lazy("subject")
|
||||
self.message.subject = gettext_lazy("subject")
|
||||
self.message.send()
|
||||
params = self.get_send_params()
|
||||
self.assertNotLazy(params['subject'])
|
||||
|
||||
def test_lazy_body(self):
|
||||
self.message.body = ugettext_lazy("text body")
|
||||
self.message.attach_alternative(ugettext_lazy("html body"), "text/html")
|
||||
self.message.body = gettext_lazy("text body")
|
||||
self.message.attach_alternative(gettext_lazy("html body"), "text/html")
|
||||
self.message.send()
|
||||
params = self.get_send_params()
|
||||
self.assertNotLazy(params['text_body'])
|
||||
self.assertNotLazy(params['html_body'])
|
||||
|
||||
def test_lazy_headers(self):
|
||||
self.message.extra_headers['X-Test'] = ugettext_lazy("Test Header")
|
||||
self.message.extra_headers['X-Test'] = gettext_lazy("Test Header")
|
||||
self.message.send()
|
||||
params = self.get_send_params()
|
||||
self.assertNotLazy(params['extra_headers']['X-Test'])
|
||||
|
||||
def test_lazy_attachments(self):
|
||||
self.message.attach(ugettext_lazy("test.csv"), ugettext_lazy("test,csv,data"), "text/csv")
|
||||
self.message.attach(MIMEText(ugettext_lazy("contact info")))
|
||||
self.message.attach(gettext_lazy("test.csv"), gettext_lazy("test,csv,data"), "text/csv")
|
||||
self.message.attach(MIMEText(gettext_lazy("contact info")))
|
||||
self.message.send()
|
||||
params = self.get_send_params()
|
||||
self.assertNotLazy(params['attachments'][0].name)
|
||||
@@ -290,22 +289,22 @@ class LazyStringsTest(TestBackendTestCase):
|
||||
self.assertNotLazy(params['attachments'][1].content)
|
||||
|
||||
def test_lazy_tags(self):
|
||||
self.message.tags = [ugettext_lazy("Shipping"), ugettext_lazy("Sales")]
|
||||
self.message.tags = [gettext_lazy("Shipping"), gettext_lazy("Sales")]
|
||||
self.message.send()
|
||||
params = self.get_send_params()
|
||||
self.assertNotLazy(params['tags'][0])
|
||||
self.assertNotLazy(params['tags'][1])
|
||||
|
||||
def test_lazy_metadata(self):
|
||||
self.message.metadata = {'order_type': ugettext_lazy("Subscription")}
|
||||
self.message.metadata = {'order_type': gettext_lazy("Subscription")}
|
||||
self.message.send()
|
||||
params = self.get_send_params()
|
||||
self.assertNotLazy(params['metadata']['order_type'])
|
||||
|
||||
def test_lazy_merge_data(self):
|
||||
self.message.merge_data = {
|
||||
'to@example.com': {'duration': ugettext_lazy("One Month")}}
|
||||
self.message.merge_global_data = {'order_type': ugettext_lazy("Subscription")}
|
||||
'to@example.com': {'duration': gettext_lazy("One Month")}}
|
||||
self.message.merge_global_data = {'order_type': gettext_lazy("Subscription")}
|
||||
self.message.send()
|
||||
params = self.get_send_params()
|
||||
self.assertNotLazy(params['merge_data']['to@example.com']['duration'])
|
||||
@@ -329,7 +328,7 @@ class CatchCommonErrorsTests(TestBackendTestCase):
|
||||
def test_explains_reply_to_must_be_list_lazy(self):
|
||||
"""Same as previous tests, with lazy strings"""
|
||||
# Lazy strings can fool string/iterable detection
|
||||
self.message.reply_to = ugettext_lazy("single-reply-to@example.com")
|
||||
self.message.reply_to = gettext_lazy("single-reply-to@example.com")
|
||||
with self.assertRaisesMessage(TypeError, '"reply_to" attribute must be a list or other iterable'):
|
||||
self.message.send()
|
||||
|
||||
@@ -431,7 +430,7 @@ class BatchSendDetectionTestCase(TestBackendTestCase):
|
||||
"""Tests shared code to consistently determine whether to use batch send"""
|
||||
|
||||
def setUp(self):
|
||||
super(BatchSendDetectionTestCase, self).setUp()
|
||||
super().setUp()
|
||||
self.backend = TestBackend()
|
||||
|
||||
def test_default_is_not_batch(self):
|
||||
@@ -460,7 +459,7 @@ class BatchSendDetectionTestCase(TestBackendTestCase):
|
||||
def set_cc(self, emails):
|
||||
if self.is_batch(): # this won't work here!
|
||||
self.unsupported_feature("cc with batch send")
|
||||
super(ImproperlyImplementedPayload, self).set_cc(emails)
|
||||
super().set_cc(emails)
|
||||
|
||||
connection = mail.get_connection('anymail.backends.test.EmailBackend',
|
||||
payload_class=ImproperlyImplementedPayload)
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import quopri
|
||||
from base64 import b64encode
|
||||
from email.utils import collapse_rfc2231_value
|
||||
@@ -169,10 +166,9 @@ class AnymailInboundMessageConstructionTests(SimpleTestCase):
|
||||
def test_parse_raw_mime_8bit_utf8(self):
|
||||
# In come cases, the message below ends up with 'Content-Transfer-Encoding: 8bit',
|
||||
# so needs to be parsed as bytes, not text (see https://bugs.python.org/issue18271).
|
||||
# Message.as_string() returns str, which is is bytes on Python 2 and text on Python 3.
|
||||
# Message.as_string() returns str (text), not bytes.
|
||||
# (This might be a Django bug; plain old MIMEText avoids the problem by using
|
||||
# 'Content-Transfer-Encoding: base64', which parses fine as text or bytes.
|
||||
# Django <1.11 on Python 3 also used base64.)
|
||||
# 'Content-Transfer-Encoding: base64', which parses fine as text or bytes.)
|
||||
# Either way, AnymailInboundMessage should try to sidestep the whole issue.
|
||||
raw = SafeMIMEText("Unicode ✓", "plain", "utf-8").as_string()
|
||||
msg = AnymailInboundMessage.parse_raw_mime(raw)
|
||||
@@ -495,9 +491,11 @@ class AnymailInboundMessageAttachedMessageTests(SimpleTestCase):
|
||||
self.assertEqual(orig_msg.get_content_type(), "multipart/related")
|
||||
|
||||
|
||||
class EmailParserWorkaroundTests(SimpleTestCase):
|
||||
# Anymail includes workarounds for (some of) the more problematic bugs
|
||||
# in the Python 2 email.parser.Parser.
|
||||
class EmailParserBehaviorTests(SimpleTestCase):
|
||||
# Python 3.5+'s EmailParser should handle all of these, so long as it's not
|
||||
# invoked with its default policy=compat32. This double checks we're using it
|
||||
# properly. (Also, older versions of Anymail included workarounds for these
|
||||
# in older, broken versions of the EmailParser.)
|
||||
|
||||
def test_parse_folded_headers(self):
|
||||
raw = dedent("""\
|
||||
@@ -540,16 +538,11 @@ class EmailParserWorkaroundTests(SimpleTestCase):
|
||||
self.assertEqual(msg.from_email.display_name, "Keith Moore")
|
||||
self.assertEqual(msg.from_email.addr_spec, "moore@example.com")
|
||||
|
||||
# When an RFC2047 encoded-word abuts an RFC5322 quoted-word in a *structured* header,
|
||||
# Python 3's parser nicely recombines them into a single quoted word. That's way too
|
||||
# complicated for our Python 2 workaround ...
|
||||
self.assertIn(msg["To"], [ # `To` header will decode to one of these:
|
||||
'Keld Jørn Simonsen <keld@example.com>, "André Pirard, Jr." <PIRARD@example.com>', # Python 3
|
||||
'Keld Jørn Simonsen <keld@example.com>, André "Pirard, Jr." <PIRARD@example.com>', # workaround version
|
||||
])
|
||||
# ... but the two forms are equivalent, and de-structure the same:
|
||||
self.assertEqual(msg["To"],
|
||||
'Keld Jørn Simonsen <keld@example.com>, '
|
||||
'"André Pirard, Jr." <PIRARD@example.com>')
|
||||
self.assertEqual(msg.to[0].display_name, "Keld Jørn Simonsen")
|
||||
self.assertEqual(msg.to[1].display_name, "André Pirard, Jr.") # correct in Python 3 *and* workaround!
|
||||
self.assertEqual(msg.to[1].display_name, "André Pirard, Jr.")
|
||||
|
||||
# Note: Like email.headerregistry.Address, Anymail decodes an RFC2047-encoded display_name,
|
||||
# but does not decode a punycode domain. (Use `idna.decode(domain)` if you need that.)
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from datetime import date, datetime
|
||||
from textwrap import dedent
|
||||
|
||||
try:
|
||||
from email import message_from_bytes
|
||||
except ImportError:
|
||||
from email import message_from_string
|
||||
|
||||
def message_from_bytes(s):
|
||||
return message_from_string(s.decode('utf-8'))
|
||||
|
||||
from email import message_from_bytes
|
||||
from email.mime.base import MIMEBase
|
||||
from email.mime.image import MIMEImage
|
||||
|
||||
@@ -24,7 +15,7 @@ from anymail.exceptions import (
|
||||
AnymailRequestsAPIError, AnymailUnsupportedFeature)
|
||||
from anymail.message import attach_inline_image_file
|
||||
|
||||
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCasesMixin
|
||||
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCases
|
||||
from .utils import (AnymailTestMixin, sample_email_content,
|
||||
sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME)
|
||||
|
||||
@@ -39,7 +30,7 @@ class MailgunBackendMockAPITestCase(RequestsBackendMockAPITestCase):
|
||||
}"""
|
||||
|
||||
def setUp(self):
|
||||
super(MailgunBackendMockAPITestCase, self).setUp()
|
||||
super().setUp()
|
||||
# Simple message useful for many tests
|
||||
self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com'])
|
||||
|
||||
@@ -178,7 +169,7 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase):
|
||||
self.assertEqual(len(inlines), 0)
|
||||
|
||||
def test_unicode_attachment_correctly_decoded(self):
|
||||
self.message.attach(u"Une pièce jointe.html", u'<p>\u2019</p>', mimetype='text/html')
|
||||
self.message.attach("Une pièce jointe.html", '<p>\u2019</p>', mimetype='text/html')
|
||||
self.message.send()
|
||||
|
||||
# Verify the RFC 7578 compliance workaround has kicked in:
|
||||
@@ -191,7 +182,7 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase):
|
||||
workaround = True
|
||||
data = data.decode("utf-8").replace("\r\n", "\n")
|
||||
self.assertNotIn("filename*=", data) # No RFC 2231 encoding
|
||||
self.assertIn(u'Content-Disposition: form-data; name="attachment"; filename="Une pièce jointe.html"', data)
|
||||
self.assertIn('Content-Disposition: form-data; name="attachment"; filename="Une pièce jointe.html"', data)
|
||||
|
||||
if workaround:
|
||||
files = self.get_api_call_files(required=False)
|
||||
@@ -199,8 +190,8 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase):
|
||||
|
||||
def test_rfc_7578_compliance(self):
|
||||
# Check some corner cases in the workaround that undoes RFC 2231 multipart/form-data encoding...
|
||||
self.message.subject = u"Testing for filename*=utf-8''problems"
|
||||
self.message.body = u"The attached message should have an attachment named 'vedhæftet fil.txt'"
|
||||
self.message.subject = "Testing for filename*=utf-8''problems"
|
||||
self.message.body = "The attached message should have an attachment named 'vedhæftet fil.txt'"
|
||||
# A forwarded message with its own attachment:
|
||||
forwarded_message = dedent("""\
|
||||
MIME-Version: 1.0
|
||||
@@ -219,7 +210,7 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase):
|
||||
This is an attachment.
|
||||
--boundary--
|
||||
""")
|
||||
self.message.attach(u"besked med vedhæftede filer", forwarded_message, "message/rfc822")
|
||||
self.message.attach("besked med vedhæftede filer", forwarded_message, "message/rfc822")
|
||||
self.message.send()
|
||||
|
||||
data = self.get_api_call_data()
|
||||
@@ -230,13 +221,13 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase):
|
||||
|
||||
# Top-level attachment (in form-data) should have RFC 7578 filename (raw Unicode):
|
||||
self.assertIn(
|
||||
u'Content-Disposition: form-data; name="attachment"; filename="besked med vedhæftede filer"', data)
|
||||
'Content-Disposition: form-data; name="attachment"; filename="besked med vedhæftede filer"', data)
|
||||
# Embedded message/rfc822 attachment should retain its RFC 2231 encoded filename:
|
||||
self.assertIn("Content-Type: text/plain; name*=utf-8''vedh%C3%A6ftet%20fil.txt", data)
|
||||
self.assertIn("Content-Disposition: attachment; filename*=utf-8''vedh%C3%A6ftet%20fil.txt", data)
|
||||
# References to RFC 2231 in message text should remain intact:
|
||||
self.assertIn("Testing for filename*=utf-8''problems", data)
|
||||
self.assertIn(u"The attached message should have an attachment named 'vedhæftet fil.txt'", data)
|
||||
self.assertIn("The attached message should have an attachment named 'vedhæftet fil.txt'", data)
|
||||
|
||||
def test_attachment_missing_filename(self):
|
||||
"""Mailgun silently drops attachments without filenames, so warn the caller"""
|
||||
@@ -767,14 +758,14 @@ class MailgunBackendRecipientsRefusedTests(MailgunBackendMockAPITestCase):
|
||||
|
||||
|
||||
@tag('mailgun')
|
||||
class MailgunBackendSessionSharingTestCase(SessionSharingTestCasesMixin, MailgunBackendMockAPITestCase):
|
||||
class MailgunBackendSessionSharingTestCase(SessionSharingTestCases, MailgunBackendMockAPITestCase):
|
||||
"""Requests session sharing tests"""
|
||||
pass # tests are defined in the mixin
|
||||
pass # tests are defined in SessionSharingTestCases
|
||||
|
||||
|
||||
@tag('mailgun')
|
||||
@override_settings(EMAIL_BACKEND="anymail.backends.mailgun.EmailBackend")
|
||||
class MailgunBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin):
|
||||
class MailgunBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase):
|
||||
"""Test ESP backend without required settings in place"""
|
||||
|
||||
def test_missing_api_key(self):
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from textwrap import dedent
|
||||
|
||||
import six
|
||||
from django.test import override_settings, tag
|
||||
from django.utils.timezone import utc
|
||||
from mock import ANY
|
||||
@@ -97,13 +97,13 @@ class MailgunInboundTestCase(WebhookTestCase):
|
||||
])
|
||||
|
||||
def test_attachments(self):
|
||||
att1 = six.BytesIO('test attachment'.encode('utf-8'))
|
||||
att1 = BytesIO('test attachment'.encode('utf-8'))
|
||||
att1.name = 'test.txt'
|
||||
image_content = sample_image_content()
|
||||
att2 = six.BytesIO(image_content)
|
||||
att2 = BytesIO(image_content)
|
||||
att2.name = 'image.png'
|
||||
email_content = sample_email_content()
|
||||
att3 = six.BytesIO(email_content)
|
||||
att3 = BytesIO(email_content)
|
||||
att3.content_type = 'message/rfc822; charset="us-ascii"'
|
||||
raw_event = mailgun_sign_legacy_payload({
|
||||
'message-headers': '[]',
|
||||
@@ -124,7 +124,7 @@ class MailgunInboundTestCase(WebhookTestCase):
|
||||
self.assertEqual(len(attachments), 2)
|
||||
self.assertEqual(attachments[0].get_filename(), 'test.txt')
|
||||
self.assertEqual(attachments[0].get_content_type(), 'text/plain')
|
||||
self.assertEqual(attachments[0].get_content_text(), u'test attachment')
|
||||
self.assertEqual(attachments[0].get_content_text(), 'test attachment')
|
||||
self.assertEqual(attachments[1].get_content_type(), 'message/rfc822')
|
||||
self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content)
|
||||
|
||||
@@ -176,8 +176,8 @@ class MailgunInboundTestCase(WebhookTestCase):
|
||||
self.assertEqual(message.envelope_sender, 'envelope-from@example.org')
|
||||
self.assertEqual(message.envelope_recipient, 'test@inbound.example.com')
|
||||
self.assertEqual(message.subject, 'Raw MIME test')
|
||||
self.assertEqual(message.text, u"It's a body\N{HORIZONTAL ELLIPSIS}\n")
|
||||
self.assertEqual(message.html, u"""<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>\n""")
|
||||
self.assertEqual(message.text, "It's a body\N{HORIZONTAL ELLIPSIS}\n")
|
||||
self.assertEqual(message.html, """<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>\n""")
|
||||
|
||||
def test_misconfigured_tracking(self):
|
||||
raw_event = mailgun_sign_payload({
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
import logging
|
||||
import os
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
from time import mktime, sleep
|
||||
from time import sleep
|
||||
|
||||
import requests
|
||||
from django.test import SimpleTestCase, override_settings, tag
|
||||
|
||||
from anymail.exceptions import AnymailAPIError
|
||||
from anymail.message import AnymailMessage
|
||||
|
||||
from .utils import AnymailTestMixin, sample_image_path
|
||||
|
||||
|
||||
MAILGUN_TEST_API_KEY = os.getenv('MAILGUN_TEST_API_KEY')
|
||||
MAILGUN_TEST_DOMAIN = os.getenv('MAILGUN_TEST_DOMAIN')
|
||||
|
||||
@@ -28,7 +24,7 @@ MAILGUN_TEST_DOMAIN = os.getenv('MAILGUN_TEST_DOMAIN')
|
||||
'MAILGUN_SENDER_DOMAIN': MAILGUN_TEST_DOMAIN,
|
||||
'MAILGUN_SEND_DEFAULTS': {'esp_extra': {'o:testmode': 'yes'}}},
|
||||
EMAIL_BACKEND="anymail.backends.mailgun.EmailBackend")
|
||||
class MailgunBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
|
||||
class MailgunBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
||||
"""Mailgun API integration tests
|
||||
|
||||
These tests run against the **live** Mailgun API, using the
|
||||
@@ -39,7 +35,7 @@ class MailgunBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(MailgunBackendIntegrationTests, self).setUp()
|
||||
super().setUp()
|
||||
self.message = AnymailMessage('Anymail Mailgun integration test', 'Text content',
|
||||
'from@example.com', ['test+to1@anymail.info'])
|
||||
self.message.attach_alternative('<p>HTML content</p>', "text/html")
|
||||
@@ -101,7 +97,7 @@ class MailgunBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
|
||||
|
||||
def test_all_options(self):
|
||||
send_at = datetime.now().replace(microsecond=0) + timedelta(minutes=2)
|
||||
send_at_timestamp = mktime(send_at.timetuple()) # python3: send_at.timestamp()
|
||||
send_at_timestamp = send_at.timestamp()
|
||||
message = AnymailMessage(
|
||||
subject="Anymail Mailgun all-options integration test",
|
||||
body="This is the text body",
|
||||
|
||||
@@ -12,7 +12,7 @@ from anymail.exceptions import AnymailConfigurationError
|
||||
from anymail.signals import AnymailTrackingEvent
|
||||
from anymail.webhooks.mailgun import MailgunTrackingWebhookView
|
||||
|
||||
from .webhook_cases import WebhookTestCase, WebhookBasicAuthTestsMixin
|
||||
from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase
|
||||
|
||||
TEST_WEBHOOK_SIGNING_KEY = 'TEST_WEBHOOK_SIGNING_KEY'
|
||||
|
||||
@@ -99,14 +99,14 @@ class MailgunWebhookSettingsTestCase(WebhookTestCase):
|
||||
|
||||
@tag('mailgun')
|
||||
@override_settings(ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY=TEST_WEBHOOK_SIGNING_KEY)
|
||||
class MailgunWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin):
|
||||
class MailgunWebhookSecurityTestCase(WebhookBasicAuthTestCase):
|
||||
should_warn_if_no_auth = False # because we check webhook signature
|
||||
|
||||
def call_webhook(self):
|
||||
return self.client.post('/anymail/mailgun/tracking/', content_type="application/json",
|
||||
data=json.dumps(mailgun_sign_payload({'event-data': {'event': 'delivered'}})))
|
||||
|
||||
# Additional tests are in WebhookBasicAuthTestsMixin
|
||||
# Additional tests are in WebhookBasicAuthTestCase
|
||||
|
||||
def test_verifies_correct_signature(self):
|
||||
response = self.client.post('/anymail/mailgun/tracking/', content_type="application/json",
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from base64 import b64encode
|
||||
from decimal import Decimal
|
||||
from email.mime.base import MIMEBase
|
||||
@@ -14,7 +12,7 @@ from anymail.exceptions import (AnymailAPIError, AnymailSerializationError,
|
||||
AnymailRequestsAPIError)
|
||||
from anymail.message import attach_inline_image_file
|
||||
|
||||
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCasesMixin
|
||||
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCases
|
||||
from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin, decode_att
|
||||
|
||||
|
||||
@@ -49,7 +47,7 @@ class MailjetBackendMockAPITestCase(RequestsBackendMockAPITestCase):
|
||||
}"""
|
||||
|
||||
def setUp(self):
|
||||
super(MailjetBackendMockAPITestCase, self).setUp()
|
||||
super().setUp()
|
||||
# Simple message useful for many tests
|
||||
self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com'])
|
||||
|
||||
@@ -222,13 +220,13 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase):
|
||||
self.assertNotIn('ContentID', attachments[2])
|
||||
|
||||
def test_unicode_attachment_correctly_decoded(self):
|
||||
self.message.attach(u"Une pièce jointe.html", u'<p>\u2019</p>', mimetype='text/html')
|
||||
self.message.attach("Une pièce jointe.html", '<p>\u2019</p>', mimetype='text/html')
|
||||
self.message.send()
|
||||
data = self.get_api_call_json()
|
||||
self.assertEqual(data['Attachments'], [{
|
||||
'Filename': u'Une pièce jointe.html',
|
||||
'Filename': 'Une pièce jointe.html',
|
||||
'Content-type': 'text/html',
|
||||
'content': b64encode(u'<p>\u2019</p>'.encode('utf-8')).decode('ascii')
|
||||
'content': b64encode('<p>\u2019</p>'.encode('utf-8')).decode('ascii')
|
||||
}])
|
||||
|
||||
def test_embedded_images(self):
|
||||
@@ -656,14 +654,14 @@ class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase):
|
||||
|
||||
|
||||
@tag('mailjet')
|
||||
class MailjetBackendSessionSharingTestCase(SessionSharingTestCasesMixin, MailjetBackendMockAPITestCase):
|
||||
class MailjetBackendSessionSharingTestCase(SessionSharingTestCases, MailjetBackendMockAPITestCase):
|
||||
"""Requests session sharing tests"""
|
||||
pass # tests are defined in the mixin
|
||||
pass # tests are defined in SessionSharingTestCases
|
||||
|
||||
|
||||
@tag('mailjet')
|
||||
@override_settings(EMAIL_BACKEND="anymail.backends.mailjet.EmailBackend")
|
||||
class MailjetBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin):
|
||||
class MailjetBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase):
|
||||
"""Test ESP backend without required settings in place"""
|
||||
|
||||
def test_missing_api_key(self):
|
||||
|
||||
@@ -160,7 +160,7 @@ class MailjetInboundTestCase(WebhookTestCase):
|
||||
self.assertEqual(len(attachments), 2)
|
||||
self.assertEqual(attachments[0].get_filename(), 'test.txt')
|
||||
self.assertEqual(attachments[0].get_content_type(), 'text/plain')
|
||||
self.assertEqual(attachments[0].get_content_text(), u'test attachment')
|
||||
self.assertEqual(attachments[0].get_content_text(), 'test attachment')
|
||||
self.assertEqual(attachments[1].get_content_type(), 'message/rfc822')
|
||||
self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content)
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ MAILJET_TEST_SECRET_KEY = os.getenv('MAILJET_TEST_SECRET_KEY')
|
||||
@override_settings(ANYMAIL_MAILJET_API_KEY=MAILJET_TEST_API_KEY,
|
||||
ANYMAIL_MAILJET_SECRET_KEY=MAILJET_TEST_SECRET_KEY,
|
||||
EMAIL_BACKEND="anymail.backends.mailjet.EmailBackend")
|
||||
class MailjetBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
|
||||
class MailjetBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
||||
"""Mailjet API integration tests
|
||||
|
||||
These tests run against the **live** Mailjet API, using the
|
||||
@@ -36,7 +36,7 @@ class MailjetBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(MailjetBackendIntegrationTests, self).setUp()
|
||||
super().setUp()
|
||||
self.message = AnymailMessage('Anymail Mailjet integration test', 'Text content',
|
||||
'test@test-mj.anymail.info', ['test+to1@anymail.info'])
|
||||
self.message.attach_alternative('<p>HTML content</p>', "text/html")
|
||||
|
||||
@@ -7,16 +7,16 @@ from mock import ANY
|
||||
|
||||
from anymail.signals import AnymailTrackingEvent
|
||||
from anymail.webhooks.mailjet import MailjetTrackingWebhookView
|
||||
from .webhook_cases import WebhookBasicAuthTestsMixin, WebhookTestCase
|
||||
from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase
|
||||
|
||||
|
||||
@tag('mailjet')
|
||||
class MailjetWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin):
|
||||
class MailjetWebhookSecurityTestCase(WebhookBasicAuthTestCase):
|
||||
def call_webhook(self):
|
||||
return self.client.post('/anymail/mailjet/tracking/',
|
||||
content_type='application/json', data=json.dumps([]))
|
||||
|
||||
# Actual tests are in WebhookBasicAuthTestsMixin
|
||||
# Actual tests are in WebhookBasicAuthTestCase
|
||||
|
||||
|
||||
@tag('mailjet')
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from email.mime.base import MIMEBase
|
||||
@@ -14,7 +12,7 @@ from anymail.exceptions import (AnymailAPIError, AnymailRecipientsRefused,
|
||||
AnymailSerializationError, AnymailUnsupportedFeature)
|
||||
from anymail.message import attach_inline_image
|
||||
|
||||
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCasesMixin
|
||||
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCases
|
||||
from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin, decode_att
|
||||
|
||||
|
||||
@@ -30,7 +28,7 @@ class MandrillBackendMockAPITestCase(RequestsBackendMockAPITestCase):
|
||||
}]"""
|
||||
|
||||
def setUp(self):
|
||||
super(MandrillBackendMockAPITestCase, self).setUp()
|
||||
super().setUp()
|
||||
# Simple message useful for many tests
|
||||
self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com'])
|
||||
|
||||
@@ -170,7 +168,7 @@ class MandrillBackendStandardEmailTests(MandrillBackendMockAPITestCase):
|
||||
self.assertFalse('images' in data['message'])
|
||||
|
||||
def test_unicode_attachment_correctly_decoded(self):
|
||||
self.message.attach(u"Une pièce jointe.html", u'<p>\u2019</p>', mimetype='text/html')
|
||||
self.message.attach("Une pièce jointe.html", '<p>\u2019</p>', mimetype='text/html')
|
||||
self.message.send()
|
||||
data = self.get_api_call_json()
|
||||
attachments = data['message']['attachments']
|
||||
@@ -610,14 +608,14 @@ class MandrillBackendRecipientsRefusedTests(MandrillBackendMockAPITestCase):
|
||||
|
||||
|
||||
@tag('mandrill')
|
||||
class MandrillBackendSessionSharingTestCase(SessionSharingTestCasesMixin, MandrillBackendMockAPITestCase):
|
||||
class MandrillBackendSessionSharingTestCase(SessionSharingTestCases, MandrillBackendMockAPITestCase):
|
||||
"""Requests session sharing tests"""
|
||||
pass # tests are defined in the mixin
|
||||
pass # tests are defined in SessionSharingTestCases
|
||||
|
||||
|
||||
@tag('mandrill')
|
||||
@override_settings(EMAIL_BACKEND="anymail.backends.mandrill.EmailBackend")
|
||||
class MandrillBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin):
|
||||
class MandrillBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase):
|
||||
"""Test backend without required settings"""
|
||||
|
||||
def test_missing_api_key(self):
|
||||
|
||||
@@ -75,8 +75,8 @@ class MandrillInboundTestCase(WebhookTestCase):
|
||||
self.assertEqual(message.to[1].addr_spec, 'other@example.com')
|
||||
self.assertEqual(message.subject, 'Test subject')
|
||||
self.assertEqual(message.date.isoformat(" "), "2017-10-12 18:03:30-07:00")
|
||||
self.assertEqual(message.text, u"It's a body\N{HORIZONTAL ELLIPSIS}\n")
|
||||
self.assertEqual(message.html, u"""<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>\n""")
|
||||
self.assertEqual(message.text, "It's a body\N{HORIZONTAL ELLIPSIS}\n")
|
||||
self.assertEqual(message.html, """<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>\n""")
|
||||
|
||||
self.assertIsNone(message.envelope_sender) # Mandrill doesn't provide sender
|
||||
self.assertEqual(message.envelope_recipient, 'delivered-to@example.com')
|
||||
|
||||
@@ -17,7 +17,7 @@ MANDRILL_TEST_API_KEY = os.getenv('MANDRILL_TEST_API_KEY')
|
||||
"Set MANDRILL_TEST_API_KEY environment variable to run integration tests")
|
||||
@override_settings(MANDRILL_API_KEY=MANDRILL_TEST_API_KEY,
|
||||
EMAIL_BACKEND="anymail.backends.mandrill.EmailBackend")
|
||||
class MandrillBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
|
||||
class MandrillBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
||||
"""Mandrill API integration tests
|
||||
|
||||
These tests run against the **live** Mandrill API, using the
|
||||
@@ -30,7 +30,7 @@ class MandrillBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(MandrillBackendIntegrationTests, self).setUp()
|
||||
super().setUp()
|
||||
self.message = mail.EmailMultiAlternatives('Anymail Mandrill integration test', 'Text content',
|
||||
'from@example.com', ['test+to1@anymail.info'])
|
||||
self.message.attach_alternative('<p>HTML content</p>', "text/html")
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from six.moves.urllib.parse import urljoin
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from base64 import b64encode
|
||||
from datetime import datetime
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.test import override_settings, tag
|
||||
from django.utils.timezone import utc
|
||||
@@ -12,8 +12,7 @@ from mock import ANY
|
||||
|
||||
from anymail.signals import AnymailTrackingEvent
|
||||
from anymail.webhooks.mandrill import MandrillCombinedWebhookView, MandrillTrackingWebhookView
|
||||
|
||||
from .webhook_cases import WebhookTestCase, WebhookBasicAuthTestsMixin
|
||||
from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase
|
||||
|
||||
TEST_WEBHOOK_KEY = 'TEST_WEBHOOK_KEY'
|
||||
|
||||
@@ -65,14 +64,14 @@ class MandrillWebhookSettingsTestCase(WebhookTestCase):
|
||||
|
||||
@tag('mandrill')
|
||||
@override_settings(ANYMAIL_MANDRILL_WEBHOOK_KEY=TEST_WEBHOOK_KEY)
|
||||
class MandrillWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin):
|
||||
class MandrillWebhookSecurityTestCase(WebhookBasicAuthTestCase):
|
||||
should_warn_if_no_auth = False # because we check webhook signature
|
||||
|
||||
def call_webhook(self):
|
||||
kwargs = mandrill_args([{'event': 'send'}])
|
||||
return self.client.post(**kwargs)
|
||||
|
||||
# Additional tests are in WebhookBasicAuthTestsMixin
|
||||
# Additional tests are in WebhookBasicAuthTestCase
|
||||
|
||||
def test_verifies_correct_signature(self):
|
||||
kwargs = mandrill_args([{'event': 'send'}])
|
||||
@@ -112,7 +111,7 @@ class MandrillWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixi
|
||||
response = self.client.post(SERVER_NAME="127.0.0.1", **kwargs)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# override WebhookBasicAuthTestsMixin version of this test
|
||||
# override WebhookBasicAuthTestCase version of this test
|
||||
@override_settings(ANYMAIL={'WEBHOOK_SECRET': ['cred1:pass1', 'cred2:pass2']})
|
||||
def test_supports_credential_rotation(self):
|
||||
"""You can supply a list of basic auth credentials, and any is allowed"""
|
||||
|
||||
@@ -10,7 +10,7 @@ from .utils import AnymailTestMixin, sample_image_content
|
||||
class InlineImageTests(AnymailTestMixin, SimpleTestCase):
|
||||
def setUp(self):
|
||||
self.message = EmailMultiAlternatives()
|
||||
super(InlineImageTests, self).setUp()
|
||||
super().setUp()
|
||||
|
||||
@patch("email.utils.socket.getfqdn")
|
||||
def test_default_domain(self, mock_getfqdn):
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
from base64 import b64encode
|
||||
from decimal import Decimal
|
||||
@@ -14,7 +13,7 @@ from anymail.exceptions import (
|
||||
AnymailUnsupportedFeature, AnymailRecipientsRefused, AnymailInvalidAddress)
|
||||
from anymail.message import attach_inline_image_file, AnymailMessage
|
||||
|
||||
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCasesMixin
|
||||
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCases
|
||||
from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin, decode_att
|
||||
|
||||
|
||||
@@ -31,7 +30,7 @@ class PostmarkBackendMockAPITestCase(RequestsBackendMockAPITestCase):
|
||||
}"""
|
||||
|
||||
def setUp(self):
|
||||
super(PostmarkBackendMockAPITestCase, self).setUp()
|
||||
super().setUp()
|
||||
# Simple message useful for many tests
|
||||
self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com'])
|
||||
|
||||
@@ -182,13 +181,13 @@ class PostmarkBackendStandardEmailTests(PostmarkBackendMockAPITestCase):
|
||||
self.assertNotIn('ContentID', attachments[2])
|
||||
|
||||
def test_unicode_attachment_correctly_decoded(self):
|
||||
self.message.attach(u"Une pièce jointe.html", u'<p>\u2019</p>', mimetype='text/html')
|
||||
self.message.attach("Une pièce jointe.html", '<p>\u2019</p>', mimetype='text/html')
|
||||
self.message.send()
|
||||
data = self.get_api_call_json()
|
||||
self.assertEqual(data['Attachments'], [{
|
||||
'Name': u'Une pièce jointe.html',
|
||||
'Name': 'Une pièce jointe.html',
|
||||
'ContentType': 'text/html',
|
||||
'Content': b64encode(u'<p>\u2019</p>'.encode('utf-8')).decode('ascii')
|
||||
'Content': b64encode('<p>\u2019</p>'.encode('utf-8')).decode('ascii')
|
||||
}])
|
||||
|
||||
def test_embedded_images(self):
|
||||
@@ -758,14 +757,14 @@ class PostmarkBackendRecipientsRefusedTests(PostmarkBackendMockAPITestCase):
|
||||
|
||||
|
||||
@tag('postmark')
|
||||
class PostmarkBackendSessionSharingTestCase(SessionSharingTestCasesMixin, PostmarkBackendMockAPITestCase):
|
||||
class PostmarkBackendSessionSharingTestCase(SessionSharingTestCases, PostmarkBackendMockAPITestCase):
|
||||
"""Requests session sharing tests"""
|
||||
pass # tests are defined in the mixin
|
||||
pass # tests are defined in SessionSharingTestCases
|
||||
|
||||
|
||||
@tag('postmark')
|
||||
@override_settings(EMAIL_BACKEND="anymail.backends.postmark.EmailBackend")
|
||||
class PostmarkBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin):
|
||||
class PostmarkBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase):
|
||||
"""Test ESP backend without required settings in place"""
|
||||
|
||||
def test_missing_api_key(self):
|
||||
|
||||
@@ -163,7 +163,7 @@ class PostmarkInboundTestCase(WebhookTestCase):
|
||||
self.assertEqual(len(attachments), 2)
|
||||
self.assertEqual(attachments[0].get_filename(), 'test.txt')
|
||||
self.assertEqual(attachments[0].get_content_type(), 'text/plain')
|
||||
self.assertEqual(attachments[0].get_content_text(), u'test attachment')
|
||||
self.assertEqual(attachments[0].get_content_text(), 'test attachment')
|
||||
self.assertEqual(attachments[1].get_content_type(), 'message/rfc822')
|
||||
self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ POSTMARK_TEST_TEMPLATE_ID = os.getenv('POSTMARK_TEST_TEMPLATE_ID')
|
||||
@tag('postmark', 'live')
|
||||
@override_settings(ANYMAIL_POSTMARK_SERVER_TOKEN="POSTMARK_API_TEST",
|
||||
EMAIL_BACKEND="anymail.backends.postmark.EmailBackend")
|
||||
class PostmarkBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
|
||||
class PostmarkBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
||||
"""Postmark API integration tests
|
||||
|
||||
These tests run against the **live** Postmark API, but using a
|
||||
@@ -26,7 +26,7 @@ class PostmarkBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(PostmarkBackendIntegrationTests, self).setUp()
|
||||
super().setUp()
|
||||
self.message = AnymailMessage('Anymail Postmark integration test', 'Text content',
|
||||
'from@example.com', ['test+to1@anymail.info'])
|
||||
self.message.attach_alternative('<p>HTML content</p>', "text/html")
|
||||
|
||||
@@ -8,16 +8,16 @@ from mock import ANY
|
||||
from anymail.exceptions import AnymailConfigurationError
|
||||
from anymail.signals import AnymailTrackingEvent
|
||||
from anymail.webhooks.postmark import PostmarkTrackingWebhookView
|
||||
from .webhook_cases import WebhookBasicAuthTestsMixin, WebhookTestCase
|
||||
from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase
|
||||
|
||||
|
||||
@tag('postmark')
|
||||
class PostmarkWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin):
|
||||
class PostmarkWebhookSecurityTestCase(WebhookBasicAuthTestCase):
|
||||
def call_webhook(self):
|
||||
return self.client.post('/anymail/postmark/tracking/',
|
||||
content_type='application/json', data=json.dumps({}))
|
||||
|
||||
# Actual tests are in WebhookBasicAuthTestsMixin
|
||||
# Actual tests are in WebhookBasicAuthTestCase
|
||||
|
||||
|
||||
@tag('postmark')
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from base64 import b64encode, b64decode
|
||||
from calendar import timegm
|
||||
from datetime import date, datetime
|
||||
@@ -7,7 +5,6 @@ from decimal import Decimal
|
||||
from email.mime.base import MIMEBase
|
||||
from email.mime.image import MIMEImage
|
||||
|
||||
import six
|
||||
from django.core import mail
|
||||
from django.test import SimpleTestCase, override_settings, tag
|
||||
from django.utils.timezone import get_fixed_timezone, override as override_current_timezone
|
||||
@@ -17,12 +14,9 @@ from anymail.exceptions import (AnymailAPIError, AnymailConfigurationError, Anym
|
||||
AnymailUnsupportedFeature, AnymailWarning)
|
||||
from anymail.message import attach_inline_image_file
|
||||
|
||||
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCasesMixin
|
||||
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCases
|
||||
from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin
|
||||
|
||||
# noinspection PyUnresolvedReferences
|
||||
longtype = int if six.PY3 else long # NOQA: F821
|
||||
|
||||
|
||||
@tag('sendgrid')
|
||||
@override_settings(EMAIL_BACKEND='anymail.backends.sendgrid.EmailBackend',
|
||||
@@ -32,7 +26,7 @@ class SendGridBackendMockAPITestCase(RequestsBackendMockAPITestCase):
|
||||
DEFAULT_STATUS_CODE = 202 # SendGrid v3 uses '202 Accepted' for success (in most cases)
|
||||
|
||||
def setUp(self):
|
||||
super(SendGridBackendMockAPITestCase, self).setUp()
|
||||
super().setUp()
|
||||
|
||||
# Patch uuid4 to generate predictable anymail_ids for testing
|
||||
patch_uuid4 = patch('anymail.backends.sendgrid.uuid.uuid4',
|
||||
@@ -153,13 +147,12 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase):
|
||||
self.assertEqual(data['content'][0], {'type': "text/html", 'value': html_content})
|
||||
|
||||
def test_extra_headers(self):
|
||||
self.message.extra_headers = {'X-Custom': 'string', 'X-Num': 123, 'X-Long': longtype(123),
|
||||
self.message.extra_headers = {'X-Custom': 'string', 'X-Num': 123,
|
||||
'Reply-To': '"Do Not Reply" <noreply@example.com>'}
|
||||
self.message.send()
|
||||
data = self.get_api_call_json()
|
||||
self.assertEqual(data['headers']['X-Custom'], 'string')
|
||||
self.assertEqual(data['headers']['X-Num'], '123') # converted to string (undoc'd SendGrid requirement)
|
||||
self.assertEqual(data['headers']['X-Long'], '123') # converted to string (undoc'd SendGrid requirement)
|
||||
# Reply-To must be moved to separate param
|
||||
self.assertNotIn('Reply-To', data['headers'])
|
||||
self.assertEqual(data['reply_to'], {'name': "Do Not Reply", 'email': "noreply@example.com"})
|
||||
@@ -222,11 +215,11 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase):
|
||||
'type': "application/pdf"})
|
||||
|
||||
def test_unicode_attachment_correctly_decoded(self):
|
||||
self.message.attach(u"Une pièce jointe.html", u'<p>\u2019</p>', mimetype='text/html')
|
||||
self.message.attach("Une pièce jointe.html", '<p>\u2019</p>', mimetype='text/html')
|
||||
self.message.send()
|
||||
attachment = self.get_api_call_json()['attachments'][0]
|
||||
self.assertEqual(attachment['filename'], u'Une pièce jointe.html')
|
||||
self.assertEqual(b64decode(attachment['content']).decode('utf-8'), u'<p>\u2019</p>')
|
||||
self.assertEqual(attachment['filename'], 'Une pièce jointe.html')
|
||||
self.assertEqual(b64decode(attachment['content']).decode('utf-8'), '<p>\u2019</p>')
|
||||
|
||||
def test_embedded_images(self):
|
||||
image_filename = SAMPLE_IMAGE_FILENAME
|
||||
@@ -348,14 +341,14 @@ class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase):
|
||||
self.message.send()
|
||||
|
||||
def test_metadata(self):
|
||||
self.message.metadata = {'user_id': "12345", 'items': 6, 'float': 98.6, 'long': longtype(123)}
|
||||
self.message.metadata = {'user_id': "12345", 'items': 6, 'float': 98.6}
|
||||
self.message.send()
|
||||
data = self.get_api_call_json()
|
||||
data['custom_args'].pop('anymail_id', None) # remove anymail_id we added for tracking
|
||||
self.assertEqual(data['custom_args'], {'user_id': "12345",
|
||||
'items': "6", # int converted to a string,
|
||||
'float': "98.6", # float converted to a string (watch binary rounding!)
|
||||
'long': "123"}) # long converted to string
|
||||
})
|
||||
|
||||
def test_send_at(self):
|
||||
utc_plus_6 = get_fixed_timezone(6 * 60)
|
||||
@@ -879,14 +872,14 @@ class SendGridBackendRecipientsRefusedTests(SendGridBackendMockAPITestCase):
|
||||
|
||||
|
||||
@tag('sendgrid')
|
||||
class SendGridBackendSessionSharingTestCase(SessionSharingTestCasesMixin, SendGridBackendMockAPITestCase):
|
||||
class SendGridBackendSessionSharingTestCase(SessionSharingTestCases, SendGridBackendMockAPITestCase):
|
||||
"""Requests session sharing tests"""
|
||||
pass # tests are defined in the mixin
|
||||
pass # tests are defined in SessionSharingTestCases
|
||||
|
||||
|
||||
@tag('sendgrid')
|
||||
@override_settings(EMAIL_BACKEND="anymail.backends.sendgrid.EmailBackend")
|
||||
class SendGridBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin):
|
||||
class SendGridBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase):
|
||||
"""Test ESP backend without required settings in place"""
|
||||
|
||||
def test_missing_auth(self):
|
||||
@@ -896,7 +889,7 @@ class SendGridBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin)
|
||||
|
||||
@tag('sendgrid')
|
||||
@override_settings(EMAIL_BACKEND="anymail.backends.sendgrid.EmailBackend")
|
||||
class SendGridBackendDisallowsV2Tests(SimpleTestCase, AnymailTestMixin):
|
||||
class SendGridBackendDisallowsV2Tests(AnymailTestMixin, SimpleTestCase):
|
||||
"""Using v2-API-only features should cause errors with v3 backend"""
|
||||
|
||||
@override_settings(ANYMAIL={'SENDGRID_USERNAME': 'sg_username', 'SENDGRID_PASSWORD': 'sg_password'})
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import json
|
||||
from io import BytesIO
|
||||
from textwrap import dedent
|
||||
|
||||
import six
|
||||
from django.test import tag
|
||||
from mock import ANY
|
||||
|
||||
@@ -91,13 +89,13 @@ class SendgridInboundTestCase(WebhookTestCase):
|
||||
])
|
||||
|
||||
def test_attachments(self):
|
||||
att1 = six.BytesIO('test attachment'.encode('utf-8'))
|
||||
att1 = BytesIO('test attachment'.encode('utf-8'))
|
||||
att1.name = 'test.txt'
|
||||
image_content = sample_image_content()
|
||||
att2 = six.BytesIO(image_content)
|
||||
att2 = BytesIO(image_content)
|
||||
att2.name = 'image.png'
|
||||
email_content = sample_email_content()
|
||||
att3 = six.BytesIO(email_content)
|
||||
att3 = BytesIO(email_content)
|
||||
att3.content_type = 'message/rfc822; charset="us-ascii"'
|
||||
raw_event = {
|
||||
'headers': '',
|
||||
@@ -124,7 +122,7 @@ class SendgridInboundTestCase(WebhookTestCase):
|
||||
self.assertEqual(len(attachments), 2)
|
||||
self.assertEqual(attachments[0].get_filename(), 'test.txt')
|
||||
self.assertEqual(attachments[0].get_content_type(), 'text/plain')
|
||||
self.assertEqual(attachments[0].get_content_text(), u'test attachment')
|
||||
self.assertEqual(attachments[0].get_content_text(), 'test attachment')
|
||||
self.assertEqual(attachments[1].get_content_type(), 'message/rfc822')
|
||||
self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content)
|
||||
|
||||
@@ -183,8 +181,8 @@ class SendgridInboundTestCase(WebhookTestCase):
|
||||
self.assertEqual(message.envelope_sender, 'envelope-from@example.org')
|
||||
self.assertEqual(message.envelope_recipient, 'test@inbound.example.com')
|
||||
self.assertEqual(message.subject, 'Raw MIME test')
|
||||
self.assertEqual(message.text, u"It's a body\N{HORIZONTAL ELLIPSIS}\n")
|
||||
self.assertEqual(message.html, u"""<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>\n""")
|
||||
self.assertEqual(message.text, "It's a body\N{HORIZONTAL ELLIPSIS}\n")
|
||||
self.assertEqual(message.html, """<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>\n""")
|
||||
|
||||
def test_inbound_charsets(self):
|
||||
# Captured (sanitized) from actual SendGrid inbound webhook payload 7/2020,
|
||||
@@ -233,11 +231,11 @@ class SendgridInboundTestCase(WebhookTestCase):
|
||||
event = kwargs['event']
|
||||
message = event.message
|
||||
|
||||
self.assertEqual(message.from_email.display_name, u"Opérateur de test")
|
||||
self.assertEqual(message.from_email.display_name, "Opérateur de test")
|
||||
self.assertEqual(message.from_email.addr_spec, "sender@example.com")
|
||||
self.assertEqual(len(message.to), 1)
|
||||
self.assertEqual(message.to[0].display_name, u"Récipiendaire précieux")
|
||||
self.assertEqual(message.to[0].display_name, "Récipiendaire précieux")
|
||||
self.assertEqual(message.to[0].addr_spec, "inbound@sg.example.com")
|
||||
self.assertEqual(message.subject, u"Como usted pidió")
|
||||
self.assertEqual(message.text, u"Test the ESP’s inbound charset handling…")
|
||||
self.assertEqual(message.html, u"<p>¿Esto se ve como esperabas?</p>")
|
||||
self.assertEqual(message.subject, "Como usted pidió")
|
||||
self.assertEqual(message.text, "Test the ESP’s inbound charset handling…")
|
||||
self.assertEqual(message.html, "<p>¿Esto se ve como esperabas?</p>")
|
||||
|
||||
@@ -22,7 +22,7 @@ SENDGRID_TEST_TEMPLATE_ID = os.getenv('SENDGRID_TEST_TEMPLATE_ID')
|
||||
"mail_settings": {"sandbox_mode": {"enable": True}},
|
||||
}},
|
||||
EMAIL_BACKEND="anymail.backends.sendgrid.EmailBackend")
|
||||
class SendGridBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
|
||||
class SendGridBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
||||
"""SendGrid v3 API integration tests
|
||||
|
||||
These tests run against the **live** SendGrid API, using the
|
||||
@@ -38,7 +38,7 @@ class SendGridBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(SendGridBackendIntegrationTests, self).setUp()
|
||||
super().setUp()
|
||||
self.message = AnymailMessage('Anymail SendGrid integration test', 'Text content',
|
||||
'from@example.com', ['to@sink.sendgrid.net'])
|
||||
self.message.attach_alternative('<p>HTML content</p>', "text/html")
|
||||
|
||||
@@ -7,16 +7,16 @@ from mock import ANY
|
||||
|
||||
from anymail.signals import AnymailTrackingEvent
|
||||
from anymail.webhooks.sendgrid import SendGridTrackingWebhookView
|
||||
from .webhook_cases import WebhookBasicAuthTestsMixin, WebhookTestCase
|
||||
from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase
|
||||
|
||||
|
||||
@tag('sendgrid')
|
||||
class SendGridWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin):
|
||||
class SendGridWebhookSecurityTestCase(WebhookBasicAuthTestCase):
|
||||
def call_webhook(self):
|
||||
return self.client.post('/anymail/sendgrid/tracking/',
|
||||
content_type='application/json', data=json.dumps([]))
|
||||
|
||||
# Actual tests are in WebhookBasicAuthTestsMixin
|
||||
# Actual tests are in WebhookBasicAuthTestCase
|
||||
|
||||
|
||||
@tag('sendgrid')
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import json
|
||||
from base64 import b64encode, b64decode
|
||||
from datetime import datetime
|
||||
@@ -7,7 +5,6 @@ from decimal import Decimal
|
||||
from email.mime.base import MIMEBase
|
||||
from email.mime.image import MIMEImage
|
||||
|
||||
import six
|
||||
from django.core import mail
|
||||
from django.test import SimpleTestCase, override_settings, tag
|
||||
from django.utils.timezone import get_fixed_timezone, override as override_current_timezone
|
||||
@@ -15,12 +12,9 @@ from django.utils.timezone import get_fixed_timezone, override as override_curre
|
||||
from anymail.exceptions import (AnymailAPIError, AnymailConfigurationError, AnymailSerializationError,
|
||||
AnymailUnsupportedFeature)
|
||||
from anymail.message import attach_inline_image_file
|
||||
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCasesMixin
|
||||
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCases
|
||||
from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin
|
||||
|
||||
# noinspection PyUnresolvedReferences
|
||||
longtype = int if six.PY3 else long # NOQA: F821
|
||||
|
||||
|
||||
@tag('sendinblue')
|
||||
@override_settings(EMAIL_BACKEND='anymail.backends.sendinblue.EmailBackend',
|
||||
@@ -31,7 +25,7 @@ class SendinBlueBackendMockAPITestCase(RequestsBackendMockAPITestCase):
|
||||
DEFAULT_STATUS_CODE = 201 # SendinBlue v3 uses '201 Created' for success (in most cases)
|
||||
|
||||
def setUp(self):
|
||||
super(SendinBlueBackendMockAPITestCase, self).setUp()
|
||||
super().setUp()
|
||||
# Simple message useful for many tests
|
||||
self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com'])
|
||||
|
||||
@@ -119,13 +113,12 @@ class SendinBlueBackendStandardEmailTests(SendinBlueBackendMockAPITestCase):
|
||||
self.assertNotIn('textContent', data)
|
||||
|
||||
def test_extra_headers(self):
|
||||
self.message.extra_headers = {'X-Custom': 'string', 'X-Num': 123, 'X-Long': longtype(123),
|
||||
self.message.extra_headers = {'X-Custom': 'string', 'X-Num': 123,
|
||||
'Reply-To': '"Do Not Reply" <noreply@example.com>'}
|
||||
self.message.send()
|
||||
data = self.get_api_call_json()
|
||||
self.assertEqual(data['headers']['X-Custom'], 'string')
|
||||
self.assertEqual(data['headers']['X-Num'], 123)
|
||||
self.assertEqual(data['headers']['X-Long'], 123)
|
||||
# Reply-To must be moved to separate param
|
||||
self.assertNotIn('Reply-To', data['headers'])
|
||||
self.assertEqual(data['replyTo'], {'name': "Do Not Reply", 'email': "noreply@example.com"})
|
||||
@@ -185,11 +178,11 @@ class SendinBlueBackendStandardEmailTests(SendinBlueBackendMockAPITestCase):
|
||||
'content': b64encode(pdf_content).decode('ascii')})
|
||||
|
||||
def test_unicode_attachment_correctly_decoded(self):
|
||||
self.message.attach(u"Une pièce jointe.html", u'<p>\u2019</p>', mimetype='text/html')
|
||||
self.message.attach("Une pièce jointe.html", '<p>\u2019</p>', mimetype='text/html')
|
||||
self.message.send()
|
||||
attachment = self.get_api_call_json()['attachment'][0]
|
||||
self.assertEqual(attachment['name'], u'Une pièce jointe.html')
|
||||
self.assertEqual(b64decode(attachment['content']).decode('utf-8'), u'<p>\u2019</p>')
|
||||
self.assertEqual(attachment['name'], 'Une pièce jointe.html')
|
||||
self.assertEqual(b64decode(attachment['content']).decode('utf-8'), '<p>\u2019</p>')
|
||||
|
||||
def test_embedded_images(self):
|
||||
# SendinBlue doesn't support inline image
|
||||
@@ -284,7 +277,7 @@ class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase):
|
||||
self.message.send()
|
||||
|
||||
def test_metadata(self):
|
||||
self.message.metadata = {'user_id': "12345", 'items': 6, 'float': 98.6, 'long': longtype(123)}
|
||||
self.message.metadata = {'user_id': "12345", 'items': 6, 'float': 98.6}
|
||||
self.message.send()
|
||||
|
||||
data = self.get_api_call_json()
|
||||
@@ -293,7 +286,6 @@ class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase):
|
||||
self.assertEqual(metadata['user_id'], "12345")
|
||||
self.assertEqual(metadata['items'], 6)
|
||||
self.assertEqual(metadata['float'], 98.6)
|
||||
self.assertEqual(metadata['long'], longtype(123))
|
||||
|
||||
def test_send_at(self):
|
||||
utc_plus_6 = get_fixed_timezone(6 * 60)
|
||||
@@ -451,14 +443,14 @@ class SendinBlueBackendRecipientsRefusedTests(SendinBlueBackendMockAPITestCase):
|
||||
|
||||
|
||||
@tag('sendinblue')
|
||||
class SendinBlueBackendSessionSharingTestCase(SessionSharingTestCasesMixin, SendinBlueBackendMockAPITestCase):
|
||||
class SendinBlueBackendSessionSharingTestCase(SessionSharingTestCases, SendinBlueBackendMockAPITestCase):
|
||||
"""Requests session sharing tests"""
|
||||
pass # tests are defined in the mixin
|
||||
pass # tests are defined in SessionSharingTestCases
|
||||
|
||||
|
||||
@tag('sendinblue')
|
||||
@override_settings(EMAIL_BACKEND="anymail.backends.sendinblue.EmailBackend")
|
||||
class SendinBlueBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin):
|
||||
class SendinBlueBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase):
|
||||
"""Test ESP backend without required settings in place"""
|
||||
|
||||
def test_missing_auth(self):
|
||||
|
||||
@@ -18,7 +18,7 @@ SENDINBLUE_TEST_API_KEY = os.getenv('SENDINBLUE_TEST_API_KEY')
|
||||
@override_settings(ANYMAIL_SENDINBLUE_API_KEY=SENDINBLUE_TEST_API_KEY,
|
||||
ANYMAIL_SENDINBLUE_SEND_DEFAULTS=dict(),
|
||||
EMAIL_BACKEND="anymail.backends.sendinblue.EmailBackend")
|
||||
class SendinBlueBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
|
||||
class SendinBlueBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
||||
"""SendinBlue v3 API integration tests
|
||||
|
||||
SendinBlue doesn't have sandbox so these tests run
|
||||
@@ -31,7 +31,7 @@ class SendinBlueBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(SendinBlueBackendIntegrationTests, self).setUp()
|
||||
super().setUp()
|
||||
|
||||
self.message = AnymailMessage('Anymail SendinBlue integration test', 'Text content',
|
||||
'from@test-sb.anymail.info', ['test+to1@anymail.info'])
|
||||
|
||||
@@ -7,16 +7,16 @@ from mock import ANY
|
||||
|
||||
from anymail.signals import AnymailTrackingEvent
|
||||
from anymail.webhooks.sendinblue import SendinBlueTrackingWebhookView
|
||||
from .webhook_cases import WebhookBasicAuthTestsMixin, WebhookTestCase
|
||||
from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase
|
||||
|
||||
|
||||
@tag('sendinblue')
|
||||
class SendinBlueWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin):
|
||||
class SendinBlueWebhookSecurityTestCase(WebhookBasicAuthTestCase):
|
||||
def call_webhook(self):
|
||||
return self.client.post('/anymail/sendinblue/tracking/',
|
||||
content_type='application/json', data=json.dumps({}))
|
||||
|
||||
# Actual tests are in WebhookBasicAuthTestsMixin
|
||||
# Actual tests are in WebhookBasicAuthTestCase
|
||||
|
||||
|
||||
@tag('sendinblue')
|
||||
|
||||
@@ -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/'
|
||||
@@ -1,5 +1,5 @@
|
||||
from django.conf.urls import include, url
|
||||
from django.urls import include, re_path
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^anymail/', include('anymail.urls')),
|
||||
re_path(r'^anymail/', include('anymail.urls')),
|
||||
]
|
||||
|
||||
@@ -1,32 +1,30 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from datetime import datetime, date
|
||||
import os
|
||||
from datetime import date, datetime
|
||||
from email.mime.base import MIMEBase
|
||||
from email.mime.image import MIMEImage
|
||||
import os
|
||||
from io import BytesIO
|
||||
|
||||
import requests
|
||||
import six
|
||||
from django.core import mail
|
||||
from django.test import SimpleTestCase, override_settings, tag
|
||||
from django.utils.timezone import get_fixed_timezone, override as override_current_timezone, utc
|
||||
from mock import patch
|
||||
|
||||
from anymail.exceptions import (AnymailAPIError, AnymailUnsupportedFeature, AnymailRecipientsRefused,
|
||||
AnymailConfigurationError, AnymailInvalidAddress)
|
||||
from anymail.exceptions import (
|
||||
AnymailAPIError, AnymailConfigurationError, AnymailInvalidAddress, AnymailRecipientsRefused,
|
||||
AnymailUnsupportedFeature)
|
||||
from anymail.message import attach_inline_image_file
|
||||
|
||||
from .utils import AnymailTestMixin, decode_att, SAMPLE_IMAGE_FILENAME, sample_image_path, sample_image_content
|
||||
from .utils import AnymailTestMixin, SAMPLE_IMAGE_FILENAME, decode_att, sample_image_content, sample_image_path
|
||||
|
||||
|
||||
@tag('sparkpost')
|
||||
@override_settings(EMAIL_BACKEND='anymail.backends.sparkpost.EmailBackend',
|
||||
ANYMAIL={'SPARKPOST_API_KEY': 'test_api_key'})
|
||||
class SparkPostBackendMockAPITestCase(SimpleTestCase, AnymailTestMixin):
|
||||
class SparkPostBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
|
||||
"""TestCase that uses SparkPostEmailBackend with a mocked transmissions.send API"""
|
||||
|
||||
def setUp(self):
|
||||
super(SparkPostBackendMockAPITestCase, self).setUp()
|
||||
super().setUp()
|
||||
self.patch_send = patch('sparkpost.Transmissions.send', autospec=True)
|
||||
self.mock_send = self.patch_send.start()
|
||||
self.addCleanup(self.patch_send.stop)
|
||||
@@ -52,7 +50,7 @@ class SparkPostBackendMockAPITestCase(SimpleTestCase, AnymailTestMixin):
|
||||
response = requests.Response()
|
||||
response.status_code = status_code
|
||||
response.encoding = encoding
|
||||
response.raw = six.BytesIO(raw)
|
||||
response.raw = BytesIO(raw)
|
||||
response.url = "/mock/send"
|
||||
self.mock_send.side_effect = SparkPostAPIException(response)
|
||||
|
||||
@@ -205,7 +203,7 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase):
|
||||
def test_unicode_attachment_correctly_decoded(self):
|
||||
# Slight modification from the Django unicode docs:
|
||||
# http://django.readthedocs.org/en/latest/ref/unicode.html#email
|
||||
self.message.attach(u"Une pièce jointe.html", u'<p>\u2019</p>', mimetype='text/html')
|
||||
self.message.attach("Une pièce jointe.html", '<p>\u2019</p>', mimetype='text/html')
|
||||
self.message.send()
|
||||
params = self.get_send_params()
|
||||
attachments = params['attachments']
|
||||
@@ -609,7 +607,7 @@ class SparkPostBackendRecipientsRefusedTests(SparkPostBackendMockAPITestCase):
|
||||
|
||||
@tag('sparkpost')
|
||||
@override_settings(EMAIL_BACKEND="anymail.backends.sparkpost.EmailBackend")
|
||||
class SparkPostBackendConfigurationTests(SimpleTestCase, AnymailTestMixin):
|
||||
class SparkPostBackendConfigurationTests(AnymailTestMixin, SimpleTestCase):
|
||||
"""Test various SparkPost client options"""
|
||||
|
||||
def test_missing_api_key(self):
|
||||
|
||||
@@ -80,8 +80,8 @@ class SparkpostInboundTestCase(WebhookTestCase):
|
||||
['cc@example.com'])
|
||||
self.assertEqual(message.subject, 'Test subject')
|
||||
self.assertEqual(message.date.isoformat(" "), "2017-10-11 18:31:04-07:00")
|
||||
self.assertEqual(message.text, u"It's a body\N{HORIZONTAL ELLIPSIS}\n")
|
||||
self.assertEqual(message.html, u"""<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>\n""")
|
||||
self.assertEqual(message.text, "It's a body\N{HORIZONTAL ELLIPSIS}\n")
|
||||
self.assertEqual(message.html, """<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>\n""")
|
||||
|
||||
self.assertEqual(message.envelope_sender, 'envelope-from@example.org')
|
||||
self.assertEqual(message.envelope_recipient, 'test@inbound.example.com')
|
||||
@@ -158,7 +158,7 @@ class SparkpostInboundTestCase(WebhookTestCase):
|
||||
self.assertEqual(len(attachments), 2)
|
||||
self.assertEqual(attachments[0].get_filename(), 'test.txt')
|
||||
self.assertEqual(attachments[0].get_content_type(), 'text/plain')
|
||||
self.assertEqual(attachments[0].get_content_text(), u'test attachment')
|
||||
self.assertEqual(attachments[0].get_content_text(), 'test attachment')
|
||||
self.assertEqual(attachments[1].get_content_type(), 'message/rfc822')
|
||||
self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import unittest
|
||||
import warnings
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.test import SimpleTestCase, override_settings, tag
|
||||
@@ -18,7 +19,7 @@ SPARKPOST_TEST_API_KEY = os.getenv('SPARKPOST_TEST_API_KEY')
|
||||
"to run SparkPost integration tests")
|
||||
@override_settings(ANYMAIL_SPARKPOST_API_KEY=SPARKPOST_TEST_API_KEY,
|
||||
EMAIL_BACKEND="anymail.backends.sparkpost.EmailBackend")
|
||||
class SparkPostBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
|
||||
class SparkPostBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
||||
"""SparkPost API integration tests
|
||||
|
||||
These tests run against the **live** SparkPost API, using the
|
||||
@@ -28,19 +29,31 @@ class SparkPostBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
|
||||
SparkPost doesn't offer a test mode -- it tries to send everything
|
||||
you ask. To avoid stacking up a pile of undeliverable @example.com
|
||||
emails, the tests use SparkPost's "sink domain" @*.sink.sparkpostmail.com.
|
||||
https://support.sparkpost.com/customer/en/portal/articles/2361300-how-to-test-integrations
|
||||
https://www.sparkpost.com/docs/faq/using-sink-server/
|
||||
|
||||
SparkPost also doesn't support arbitrary senders (so no from@example.com).
|
||||
We've set up @test-sp.anymail.info as a validated sending domain for these tests.
|
||||
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(SparkPostBackendIntegrationTests, self).setUp()
|
||||
super().setUp()
|
||||
self.message = AnymailMessage('Anymail SparkPost integration test', 'Text content',
|
||||
'test@test-sp.anymail.info', ['to@test.sink.sparkpostmail.com'])
|
||||
self.message.attach_alternative('<p>HTML content</p>', "text/html")
|
||||
|
||||
# The SparkPost Python package uses requests directly, without managing sessions, and relies
|
||||
# on GC to close connections. This leads to harmless (but valid) warnings about unclosed
|
||||
# ssl.SSLSocket during cleanup: https://github.com/psf/requests/issues/1882
|
||||
# There's not much we can do about that, short of switching from the SparkPost package
|
||||
# to our own requests backend implementation (which *does* manage sessions properly).
|
||||
# Unless/until we do that, filter the warnings to avoid test noise.
|
||||
# Filter in TestCase.setUp because unittest resets the warning filters for each test.
|
||||
# https://stackoverflow.com/a/26620811/647002
|
||||
from anymail.backends.base_requests import AnymailRequestsBackend
|
||||
from anymail.backends.sparkpost import EmailBackend as SparkPostBackend
|
||||
assert not issubclass(SparkPostBackend, AnymailRequestsBackend) # else this filter can be removed
|
||||
warnings.filterwarnings("ignore", message=r"unclosed <ssl\.SSLSocket", category=ResourceWarning)
|
||||
|
||||
def test_simple_send(self):
|
||||
# Example of getting the SparkPost send status and transmission id from the message
|
||||
sent_count = self.message.send()
|
||||
|
||||
@@ -8,16 +8,16 @@ from mock import ANY
|
||||
from anymail.signals import AnymailTrackingEvent
|
||||
from anymail.webhooks.sparkpost import SparkPostTrackingWebhookView
|
||||
|
||||
from .webhook_cases import WebhookBasicAuthTestsMixin, WebhookTestCase
|
||||
from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase
|
||||
|
||||
|
||||
@tag('sparkpost')
|
||||
class SparkPostWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin):
|
||||
class SparkPostWebhookSecurityTestCase(WebhookBasicAuthTestCase):
|
||||
def call_webhook(self):
|
||||
return self.client.post('/anymail/sparkpost/tracking/',
|
||||
content_type='application/json', data=json.dumps([]))
|
||||
|
||||
# Actual tests are in WebhookBasicAuthTestsMixin
|
||||
# Actual tests are in WebhookBasicAuthTestCase
|
||||
|
||||
|
||||
@tag('sparkpost')
|
||||
|
||||
@@ -4,22 +4,11 @@ import base64
|
||||
import copy
|
||||
import pickle
|
||||
from email.mime.image import MIMEImage
|
||||
from unittest import skipIf
|
||||
|
||||
import six
|
||||
from django.http import QueryDict
|
||||
from django.test import SimpleTestCase, RequestFactory, override_settings
|
||||
from django.utils.translation import ugettext_lazy
|
||||
|
||||
try:
|
||||
from django.utils.text import format_lazy # Django >= 1.11
|
||||
except ImportError:
|
||||
format_lazy = None
|
||||
|
||||
try:
|
||||
from django.utils.translation import string_concat # Django < 2.1
|
||||
except ImportError:
|
||||
string_concat = None
|
||||
from django.utils.text import format_lazy
|
||||
from django.utils.translation import gettext_lazy
|
||||
|
||||
from anymail.exceptions import AnymailInvalidAddress, _LazyError
|
||||
from anymail.utils import (
|
||||
@@ -66,11 +55,11 @@ class ParseAddressListTests(SimpleTestCase):
|
||||
self.assertEqual(parsed.address, 'Display Name <test@example.com>')
|
||||
|
||||
def test_unicode_display_name(self):
|
||||
parsed_list = parse_address_list([u'"Unicode \N{HEAVY BLACK HEART}" <test@example.com>'])
|
||||
parsed_list = parse_address_list(['"Unicode \N{HEAVY BLACK HEART}" <test@example.com>'])
|
||||
self.assertEqual(len(parsed_list), 1)
|
||||
parsed = parsed_list[0]
|
||||
self.assertEqual(parsed.addr_spec, "test@example.com")
|
||||
self.assertEqual(parsed.display_name, u"Unicode \N{HEAVY BLACK HEART}")
|
||||
self.assertEqual(parsed.display_name, "Unicode \N{HEAVY BLACK HEART}")
|
||||
# formatted display-name automatically shifts to quoted-printable/base64 for non-ascii chars:
|
||||
self.assertEqual(parsed.address, '=?utf-8?b?VW5pY29kZSDinaQ=?= <test@example.com>')
|
||||
|
||||
@@ -83,13 +72,13 @@ class ParseAddressListTests(SimpleTestCase):
|
||||
parse_address_list(['Display Name, Inc. <test@example.com>'])
|
||||
|
||||
def test_idn(self):
|
||||
parsed_list = parse_address_list([u"idn@\N{ENVELOPE}.example.com"])
|
||||
parsed_list = parse_address_list(["idn@\N{ENVELOPE}.example.com"])
|
||||
self.assertEqual(len(parsed_list), 1)
|
||||
parsed = parsed_list[0]
|
||||
self.assertEqual(parsed.addr_spec, u"idn@\N{ENVELOPE}.example.com")
|
||||
self.assertEqual(parsed.addr_spec, "idn@\N{ENVELOPE}.example.com")
|
||||
self.assertEqual(parsed.address, "idn@xn--4bi.example.com") # punycode-encoded domain
|
||||
self.assertEqual(parsed.username, "idn")
|
||||
self.assertEqual(parsed.domain, u"\N{ENVELOPE}.example.com")
|
||||
self.assertEqual(parsed.domain, "\N{ENVELOPE}.example.com")
|
||||
|
||||
def test_none_address(self):
|
||||
# used for, e.g., telling Mandrill to use template default from_email
|
||||
@@ -139,10 +128,9 @@ class ParseAddressListTests(SimpleTestCase):
|
||||
parse_address_list(['"Display Name"', '<valid@example.com>'])
|
||||
|
||||
def test_invalid_with_unicode(self):
|
||||
# (assertRaisesMessage can't handle unicode in Python 2)
|
||||
with self.assertRaises(AnymailInvalidAddress) as cm:
|
||||
parse_address_list([u"\N{ENVELOPE}"])
|
||||
self.assertIn(u"Invalid email address '\N{ENVELOPE}'", six.text_type(cm.exception))
|
||||
with self.assertRaisesMessage(AnymailInvalidAddress,
|
||||
"Invalid email address '\N{ENVELOPE}'"):
|
||||
parse_address_list(["\N{ENVELOPE}"])
|
||||
|
||||
def test_single_string(self):
|
||||
# bare strings are used by the from_email parsing in BasePayload
|
||||
@@ -151,12 +139,12 @@ class ParseAddressListTests(SimpleTestCase):
|
||||
self.assertEqual(parsed_list[0].addr_spec, "one@example.com")
|
||||
|
||||
def test_lazy_strings(self):
|
||||
parsed_list = parse_address_list([ugettext_lazy('"Example, Inc." <one@example.com>')])
|
||||
parsed_list = parse_address_list([gettext_lazy('"Example, Inc." <one@example.com>')])
|
||||
self.assertEqual(len(parsed_list), 1)
|
||||
self.assertEqual(parsed_list[0].display_name, "Example, Inc.")
|
||||
self.assertEqual(parsed_list[0].addr_spec, "one@example.com")
|
||||
|
||||
parsed_list = parse_address_list(ugettext_lazy("one@example.com"))
|
||||
parsed_list = parse_address_list(gettext_lazy("one@example.com"))
|
||||
self.assertEqual(len(parsed_list), 1)
|
||||
self.assertEqual(parsed_list[0].display_name, "")
|
||||
self.assertEqual(parsed_list[0].addr_spec, "one@example.com")
|
||||
@@ -221,47 +209,38 @@ class LazyCoercionTests(SimpleTestCase):
|
||||
"""Test utils.is_lazy and force_non_lazy*"""
|
||||
|
||||
def test_is_lazy(self):
|
||||
self.assertTrue(is_lazy(ugettext_lazy("lazy string is lazy")))
|
||||
self.assertTrue(is_lazy(gettext_lazy("lazy string is lazy")))
|
||||
|
||||
def test_not_lazy(self):
|
||||
self.assertFalse(is_lazy(u"text not lazy"))
|
||||
self.assertFalse(is_lazy("text not lazy"))
|
||||
self.assertFalse(is_lazy(b"bytes not lazy"))
|
||||
self.assertFalse(is_lazy(None))
|
||||
self.assertFalse(is_lazy({'dict': "not lazy"}))
|
||||
self.assertFalse(is_lazy(["list", "not lazy"]))
|
||||
self.assertFalse(is_lazy(object()))
|
||||
self.assertFalse(is_lazy([ugettext_lazy("doesn't recurse")]))
|
||||
self.assertFalse(is_lazy([gettext_lazy("doesn't recurse")]))
|
||||
|
||||
def test_force_lazy(self):
|
||||
result = force_non_lazy(ugettext_lazy(u"text"))
|
||||
self.assertIsInstance(result, six.text_type)
|
||||
self.assertEqual(result, u"text")
|
||||
result = force_non_lazy(gettext_lazy("text"))
|
||||
self.assertIsInstance(result, str)
|
||||
self.assertEqual(result, "text")
|
||||
|
||||
@skipIf(string_concat is None, "string_concat not in this Django version")
|
||||
def test_force_concat(self):
|
||||
self.assertTrue(is_lazy(string_concat(ugettext_lazy("concatenation"),
|
||||
ugettext_lazy("is lazy"))))
|
||||
result = force_non_lazy(string_concat(ugettext_lazy(u"text"), ugettext_lazy("concat")))
|
||||
self.assertIsInstance(result, six.text_type)
|
||||
self.assertEqual(result, u"textconcat")
|
||||
|
||||
@skipIf(format_lazy is None, "format_lazy not in this Django version")
|
||||
def test_format_lazy(self):
|
||||
self.assertTrue(is_lazy(format_lazy("{0}{1}",
|
||||
ugettext_lazy("concatenation"), ugettext_lazy("is lazy"))))
|
||||
gettext_lazy("concatenation"), gettext_lazy("is lazy"))))
|
||||
result = force_non_lazy(format_lazy("{first}/{second}",
|
||||
first=ugettext_lazy(u"text"), second=ugettext_lazy("format")))
|
||||
self.assertIsInstance(result, six.text_type)
|
||||
self.assertEqual(result, u"text/format")
|
||||
first=gettext_lazy("text"), second=gettext_lazy("format")))
|
||||
self.assertIsInstance(result, str)
|
||||
self.assertEqual(result, "text/format")
|
||||
|
||||
def test_force_string(self):
|
||||
result = force_non_lazy(u"text")
|
||||
self.assertIsInstance(result, six.text_type)
|
||||
self.assertEqual(result, u"text")
|
||||
result = force_non_lazy("text")
|
||||
self.assertIsInstance(result, str)
|
||||
self.assertEqual(result, "text")
|
||||
|
||||
def test_force_bytes(self):
|
||||
result = force_non_lazy(b"bytes \xFE")
|
||||
self.assertIsInstance(result, six.binary_type)
|
||||
self.assertIsInstance(result, bytes)
|
||||
self.assertEqual(result, b"bytes \xFE")
|
||||
|
||||
def test_force_none(self):
|
||||
@@ -269,16 +248,16 @@ class LazyCoercionTests(SimpleTestCase):
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_force_dict(self):
|
||||
result = force_non_lazy_dict({'a': 1, 'b': ugettext_lazy(u"b"),
|
||||
'c': {'c1': ugettext_lazy(u"c1")}})
|
||||
self.assertEqual(result, {'a': 1, 'b': u"b", 'c': {'c1': u"c1"}})
|
||||
self.assertIsInstance(result['b'], six.text_type)
|
||||
self.assertIsInstance(result['c']['c1'], six.text_type)
|
||||
result = force_non_lazy_dict({'a': 1, 'b': gettext_lazy("b"),
|
||||
'c': {'c1': gettext_lazy("c1")}})
|
||||
self.assertEqual(result, {'a': 1, 'b': "b", 'c': {'c1': "c1"}})
|
||||
self.assertIsInstance(result['b'], str)
|
||||
self.assertIsInstance(result['c']['c1'], str)
|
||||
|
||||
def test_force_list(self):
|
||||
result = force_non_lazy_list([0, ugettext_lazy(u"b"), u"c"])
|
||||
self.assertEqual(result, [0, u"b", u"c"]) # coerced to list
|
||||
self.assertIsInstance(result[1], six.text_type)
|
||||
result = force_non_lazy_list([0, gettext_lazy("b"), "c"])
|
||||
self.assertEqual(result, [0, "b", "c"]) # coerced to list
|
||||
self.assertIsInstance(result[1], str)
|
||||
|
||||
|
||||
class UpdateDeepTests(SimpleTestCase):
|
||||
@@ -313,7 +292,7 @@ class RequestUtilsTests(SimpleTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.request_factory = RequestFactory()
|
||||
super(RequestUtilsTests, self).setUp()
|
||||
super().setUp()
|
||||
|
||||
@staticmethod
|
||||
def basic_auth(username, password):
|
||||
|
||||
183
tests/utils.py
183
tests/utils.py
@@ -1,6 +1,4 @@
|
||||
# Anymail test utils
|
||||
import collections
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
@@ -8,10 +6,10 @@ import uuid
|
||||
import warnings
|
||||
from base64 import b64decode
|
||||
from contextlib import contextmanager
|
||||
from io import StringIO
|
||||
from unittest import TestCase
|
||||
|
||||
import six
|
||||
from django.test import Client
|
||||
from six.moves import StringIO
|
||||
|
||||
|
||||
def decode_att(att):
|
||||
@@ -71,36 +69,9 @@ def sample_email_content(filename=SAMPLE_EMAIL_FILENAME):
|
||||
# TestCase helpers
|
||||
#
|
||||
|
||||
# noinspection PyUnresolvedReferences
|
||||
class AnymailTestMixin:
|
||||
class AnymailTestMixin(TestCase):
|
||||
"""Helpful additional methods for Anymail tests"""
|
||||
|
||||
def assertLogs(self, logger=None, level=None):
|
||||
# Note: django.utils.log.DEFAULT_LOGGING config is set to *not* propagate certain
|
||||
# logging records. That means you *can't* capture those logs at the root (None) logger.
|
||||
assert logger is not None # `None` root logger won't reliably capture
|
||||
try:
|
||||
return super(AnymailTestMixin, self).assertLogs(logger, level)
|
||||
except (AttributeError, TypeError):
|
||||
# Python <3.4: use our backported assertLogs
|
||||
return _AssertLogsContext(self, logger, level)
|
||||
|
||||
def assertWarns(self, expected_warning, msg=None):
|
||||
# We only support the context-manager version
|
||||
try:
|
||||
return super(AnymailTestMixin, self).assertWarns(expected_warning, msg=msg)
|
||||
except TypeError:
|
||||
# Python 2.x: use our backported assertWarns
|
||||
return _AssertWarnsContext(expected_warning, self, msg=msg)
|
||||
|
||||
def assertWarnsRegex(self, expected_warning, expected_regex, msg=None):
|
||||
# We only support the context-manager version
|
||||
try:
|
||||
return super(AnymailTestMixin, self).assertWarnsRegex(expected_warning, expected_regex, msg=msg)
|
||||
except TypeError:
|
||||
# Python 2.x: use our backported assertWarns
|
||||
return _AssertWarnsContext(expected_warning, self, expected_regex=expected_regex, msg=msg)
|
||||
|
||||
@contextmanager
|
||||
def assertDoesNotWarn(self, disallowed_warning=Warning):
|
||||
"""Makes test error (rather than fail) if disallowed_warning occurs.
|
||||
@@ -115,31 +86,13 @@ class AnymailTestMixin:
|
||||
finally:
|
||||
warnings.resetwarnings()
|
||||
|
||||
def assertCountEqual(self, *args, **kwargs):
|
||||
try:
|
||||
return super(AnymailTestMixin, self).assertCountEqual(*args, **kwargs)
|
||||
except TypeError:
|
||||
return self.assertItemsEqual(*args, **kwargs) # Python 2
|
||||
|
||||
def assertRaisesRegex(self, *args, **kwargs):
|
||||
try:
|
||||
return super(AnymailTestMixin, self).assertRaisesRegex(*args, **kwargs)
|
||||
except TypeError:
|
||||
return self.assertRaisesRegexp(*args, **kwargs) # Python 2
|
||||
|
||||
def assertRegex(self, *args, **kwargs):
|
||||
try:
|
||||
return super(AnymailTestMixin, self).assertRegex(*args, **kwargs)
|
||||
except TypeError:
|
||||
return self.assertRegexpMatches(*args, **kwargs) # Python 2
|
||||
|
||||
def assertEqualIgnoringHeaderFolding(self, first, second, msg=None):
|
||||
# Unfold (per RFC-8222) all text first and second, then compare result.
|
||||
# Useful for message/rfc822 attachment tests, where various Python email
|
||||
# versions handled folding slightly differently.
|
||||
# (Technically, this is unfolding both headers and (incorrectly) bodies,
|
||||
# but that doesn't really affect the tests.)
|
||||
if isinstance(first, six.binary_type) and isinstance(second, six.binary_type):
|
||||
if isinstance(first, bytes) and isinstance(second, bytes):
|
||||
first = first.decode('utf-8')
|
||||
second = second.decode('utf-8')
|
||||
first = rfc822_unfold(first)
|
||||
@@ -190,131 +143,6 @@ class AnymailTestMixin:
|
||||
sys.stdout = old_stdout
|
||||
|
||||
|
||||
# Backported from Python 3.4
|
||||
class _AssertLogsContext(object):
|
||||
"""A context manager used to implement TestCase.assertLogs()."""
|
||||
|
||||
LOGGING_FORMAT = "%(levelname)s:%(name)s:%(message)s"
|
||||
|
||||
def __init__(self, test_case, logger_name, level):
|
||||
self.test_case = test_case
|
||||
self.logger_name = logger_name
|
||||
if level:
|
||||
self.level = logging._nameToLevel.get(level, level)
|
||||
else:
|
||||
self.level = logging.INFO
|
||||
self.msg = None
|
||||
|
||||
def _raiseFailure(self, standardMsg):
|
||||
msg = self.test_case._formatMessage(self.msg, standardMsg)
|
||||
raise self.test_case.failureException(msg)
|
||||
|
||||
class _CapturingHandler(logging.Handler):
|
||||
"""A logging handler capturing all (raw and formatted) logging output."""
|
||||
|
||||
_LoggingWatcher = collections.namedtuple("_LoggingWatcher", ["records", "output"])
|
||||
|
||||
def __init__(self):
|
||||
logging.Handler.__init__(self)
|
||||
self.watcher = self._LoggingWatcher([], [])
|
||||
|
||||
def flush(self):
|
||||
pass
|
||||
|
||||
def emit(self, record):
|
||||
self.watcher.records.append(record)
|
||||
msg = self.format(record)
|
||||
self.watcher.output.append(msg)
|
||||
|
||||
def __enter__(self):
|
||||
if isinstance(self.logger_name, logging.Logger):
|
||||
logger = self.logger = self.logger_name
|
||||
else:
|
||||
logger = self.logger = logging.getLogger(self.logger_name)
|
||||
formatter = logging.Formatter(self.LOGGING_FORMAT)
|
||||
handler = self._CapturingHandler()
|
||||
handler.setFormatter(formatter)
|
||||
self.watcher = handler.watcher
|
||||
self.old_handlers = logger.handlers[:]
|
||||
self.old_level = logger.level
|
||||
self.old_propagate = logger.propagate
|
||||
logger.handlers = [handler]
|
||||
logger.setLevel(self.level)
|
||||
logger.propagate = False
|
||||
return handler.watcher
|
||||
|
||||
def __exit__(self, exc_type, exc_value, tb):
|
||||
self.logger.handlers = self.old_handlers
|
||||
self.logger.propagate = self.old_propagate
|
||||
self.logger.setLevel(self.old_level)
|
||||
if exc_type is not None:
|
||||
# let unexpected exceptions pass through
|
||||
return False
|
||||
if len(self.watcher.records) == 0:
|
||||
self._raiseFailure(
|
||||
"no logs of level {} or higher triggered on {}"
|
||||
.format(logging.getLevelName(self.level), self.logger.name))
|
||||
|
||||
|
||||
# Backported from python 3.5
|
||||
class _AssertWarnsContext(object):
|
||||
"""A context manager used to implement TestCase.assertWarns* methods."""
|
||||
|
||||
def __init__(self, expected, test_case, expected_regex=None, msg=None):
|
||||
self.test_case = test_case
|
||||
self.expected = expected
|
||||
self.test_case = test_case
|
||||
if expected_regex is not None:
|
||||
expected_regex = re.compile(expected_regex)
|
||||
self.expected_regex = expected_regex
|
||||
self.msg = msg
|
||||
|
||||
def _raiseFailure(self, standardMsg):
|
||||
# msg = self.test_case._formatMessage(self.msg, standardMsg)
|
||||
msg = self.msg or standardMsg
|
||||
raise self.test_case.failureException(msg)
|
||||
|
||||
def __enter__(self):
|
||||
# The __warningregistry__'s need to be in a pristine state for tests
|
||||
# to work properly.
|
||||
for v in sys.modules.values():
|
||||
if getattr(v, '__warningregistry__', None):
|
||||
v.__warningregistry__ = {}
|
||||
self.warnings_manager = warnings.catch_warnings(record=True)
|
||||
self.warnings = self.warnings_manager.__enter__()
|
||||
warnings.simplefilter("always", self.expected)
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, tb):
|
||||
self.warnings_manager.__exit__(exc_type, exc_value, tb)
|
||||
if exc_type is not None:
|
||||
# let unexpected exceptions pass through
|
||||
return
|
||||
try:
|
||||
exc_name = self.expected.__name__
|
||||
except AttributeError:
|
||||
exc_name = str(self.expected)
|
||||
first_matching = None
|
||||
for m in self.warnings:
|
||||
w = m.message
|
||||
if not isinstance(w, self.expected):
|
||||
continue
|
||||
if first_matching is None:
|
||||
first_matching = w
|
||||
if self.expected_regex is not None and not self.expected_regex.search(str(w)):
|
||||
continue
|
||||
# store warning for later retrieval
|
||||
self.warning = w
|
||||
self.filename = m.filename
|
||||
self.lineno = m.lineno
|
||||
return
|
||||
# Now we simply try to choose a helpful failure message
|
||||
if first_matching is not None:
|
||||
self._raiseFailure('"{}" does not match "{}"'.format(
|
||||
self.expected_regex.pattern, str(first_matching)))
|
||||
self._raiseFailure("{} not triggered".format(exc_name))
|
||||
|
||||
|
||||
class ClientWithCsrfChecks(Client):
|
||||
"""Django test Client that enforces CSRF checks
|
||||
|
||||
@@ -322,8 +150,7 @@ class ClientWithCsrfChecks(Client):
|
||||
"""
|
||||
|
||||
def __init__(self, **defaults):
|
||||
super(ClientWithCsrfChecks, self).__init__(
|
||||
enforce_csrf_checks=True, **defaults)
|
||||
super().__init__(enforce_csrf_checks=True, **defaults)
|
||||
|
||||
|
||||
# dedent for bytestrs
|
||||
|
||||
@@ -25,7 +25,7 @@ class WebhookTestCase(AnymailTestMixin, SimpleTestCase):
|
||||
client_class = ClientWithCsrfChecks
|
||||
|
||||
def setUp(self):
|
||||
super(WebhookTestCase, self).setUp()
|
||||
super().setUp()
|
||||
# Use correct basic auth by default (individual tests can override):
|
||||
self.set_basic_auth()
|
||||
|
||||
@@ -71,15 +71,24 @@ class WebhookTestCase(AnymailTestMixin, SimpleTestCase):
|
||||
return actual_kwargs
|
||||
|
||||
|
||||
# noinspection PyUnresolvedReferences
|
||||
class WebhookBasicAuthTestsMixin(object):
|
||||
class WebhookBasicAuthTestCase(WebhookTestCase):
|
||||
"""Common test cases for webhook basic authentication.
|
||||
|
||||
Instantiate for each ESP's webhooks by:
|
||||
- mixing into WebhookTestCase
|
||||
- subclassing
|
||||
- defining call_webhook to invoke the ESP's webhook
|
||||
- adding or overriding any tests as appropriate
|
||||
"""
|
||||
|
||||
def __init__(self, methodName='runTest'):
|
||||
if self.__class__ is WebhookBasicAuthTestCase:
|
||||
# don't run these tests on the abstract base implementation
|
||||
methodName = 'runNoTestsInBaseClass'
|
||||
super().__init__(methodName)
|
||||
|
||||
def runNoTestsInBaseClass(self):
|
||||
pass
|
||||
|
||||
should_warn_if_no_auth = True # subclass set False if other webhook verification used
|
||||
|
||||
def call_webhook(self):
|
||||
|
||||
15
tox.ini
15
tox.ini
@@ -3,27 +3,24 @@ envlist =
|
||||
# Factors: django-python-extras
|
||||
# Test these environments first, to catch most errors early...
|
||||
lint
|
||||
django30-py37-all
|
||||
django111-py27-all
|
||||
django31-py38-all
|
||||
django20-py35-all
|
||||
docs
|
||||
# ... then test all the other supported combinations:
|
||||
django30-py{36,38,py3}-all
|
||||
django31-py{36,37,py3}-all
|
||||
django30-py{36,37,38,py3}-all
|
||||
django22-py{35,36,37,py3}-all
|
||||
django21-py{35,36,37,py3}-all
|
||||
django20-py{35,36,py3}-all
|
||||
django111-py{34,35,36,py}-all
|
||||
django20-py{36,py3}-all
|
||||
# ... then prereleases (if available):
|
||||
django31-py{36,37,38,py3}-all
|
||||
djangoDev-py{36,37,38}-all
|
||||
# ... then partial installation (limit extras):
|
||||
django22-py37-{none,amazon_ses,sparkpost}
|
||||
django31-py37-{none,amazon_ses,sparkpost}
|
||||
# ... then older versions of some dependencies:
|
||||
django111-py27-all-old_urllib3
|
||||
django22-py37-all-old_urllib3
|
||||
|
||||
[testenv]
|
||||
deps =
|
||||
django111: django~=1.11.0
|
||||
django20: django~=2.0.0
|
||||
django21: django~=2.1.0
|
||||
django22: django~=2.2.0
|
||||
|
||||
Reference in New Issue
Block a user