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