SparkPost: call HTTP API directly [breaking]

Switch from the (now unmaintained) python-sparkpost
client library to a requests-based backend that calls
SparkPost's Transmissions API directly.

Also adds support for text/x-amp-html alternative parts
(which are supported by the SparkPost API, but weren't
by the client library).

Closes #203
This commit is contained in:
medmunds
2020-09-10 17:11:16 -07:00
committed by Mike Edmunds
parent 470ed2c6e6
commit 61660cd5ff
10 changed files with 522 additions and 462 deletions

View File

@@ -55,7 +55,6 @@ jobs:
# Install without optional extras (don't need to cover entire matrix)
- { env: TOXENV=django31-py37-none, python: 3.7 }
- { env: TOXENV=django31-py37-amazon_ses, python: 3.7 }
- { env: TOXENV=django31-py37-sparkpost, python: 3.7 }
# Test some specific older package versions
- { env: TOXENV=django22-py37-all-old_urllib3, python: 3.7 }

View File

@@ -43,6 +43,18 @@ Breaking changes
need to update it for compatibility with the new API. (See
`docs <https://anymail.readthedocs.io/en/latest/esps/mailjet/#esp-extra-support>`__.)
* **SparkPost:** Switch away from the (now unmaintained) Python SparkPost library to
calling the SparkPost API directly. The "sparkpost" package is no longer necessary and
can be removed from your project requirements. Most SparkPost users will not be
affected by this change, with two exceptions: (1) You must provide a
``SPARKPOST_API_KEY`` in your Anymail settings (Anymail does not check environment
variables); and (2) if you use Anymail's `esp_extra` you will need to update it with
SparkPost Transmissions API parameters.
As part of this change esp_extra now allows use of several SparkPost features, such
as A/B testing, that were unavailable through the Python SparkPost library. (See
`docs <https://anymail.readthedocs.io/en/latest/esps/sparkpost/>`__.)
* 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
@@ -54,6 +66,12 @@ Breaking changes
inheritance. (For some helpful background, see this comment about
`mixin superclass ordering <https://nedbatchelder.com/blog/201210/multiple_inheritance_is_hard.html#comment_13805>`__.)
Features
~~~~~~~~
* **SparkPost:** Add support for AMP for Email, via
``message.attach_alternative("...AMPHTML content...", "text/x-amp-html")``.
v7.2.1
------

View File

@@ -1,82 +1,46 @@
from .base import AnymailBaseBackend, BasePayload
from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled, AnymailConfigurationError
from .base_requests import AnymailRequestsBackend, RequestsPayload
from ..exceptions import AnymailRequestsAPIError
from ..message import AnymailRecipientStatus
from ..utils import get_anymail_setting
try:
from sparkpost import SparkPost, SparkPostException
except ImportError as err:
raise AnymailImproperlyInstalled(missing_package='sparkpost', backend='sparkpost') from err
from ..utils import get_anymail_setting, update_deep
class EmailBackend(AnymailBaseBackend):
class EmailBackend(AnymailRequestsBackend):
"""
SparkPost Email Backend (using python-sparkpost client)
SparkPost Email Backend
"""
esp_name = "SparkPost"
def __init__(self, **kwargs):
"""Init options from Django settings"""
super().__init__(**kwargs)
# SPARKPOST_API_KEY is optional - library reads from env by default
self.api_key = get_anymail_setting('api_key', esp_name=self.esp_name,
kwargs=kwargs, allow_bare=True, default=None)
# SPARKPOST_API_URL is optional - default is set by library;
# if provided, must be a full SparkPost API endpoint, including "/v1" if appropriate
api_url = get_anymail_setting('api_url', esp_name=self.esp_name, kwargs=kwargs, default=None)
extra_sparkpost_params = {}
if api_url is not None:
if api_url.endswith("/"):
api_url = api_url[:-1]
extra_sparkpost_params['base_uri'] = _FullSparkPostEndpoint(api_url)
try:
self.sp = SparkPost(self.api_key, **extra_sparkpost_params) # SparkPost API instance
except SparkPostException as err:
# This is almost certainly a missing API key
raise AnymailConfigurationError(
"Error initializing SparkPost: %s\n"
"You may need to set ANYMAIL = {'SPARKPOST_API_KEY': ...} "
"or ANYMAIL_SPARKPOST_API_KEY in your Django settings, "
"or SPARKPOST_API_KEY in your environment." % str(err)
) from err
# Note: SparkPost python API doesn't expose requests session sharing
# (so there's no need to implement open/close connection management here)
kwargs=kwargs, allow_bare=True)
api_url = get_anymail_setting('api_url', esp_name=self.esp_name, kwargs=kwargs,
default="https://api.sparkpost.com/api/v1/")
if not api_url.endswith("/"):
api_url += "/"
super().__init__(api_url, **kwargs)
def build_message_payload(self, message, defaults):
return SparkPostPayload(message, defaults, self)
def post_to_esp(self, payload, message):
params = payload.get_api_params()
try:
response = self.sp.transmissions.send(**params)
except SparkPostException as err:
raise AnymailAPIError(
str(err), backend=self, email_message=message, payload=payload,
response=getattr(err, 'response', None), # SparkPostAPIException requests.Response
status_code=getattr(err, 'status', None), # SparkPostAPIException HTTP status_code
) from err
return response
def parse_recipient_status(self, response, payload, message):
parsed_response = self.deserialize_json_response(response, payload, message)
try:
accepted = response['total_accepted_recipients']
rejected = response['total_rejected_recipients']
transmission_id = response['id']
results = parsed_response["results"]
accepted = results["total_accepted_recipients"]
rejected = results["total_rejected_recipients"]
transmission_id = results["id"]
except (KeyError, TypeError) as err:
raise AnymailAPIError(
"%s in SparkPost.transmissions.send result %r" % (str(err), response),
backend=self, email_message=message, payload=payload,
) from err
raise AnymailRequestsAPIError("Invalid SparkPost API response format",
email_message=message, payload=payload,
response=response, backend=self) from err
# SparkPost doesn't (yet*) tell us *which* recipients were accepted or rejected.
# (* looks like undocumented 'rcpt_to_errors' might provide this info.)
# If all are one or the other, we can report a specific status;
# else just report 'unknown' for all recipients.
recipient_count = len(payload.all_recipients)
recipient_count = len(payload.recipients)
if accepted == recipient_count and rejected == 0:
status = 'queued'
elif rejected == recipient_count and accepted == 0:
@@ -84,174 +48,202 @@ class EmailBackend(AnymailBaseBackend):
else: # mixed results, or wrong total
status = 'unknown'
recipient_status = AnymailRecipientStatus(message_id=transmission_id, status=status)
return {recipient.addr_spec: recipient_status for recipient in payload.all_recipients}
return {recipient.addr_spec: recipient_status for recipient in payload.recipients}
class SparkPostPayload(BasePayload):
def init_payload(self):
self.params = {}
self.all_recipients = []
self.to_emails = []
self.merge_data = {}
self.merge_metadata = {}
class SparkPostPayload(RequestsPayload):
def __init__(self, message, defaults, backend, *args, **kwargs):
http_headers = {
'Authorization': backend.api_key,
'Content-Type': 'application/json',
}
self.recipients = [] # all recipients, for backend parse_recipient_status
self.cc_and_bcc = [] # for _finalize_recipients
super().__init__(message, defaults, backend, headers=http_headers, *args, **kwargs)
def get_api_params(self):
# Compose recipients param from to_emails and merge_data (if any)
recipients = []
def get_api_endpoint(self):
return "transmissions/"
def serialize_data(self):
self._finalize_recipients()
return self.serialize_json(self.data)
def _finalize_recipients(self):
# https://www.sparkpost.com/docs/faq/cc-bcc-with-rest-api/
# self.data["recipients"] is currently a list of all to-recipients. We need to add
# all cc and bcc recipients. Exactly how depends on whether this is a batch send.
if self.is_batch():
# Build JSON recipient structures
for email in self.to_emails:
rcpt = {'address': {'email': email.addr_spec}}
if email.display_name:
rcpt['address']['name'] = email.display_name
try:
rcpt['substitution_data'] = self.merge_data[email.addr_spec]
except KeyError:
pass # no merge_data or none for this recipient
try:
rcpt['metadata'] = self.merge_metadata[email.addr_spec]
except KeyError:
pass # no merge_metadata or none for this recipient
recipients.append(rcpt)
# For batch sends, must duplicate the cc/bcc for *every* to-recipient
# (using each to-recipient's metadata and substitutions).
extra_recipients = []
for to_recipient in self.data["recipients"]:
for email in self.cc_and_bcc:
extra = to_recipient.copy() # capture "metadata" and "substitutions", if any
extra["address"] = {
"email": email.addr_spec,
"header_to": to_recipient["address"]["header_to"],
}
extra_recipients.append(extra)
self.data["recipients"].extend(extra_recipients)
else:
# Just use simple recipients list
recipients = [email.address for email in self.to_emails]
if recipients:
self.params['recipients'] = recipients
# For non-batch sends, we need to patch up *everyone's* displayed
# "To" header to show all the "To" recipients...
full_to_header = ", ".join(
to_recipient["address"]["header_to"]
for to_recipient in self.data["recipients"])
for recipient in self.data["recipients"]:
recipient["address"]["header_to"] = full_to_header
# ... and then simply add the cc/bcc to the end of the list.
# (There is no per-recipient data, or it would be a batch send.)
self.data["recipients"].extend(
{"address": {
"email": email.addr_spec,
"header_to": full_to_header,
}}
for email in self.cc_and_bcc)
# Must remove empty string "content" params when using stored template
if self.params.get('template', None):
for content_param in ['subject', 'text', 'html']:
try:
if not self.params[content_param]:
del self.params[content_param]
except KeyError:
pass
#
# Payload construction
#
return self.params
def init_payload(self):
# The JSON payload:
self.data = {
"content": {},
"recipients": [],
}
def set_from_email_list(self, emails):
# SparkPost supports multiple From email addresses,
# as a single comma-separated string
self.params['from_email'] = ", ".join([email.address for email in emails])
self.data["content"]["from"] = ", ".join(email.address for email in emails)
def set_to(self, emails):
if emails:
self.to_emails = emails # bound to params['recipients'] in get_api_params
self.all_recipients += emails
# In the recipient address, "email" is the addr spec to deliver to,
# and "header_to" is a fully-composed "To" header to display.
# (We use "header_to" rather than "name" to simplify some logic
# in _finalize_recipients; the results end up the same.)
self.data["recipients"].extend(
{"address": {
"email": email.addr_spec,
"header_to": email.address,
}}
for email in emails)
self.recipients += emails
def set_cc(self, emails):
# https://www.sparkpost.com/docs/faq/cc-bcc-with-rest-api/
if emails:
self.params['cc'] = [email.address for email in emails]
self.all_recipients += emails
# Add the Cc header, visible to all recipients:
cc_header = ", ".join(email.address for email in emails)
self.data["content"].setdefault("headers", {})["Cc"] = cc_header
# Actual recipients are added later, in _finalize_recipients
self.cc_and_bcc += emails
self.recipients += emails
def set_bcc(self, emails):
if emails:
self.params['bcc'] = [email.address for email in emails]
self.all_recipients += emails
# Actual recipients are added later, in _finalize_recipients
self.cc_and_bcc += emails
self.recipients += emails
def set_subject(self, subject):
self.params['subject'] = subject
self.data["content"]["subject"] = subject
def set_reply_to(self, emails):
if emails:
# reply_to is only documented as a single email, but this seems to work:
self.params['reply_to'] = ', '.join([email.address for email in emails])
self.data["content"]["reply_to"] = ", ".join(email.address for email in emails)
def set_extra_headers(self, headers):
if headers:
self.params['custom_headers'] = dict(headers) # convert CaseInsensitiveDict to plain dict for SP lib
self.data["content"].setdefault("headers", {}).update(headers)
def set_text_body(self, body):
self.params['text'] = body
self.data["content"]["text"] = body
def set_html_body(self, body):
if 'html' in self.params:
if "html" in self.data["content"]:
# second html body could show up through multiple alternatives, or html body + alternative
self.unsupported_feature("multiple html parts")
self.params['html'] = body
self.data["content"]["html"] = body
def add_attachment(self, attachment):
if attachment.inline:
param = 'inline_images'
name = attachment.cid
def add_alternative(self, content, mimetype):
if mimetype.lower() == "text/x-amp-html":
if "amp_html" in self.data["content"]:
self.unsupported_feature("multiple html parts")
self.data["content"]["amp_html"] = content
else:
param = 'attachments'
name = attachment.name or ''
super().add_alternative(content, mimetype)
self.params.setdefault(param, []).append({
'type': attachment.mimetype,
'name': name,
'data': attachment.b64content})
def set_attachments(self, atts):
attachments = [{
"name": att.name or "",
"type": att.content_type,
"data": att.b64content,
} for att in atts if not att.inline]
if attachments:
self.data["content"]["attachments"] = attachments
inline_images = [{
"name": att.cid,
"type": att.mimetype,
"data": att.b64content,
} for att in atts if att.inline]
if inline_images:
self.data["content"]["inline_images"] = inline_images
# Anymail-specific payload construction
def set_envelope_sender(self, email):
self.params['return_path'] = email.addr_spec
self.data["return_path"] = email.addr_spec
def set_metadata(self, metadata):
self.params['metadata'] = metadata
self.data["metadata"] = metadata
def set_merge_metadata(self, merge_metadata):
for recipient in self.data["recipients"]:
to_email = recipient["address"]["email"]
if to_email in merge_metadata:
recipient["metadata"] = merge_metadata[to_email]
def set_send_at(self, send_at):
try:
self.params['start_time'] = send_at.replace(microsecond=0).isoformat()
start_time = send_at.replace(microsecond=0).isoformat()
except (AttributeError, TypeError):
self.params['start_time'] = send_at # assume user already formatted
start_time = send_at # assume user already formatted
self.data.setdefault("options", {})["start_time"] = start_time
def set_tags(self, tags):
if len(tags) > 0:
self.params['campaign'] = tags[0]
self.data["campaign_id"] = tags[0]
if len(tags) > 1:
self.unsupported_feature('multiple tags (%r)' % tags)
self.unsupported_feature("multiple tags (%r)" % tags)
def set_track_clicks(self, track_clicks):
self.params['track_clicks'] = track_clicks
self.data.setdefault("options", {})["click_tracking"] = track_clicks
def set_track_opens(self, track_opens):
self.params['track_opens'] = track_opens
self.data.setdefault("options", {})["open_tracking"] = track_opens
def set_template_id(self, template_id):
# 'template' transmissions.send param becomes 'template_id' in API json 'content'
self.params['template'] = template_id
self.data["content"]["template_id"] = template_id
# Must remove empty string "content" params when using stored template
for content_param in ["subject", "text", "html"]:
try:
if not self.data["content"][content_param]:
del self.data["content"][content_param]
except KeyError:
pass
def set_merge_data(self, merge_data):
self.merge_data = merge_data # merged into params['recipients'] in get_api_params
def set_merge_metadata(self, merge_metadata):
self.merge_metadata = merge_metadata # merged into params['recipients'] in get_api_params
for recipient in self.data["recipients"]:
to_email = recipient["address"]["email"]
if to_email in merge_data:
recipient["substitution_data"] = merge_data[to_email]
def set_merge_global_data(self, merge_global_data):
self.params['substitution_data'] = merge_global_data
self.data["substitution_data"] = merge_global_data
# ESP-specific payload construction
def set_esp_extra(self, extra):
self.params.update(extra)
class _FullSparkPostEndpoint(str):
"""A string-like object that allows using a complete SparkPost API endpoint url as base_uri:
sp = SparkPost(api_key, base_uri=_FullSparkPostEndpoint('https://api.sparkpost.com/api/labs'))
Works around SparkPost.__init__ code `self.base_uri = base_uri + '/api/v' + version`,
which makes it difficult to simply copy and paste full API endpoints from SparkPost's docs
(https://developers.sparkpost.com/api/index.html#header-api-endpoints) -- and completely
prevents using the labs API endpoint (which has no "v" in it).
Should work with all python-sparkpost releases through at least v1.3.6.
"""
_expect = ['/api/v', '1'] # ignore attempts to concatenate these with me (in order)
def __add__(self, other):
expected = self._expect[0]
self._expect = self._expect[1:] # (makes a copy for this instance)
if other == expected:
# ignore this operation
if self._expect:
return self
else:
return str(self) # my work is done; just be a normal str now
else:
# something changed in python-sparkpost; please open an Anymail issue to fix
raise ValueError(
"This version of Anymail is not compatible with this version of python-sparkpost.\n"
"(_FullSparkPostEndpoint(%r) expected %r but got %r)" % (self, expected, other))
update_deep(self.data, extra)

View File

@@ -91,7 +91,7 @@ Or:
.. code-block:: console
$ pip install mock boto3 sparkpost # install test dependencies
$ pip install mock boto3 # install test dependencies
$ python runtests.py
## this command can also run just a few test cases, e.g.:

View File

@@ -4,22 +4,20 @@ SparkPost
=========
Anymail integrates with the `SparkPost`_ email service, using their
Python :pypi:`sparkpost` API client package.
`Transmissions API`_.
.. versionchanged:: 8.0
Earlier Anymail versions used the official Python :pypi:`sparkpost` API client.
That library is no longer maintained, and Anymail now calls SparkPost's HTTP API
directly. This change should not affect most users, but you should make sure you
provide :setting:`SPARKPOST_API_KEY <ANYMAIL_SPARKPOST_API_KEY>` in your
Anymail settings (Anymail doesn't check environment variables), and if you are
using Anymail's :ref:`esp_extra <sparkpost-esp-extra>` you will need to update that
to use Transmissions API parameters.
.. _SparkPost: https://www.sparkpost.com/
Installation
------------
You must ensure the :pypi:`sparkpost` package is installed to use Anymail's SparkPost
backend. Either include the "sparkpost" option when you install Anymail:
.. code-block:: console
$ pip install "django-anymail[sparkpost]"
or separately run `pip install sparkpost`.
.. _Transmissions API: https://developers.sparkpost.com/api/transmissions/
Settings
@@ -44,9 +42,6 @@ in your settings.py.
A SparkPost API key with at least the "Transmissions: Read/Write" permission.
(Manage API keys in your `SparkPost account API keys`_.)
This setting is optional; if not provided, the SparkPost API client will attempt
to read your API key from the `SPARKPOST_API_KEY` environment variable.
.. code-block:: python
ANYMAIL = {
@@ -58,6 +53,13 @@ Anymail will also look for ``SPARKPOST_API_KEY`` at the
root of the settings file if neither ``ANYMAIL["SPARKPOST_API_KEY"]``
nor ``ANYMAIL_SPARKPOST_API_KEY`` is set.
.. versionchanged:: 8.0
This setting is required. If you store your API key in an environment variable, load
it into your Anymail settings: ``"SPARKPOST_API_KEY": os.environ["SPARKPOST_API_KEY"]``.
(Earlier Anymail releases used the SparkPost Python library, which would look for
the environment variable.)
.. _SparkPost account API keys: https://app.sparkpost.com/account/credentials
@@ -65,8 +67,7 @@ nor ``ANYMAIL_SPARKPOST_API_KEY`` is set.
.. rubric:: SPARKPOST_API_URL
The `SparkPost API Endpoint`_ to use. This setting is optional; if not provided, Anymail will
use the :pypi:`python-sparkpost` client default endpoint (``"https://api.sparkpost.com/api/v1"``).
The `SparkPost API Endpoint`_ to use. The default is ``"https://api.sparkpost.com/api/v1"``.
Set this to use a SparkPost EU account, or to work with any other API endpoint including
SparkPost Enterprise API and SparkPost Labs.
@@ -79,8 +80,6 @@ SparkPost Enterprise API and SparkPost Labs.
}
You must specify the full, versioned API endpoint as shown above (not just the base_uri).
This setting only affects Anymail's calls to SparkPost, and will not apply to other code
using :pypi:`python-sparkpost`.
.. _SparkPost API Endpoint: https://developers.sparkpost.com/api/index.html#header-api-endpoints
@@ -90,28 +89,47 @@ using :pypi:`python-sparkpost`.
esp_extra support
-----------------
To use SparkPost features not directly supported by Anymail, you can
set a message's :attr:`~anymail.message.AnymailMessage.esp_extra` to
a `dict` of parameters for python-sparkpost's `transmissions.send method`_.
Any keys in your :attr:`esp_extra` dict will override Anymail's normal
values for that parameter.
To use SparkPost features not directly supported by Anymail, you can set
a message's :attr:`~anymail.message.AnymailMessage.esp_extra` to a `dict`
of `transmissions API request body`_ data. Anymail will deeply merge your overrides
into the normal API payload it has constructed, with esp_extra taking precedence
in conflicts.
Example:
Example (you probably wouldn't combine all of these options at once):
.. code-block:: python
message.esp_extra = {
'transactional': True, # treat as transactional for unsubscribe and suppression
'description': "Marketing test-run for new templates",
'use_draft_template': True,
"options": {
# Treat as transactional for unsubscribe and suppression:
"transactional": True,
# Override your default dedicated IP pool:
"ip_pool": "transactional_pool",
},
# Add a description:
"description": "Test-run for new templates",
"content": {
# Use draft rather than published template:
"use_draft_template": True,
# Use an A/B test:
"ab_test_id": "highlight_support_links",
},
# Use a stored recipients list (overrides message to/cc/bcc):
"recipients": {
"list_id": "design_team"
},
}
Note that including ``"recipients"`` in esp_extra will *completely* override the
recipients list Anymail generates from your message's to/cc/bcc fields, along with any
per-recipient :attr:`~anymail.message.AnymailMessage.merge_data` and
:attr:`~anymail.message.AnymailMessage.merge_metadata`.
(You can also set `"esp_extra"` in Anymail's :ref:`global send defaults <send-defaults>`
to apply it to all messages.)
.. _transmissions.send method:
https://python-sparkpost.readthedocs.io/en/latest/api/transmissions.html#sparkpost.transmissions.Transmissions.send
.. _transmissions API request body:
https://developers.sparkpost.com/api/transmissions/#header-request-body
@@ -151,6 +169,13 @@ Limitations and quirks
(SparkPost's "recipient tags" are not available for tagging *messages*.
They're associated with individual *addresses* in stored recipient lists.)
**AMP for Email**
SparkPost supports sending AMPHTML email content. To include it, use
``message.attach_alternative("...AMPHTML content...", "text/x-amp-html")``
(and be sure to also include regular HTML and/or text bodies, too).
.. versionadded:: 8.0
**Envelope sender may use domain only**
Anymail's :attr:`~anymail.message.AnymailMessage.envelope_sender` is used to
populate SparkPost's `'return_path'` parameter. Anymail supplies the full

View File

@@ -12,11 +12,6 @@ used with Django's batch-sending functions :func:`~django.core.mail.send_mass_ma
:meth:`connection.send_messages`. See :ref:`django:topics-sending-multiple-emails`
in the Django docs for more info and an example.
(The exception is when Anymail wraps an ESP's official Python package, and that
package doesn't support connection reuse. Django's batch-sending functions will
still work, but will incur the overhead of creating a separate connection for each
message sent. Currently, only SparkPost has this limitation.)
If you need even more performance, you may want to consider your ESP's batch-sending
features. When supported by your ESP, Anymail can send multiple messages with a single
API call. See :ref:`batch-send` for details, and be sure to check the

View File

@@ -54,11 +54,11 @@ setup(
"postmark": [],
"sendgrid": [],
"sendinblue": [],
"sparkpost": ["sparkpost"],
"sparkpost": [],
},
include_package_data=True,
test_suite="runtests.runtests",
tests_require=["mock", "boto3", "sparkpost"],
tests_require=["mock", "boto3"],
classifiers=[
"Development Status :: 5 - Production/Stable",
"Programming Language :: Python",

View File

@@ -1,83 +1,54 @@
import os
import json
from datetime import date, datetime
from decimal import Decimal
from email.mime.base import MIMEBase
from email.mime.image import MIMEImage
from io import BytesIO
from email.mime.text import MIMEText
import requests
from django.core import mail
from django.test import SimpleTestCase, override_settings, tag
from django.test import override_settings, tag
from django.utils.timezone import get_fixed_timezone, override as override_current_timezone, utc
from mock import patch
from anymail.exceptions import (
AnymailAPIError, AnymailConfigurationError, AnymailInvalidAddress, AnymailRecipientsRefused,
AnymailUnsupportedFeature)
AnymailSerializationError, AnymailUnsupportedFeature)
from anymail.message import attach_inline_image_file
from .utils import AnymailTestMixin, SAMPLE_IMAGE_FILENAME, decode_att, sample_image_content, sample_image_path
from .mock_requests_backend import RequestsBackendMockAPITestCase
from .utils import SAMPLE_IMAGE_FILENAME, decode_att, sample_image_content, sample_image_path
@tag('sparkpost')
@override_settings(EMAIL_BACKEND='anymail.backends.sparkpost.EmailBackend',
ANYMAIL={'SPARKPOST_API_KEY': 'test_api_key'})
class SparkPostBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
class SparkPostBackendMockAPITestCase(RequestsBackendMockAPITestCase):
"""TestCase that uses SparkPostEmailBackend with a mocked transmissions.send API"""
DEFAULT_RAW_RESPONSE = b"""{
"results": {
"id": "12345678901234567890",
"total_accepted_recipients": 1,
"total_rejected_recipients": 0
}
}"""
def setUp(self):
super().setUp()
self.patch_send = patch('sparkpost.Transmissions.send', autospec=True)
self.mock_send = self.patch_send.start()
self.addCleanup(self.patch_send.stop)
self.set_mock_response()
# Simple message useful for many tests
self.message = mail.EmailMultiAlternatives('Subject', 'Text Body',
'from@example.com', ['to@example.com'])
def set_mock_response(self, accepted=1, rejected=0, raw=None):
# SparkPost.transmissions.send returns the parsed 'result' field
# from the transmissions/send JSON response
self.mock_send.return_value = raw or {
"id": "12345678901234567890",
def set_mock_result(self, accepted=1, rejected=0, id="12345678901234567890"):
"""Set a mock response that reflects count of accepted/rejected recipients"""
raw = json.dumps({
"results": {
"id": id,
"total_accepted_recipients": accepted,
"total_rejected_recipients": rejected,
}
return self.mock_send.return_value
def set_mock_failure(self, status_code=400, raw=b'{"errors":[{"message":"test error"}]}', encoding='utf-8'):
from sparkpost.exceptions import SparkPostAPIException
# Need to build a real(-ish) requests.Response for SparkPostAPIException
response = requests.Response()
response.status_code = status_code
response.encoding = encoding
response.raw = BytesIO(raw)
response.url = "/mock/send"
self.mock_send.side_effect = SparkPostAPIException(response)
def get_send_params(self):
"""Returns kwargs params passed to the mock send API.
Fails test if API wasn't called.
"""
if self.mock_send.call_args is None:
raise AssertionError("API was not called")
(args, kwargs) = self.mock_send.call_args
return kwargs
def get_send_api_key(self):
"""Returns api_key on SparkPost api object used for mock send
Fails test if API wasn't called
"""
if self.mock_send.call_args is None:
raise AssertionError("API was not called")
(args, kwargs) = self.mock_send.call_args
mock_self = args[0]
return mock_self.api_key
def assert_esp_not_called(self, msg=None):
if self.mock_send.called:
raise AssertionError(msg or "ESP API was called and shouldn't have been")
}).encode("utf-8")
self.set_mock_response(raw=raw)
return raw
@tag('sparkpost')
@@ -88,56 +59,88 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase):
"""Test basic API for simple send"""
mail.send_mail('Subject here', 'Here is the message.',
'from@example.com', ['to@example.com'], fail_silently=False)
params = self.get_send_params()
self.assertEqual(params['subject'], "Subject here")
self.assertEqual(params['text'], "Here is the message.")
self.assertEqual(params['from_email'], "from@example.com")
self.assertEqual(params['recipients'], ["to@example.com"])
self.assertEqual(self.get_send_api_key(), 'test_api_key')
self.assert_esp_called('/api/v1/transmissions/')
headers = self.get_api_call_headers()
self.assertEqual("test_api_key", headers["Authorization"])
data = self.get_api_call_json()
self.assertEqual(data["content"]["subject"], "Subject here")
self.assertEqual(data["content"]["text"], "Here is the message.")
self.assertEqual(data["content"]["from"], "from@example.com")
self.assertEqual(data['recipients'], [{
"address": {"email": "to@example.com", "header_to": "to@example.com"}
}])
def test_name_addr(self):
"""Make sure RFC2822 name-addr format (with display-name) is allowed
(Test both sender and recipient addresses)
"""
self.set_mock_response(accepted=6)
self.set_mock_result(accepted=6)
msg = mail.EmailMessage(
'Subject', 'Message', 'From Name <from@example.com>',
['Recipient #1 <to1@example.com>', 'to2@example.com'],
cc=['Carbon Copy <cc1@example.com>', 'cc2@example.com'],
bcc=['Blind Copy <bcc1@example.com>', 'bcc2@example.com'])
msg.send()
params = self.get_send_params()
self.assertEqual(params['from_email'], "From Name <from@example.com>")
# We pre-parse the to-field emails (merge_data also gets attached there):
self.assertEqual(params['recipients'], ['Recipient #1 <to1@example.com>', 'to2@example.com'])
# We let python-sparkpost parse the other email fields:
self.assertEqual(params['cc'], ['Carbon Copy <cc1@example.com>', 'cc2@example.com'])
self.assertEqual(params['bcc'], ['Blind Copy <bcc1@example.com>', 'bcc2@example.com'])
def test_email_message(self):
self.set_mock_response(accepted=6)
data = self.get_api_call_json()
self.assertEqual(data["content"]["from"], "From Name <from@example.com>")
# This also checks recipient generation for cc and bcc. Because it's *not*
# a batch send, each recipient should see a To header reflecting all To addresses.
self.assertCountEqual(data["recipients"], [
{"address": {
"email": "to1@example.com",
"header_to": "Recipient #1 <to1@example.com>, to2@example.com",
}},
{"address": {
"email": "to2@example.com",
"header_to": "Recipient #1 <to1@example.com>, to2@example.com",
}},
# cc and bcc must be explicitly specified as recipients
{"address": {
"email": "cc1@example.com",
"header_to": "Recipient #1 <to1@example.com>, to2@example.com",
}},
{"address": {
"email": "cc2@example.com",
"header_to": "Recipient #1 <to1@example.com>, to2@example.com",
}},
{"address": {
"email": "bcc1@example.com",
"header_to": "Recipient #1 <to1@example.com>, to2@example.com",
}},
{"address": {
"email": "bcc2@example.com",
"header_to": "Recipient #1 <to1@example.com>, to2@example.com",
}},
])
# Make sure we added a formatted Cc header visible to recipients
# (and not a Bcc header)
self.assertEqual(data["content"]["headers"], {
"Cc": "Carbon Copy <cc1@example.com>, cc2@example.com"
})
def test_custom_headers(self):
self.set_mock_result(accepted=6)
email = mail.EmailMessage(
'Subject', 'Body goes here', 'from@example.com',
['to1@example.com', 'Also To <to2@example.com>'],
bcc=['bcc1@example.com', 'Also BCC <bcc2@example.com>'],
cc=['cc1@example.com', 'Also CC <cc2@example.com>'],
'Subject', 'Body goes here', 'from@example.com', ['to1@example.com'],
cc=['cc1@example.com'],
headers={'Reply-To': 'another@example.com',
'X-MyHeader': 'my value',
'Message-ID': 'mycustommsgid@example.com'})
email.send()
params = self.get_send_params()
self.assertEqual(params['subject'], "Subject")
self.assertEqual(params['text'], "Body goes here")
self.assertEqual(params['from_email'], "from@example.com")
self.assertEqual(params['recipients'], ['to1@example.com', 'Also To <to2@example.com>'])
self.assertEqual(params['bcc'], ['bcc1@example.com', 'Also BCC <bcc2@example.com>'])
self.assertEqual(params['cc'], ['cc1@example.com', 'Also CC <cc2@example.com>'])
self.assertEqual(params['reply_to'], 'another@example.com')
self.assertEqual(params['custom_headers'], {
'X-MyHeader': 'my value',
'Message-ID': 'mycustommsgid@example.com'})
data = self.get_api_call_json()
self.assertEqual(data["content"]["headers"], {
# Reply-To moved to separate param (below)
"X-MyHeader": "my value",
"Message-ID": "mycustommsgid@example.com",
"Cc": "cc1@example.com", # Cc header added
})
self.assertEqual(data["content"]["reply_to"], "another@example.com")
def test_html_message(self):
text_content = 'This is an important message.'
@@ -146,29 +149,33 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase):
'from@example.com', ['to@example.com'])
email.attach_alternative(html_content, "text/html")
email.send()
params = self.get_send_params()
self.assertEqual(params['text'], text_content)
self.assertEqual(params['html'], html_content)
data = self.get_api_call_json()
self.assertEqual(data["content"]["text"], text_content)
self.assertEqual(data["content"]["html"], html_content)
# Don't accidentally send the html part as an attachment:
self.assertNotIn('attachments', params)
self.assertNotIn("attachments", data["content"])
def test_html_only_message(self):
html_content = '<p>This is an <strong>important</strong> message.</p>'
email = mail.EmailMessage('Subject', html_content, 'from@example.com', ['to@example.com'])
email.content_subtype = "html" # Main content is now text/html
email.send()
params = self.get_send_params()
self.assertNotIn('text', params)
self.assertEqual(params['html'], html_content)
data = self.get_api_call_json()
self.assertNotIn("text", data["content"])
self.assertEqual(data["content"]["html"], html_content)
def test_reply_to(self):
email = mail.EmailMessage('Subject', 'Body goes here', 'from@example.com', ['to1@example.com'],
reply_to=['reply@example.com', 'Other <reply2@example.com>'],
headers={'X-Other': 'Keep'})
email.send()
params = self.get_send_params()
self.assertEqual(params['reply_to'], 'reply@example.com, Other <reply2@example.com>')
self.assertEqual(params['custom_headers'], {'X-Other': 'Keep'}) # don't lose other headers
data = self.get_api_call_json()
self.assertEqual(data["content"]["reply_to"],
"reply@example.com, Other <reply2@example.com>")
self.assertEqual(data["content"]["headers"], {"X-Other": "Keep"}) # don't lose other headers
def test_attachments(self):
text_content = "* Item one\n* Item two\n* Item three"
@@ -185,30 +192,39 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase):
self.message.attach(mimeattachment)
self.message.send()
params = self.get_send_params()
attachments = params['attachments']
data = self.get_api_call_json()
attachments = data["content"]["attachments"]
self.assertEqual(len(attachments), 3)
self.assertEqual(attachments[0]['type'], 'text/plain')
self.assertEqual(attachments[0]['name'], 'test.txt')
self.assertEqual(decode_att(attachments[0]['data']).decode('ascii'), text_content)
self.assertEqual(attachments[1]['type'], 'image/png') # inferred from filename
self.assertEqual(attachments[1]['name'], 'test.png')
self.assertEqual(decode_att(attachments[1]['data']), png_content)
self.assertEqual(attachments[2]['type'], 'application/pdf')
self.assertEqual(attachments[2]['name'], '') # none
self.assertEqual(decode_att(attachments[2]['data']), pdf_content)
self.assertEqual(attachments[0]["type"], "text/plain")
self.assertEqual(attachments[0]["name"], "test.txt")
self.assertEqual(decode_att(attachments[0]["data"]).decode("ascii"), text_content)
self.assertEqual(attachments[1]["type"], "image/png") # inferred from filename
self.assertEqual(attachments[1]["name"], "test.png")
self.assertEqual(decode_att(attachments[1]["data"]), png_content)
self.assertEqual(attachments[2]["type"], "application/pdf")
self.assertEqual(attachments[2]["name"], "") # none
self.assertEqual(decode_att(attachments[2]["data"]), pdf_content)
# Make sure the image attachment is not treated as embedded:
self.assertNotIn('inline_images', params)
self.assertNotIn("inline_images", data["content"])
def test_unicode_attachment_correctly_decoded(self):
# Slight modification from the Django unicode docs:
# http://django.readthedocs.org/en/latest/ref/unicode.html#email
self.message.attach("Une pièce jointe.html", '<p>\u2019</p>', mimetype='text/html')
self.message.send()
params = self.get_send_params()
attachments = params['attachments']
data = self.get_api_call_json()
attachments = data["content"]["attachments"]
self.assertEqual(len(attachments), 1)
def test_attachment_charset(self):
# SparkPost allows charset param in attachment type
self.message.attach(MIMEText("Une pièce jointe", "plain", "iso8859-1"))
self.message.send()
data = self.get_api_call_json()
attachment = data["content"]["attachments"][0]
self.assertEqual(attachment["type"], 'text/plain; charset="iso8859-1"')
self.assertEqual(decode_att(attachment["data"]), "Une pièce jointe".encode("iso8859-1"))
def test_embedded_images(self):
image_filename = SAMPLE_IMAGE_FILENAME
image_path = sample_image_path(image_filename)
@@ -219,15 +235,15 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase):
self.message.attach_alternative(html_content, "text/html")
self.message.send()
params = self.get_send_params()
self.assertEqual(params['html'], html_content)
data = self.get_api_call_json()
self.assertEqual(data["content"]["html"], html_content)
self.assertEqual(len(params['inline_images']), 1)
self.assertEqual(params['inline_images'][0]["type"], "image/png")
self.assertEqual(params['inline_images'][0]["name"], cid)
self.assertEqual(decode_att(params['inline_images'][0]["data"]), image_data)
self.assertEqual(len(data["content"]["inline_images"]), 1)
self.assertEqual(data["content"]["inline_images"][0]["type"], "image/png")
self.assertEqual(data["content"]["inline_images"][0]["name"], cid)
self.assertEqual(decode_att(data["content"]["inline_images"][0]["data"]), image_data)
# Make sure neither the html nor the inline image is treated as an attachment:
self.assertNotIn('attachments', params)
self.assertNotIn("attachments", data["content"])
def test_attached_images(self):
image_filename = SAMPLE_IMAGE_FILENAME
@@ -240,8 +256,8 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase):
self.message.attach(image)
self.message.send()
params = self.get_send_params()
attachments = params['attachments']
data = self.get_api_call_json()
attachments = data["content"]["attachments"]
self.assertEqual(len(attachments), 2)
self.assertEqual(attachments[0]["type"], "image/png")
self.assertEqual(attachments[0]["name"], image_filename)
@@ -250,15 +266,24 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase):
self.assertEqual(attachments[1]["name"], "") # unknown -- not attached as file
self.assertEqual(decode_att(attachments[1]["data"]), image_data)
# Make sure the image attachments are not treated as embedded:
self.assertNotIn('inline_images', params)
self.assertNotIn("inline_images", data["content"])
def test_multiple_html_alternatives(self):
# Multiple alternatives not allowed
# Multiple text/html alternatives not allowed
self.message.attach_alternative("<p>First html is OK</p>", "text/html")
self.message.attach_alternative("<p>But not second html</p>", "text/html")
with self.assertRaises(AnymailUnsupportedFeature):
self.message.send()
def test_amp_html_alternative(self):
# SparkPost *does* support text/x-amp-html alongside text/html
self.message.attach_alternative("<p>HTML</p>", "text/html")
self.message.attach_alternative("<p>And AMP HTML</p>", "text/x-amp-html")
self.message.send()
data = self.get_api_call_json()
self.assertEqual(data["content"]["html"], "<p>HTML</p>")
self.assertEqual(data["content"]["amp_html"], "<p>And AMP HTML</p>")
def test_html_alternative(self):
# Only html alternatives allowed
self.message.attach_alternative("{'not': 'allowed'}", "application/json")
@@ -275,24 +300,30 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase):
def test_suppress_empty_address_lists(self):
"""Empty to, cc, bcc, and reply_to shouldn't generate empty headers"""
self.message.send()
params = self.get_send_params()
self.assertNotIn('cc', params)
self.assertNotIn('bcc', params)
self.assertNotIn('reply_to', params)
data = self.get_api_call_json()
self.assertNotIn("headers", data["content"]) # No Cc, Bcc or Reply-To header
self.assertNotIn("reply_to", data["content"])
def test_empty_to(self):
# Test empty `to` -- but send requires at least one recipient somewhere (like cc)
self.message.to = []
self.message.cc = ['cc@example.com']
self.message.cc = ["cc@example.com"]
self.message.send()
params = self.get_send_params()
self.assertNotIn('recipients', params)
data = self.get_api_call_json()
self.assertEqual(data["recipients"], [{
"address": {
"email": "cc@example.com",
# This results in a message with an empty To header, as desired:
"header_to": "",
},
}])
def test_multiple_from_emails(self):
"""SparkPost supports multiple addresses in from_email"""
self.message.from_email = 'first@example.com, "From, also" <second@example.com>'
self.message.send()
params = self.get_send_params()
self.assertEqual(params['from_email'],
data = self.get_api_call_json()
self.assertEqual(data["content"]["from"],
'first@example.com, "From, also" <second@example.com>')
# Make sure the far-more-likely scenario of a single from_email
@@ -302,13 +333,13 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase):
self.message.send()
def test_api_failure(self):
self.set_mock_failure(status_code=400)
self.set_mock_response(status_code=400)
with self.assertRaisesMessage(AnymailAPIError, "SparkPost API response 400"):
self.message.send()
def test_api_failure_fail_silently(self):
# Make sure fail_silently is respected
self.set_mock_failure()
self.set_mock_response(status_code=400)
sent = self.message.send(fail_silently=True)
self.assertEqual(sent, 0)
@@ -319,7 +350,7 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase):
"message": "Helpful explanation from your ESP"
}]
}"""
self.set_mock_failure(raw=failure_response)
self.set_mock_response(status_code=400, raw=failure_response)
with self.assertRaisesMessage(AnymailAPIError, "Helpful explanation from your ESP"):
self.message.send()
@@ -331,14 +362,14 @@ class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase):
def test_envelope_sender(self):
self.message.envelope_sender = "bounce-handler@bounces.example.com"
self.message.send()
params = self.get_send_params()
self.assertEqual(params['return_path'], "bounce-handler@bounces.example.com")
data = self.get_api_call_json()
self.assertEqual(data["return_path"], "bounce-handler@bounces.example.com")
def test_metadata(self):
self.message.metadata = {'user_id': "12345", 'items': 'spark, post'}
self.message.send()
params = self.get_send_params()
self.assertEqual(params['metadata'], {'user_id': "12345", 'items': 'spark, post'})
data = self.get_api_call_json()
self.assertEqual(data["metadata"], {'user_id': "12345", 'items': 'spark, post'})
def test_send_at(self):
utc_plus_6 = get_fixed_timezone(6 * 60)
@@ -349,45 +380,45 @@ class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase):
# Timezone-aware datetime converted to UTC:
self.message.send_at = datetime(2016, 3, 4, 5, 6, 7, tzinfo=utc_minus_8)
self.message.send()
params = self.get_send_params()
self.assertEqual(params['start_time'], "2016-03-04T05:06:07-08:00")
data = self.get_api_call_json()
self.assertEqual(data["options"]["start_time"], "2016-03-04T05:06:07-08:00")
# Explicit UTC:
self.message.send_at = datetime(2016, 3, 4, 5, 6, 7, tzinfo=utc)
self.message.send()
params = self.get_send_params()
self.assertEqual(params['start_time'], "2016-03-04T05:06:07+00:00")
data = self.get_api_call_json()
self.assertEqual(data["options"]["start_time"], "2016-03-04T05:06:07+00:00")
# Timezone-naive datetime assumed to be Django current_timezone
# (also checks stripping microseconds)
self.message.send_at = datetime(2022, 10, 11, 12, 13, 14, 567)
self.message.send()
params = self.get_send_params()
self.assertEqual(params['start_time'], "2022-10-11T12:13:14+06:00")
data = self.get_api_call_json()
self.assertEqual(data["options"]["start_time"], "2022-10-11T12:13:14+06:00")
# Date-only treated as midnight in current timezone
self.message.send_at = date(2022, 10, 22)
self.message.send()
params = self.get_send_params()
self.assertEqual(params['start_time'], "2022-10-22T00:00:00+06:00")
data = self.get_api_call_json()
self.assertEqual(data["options"]["start_time"], "2022-10-22T00:00:00+06:00")
# POSIX timestamp
self.message.send_at = 1651820889 # 2022-05-06 07:08:09 UTC
self.message.send()
params = self.get_send_params()
self.assertEqual(params['start_time'], "2022-05-06T07:08:09+00:00")
data = self.get_api_call_json()
self.assertEqual(data["options"]["start_time"], "2022-05-06T07:08:09+00:00")
# String passed unchanged (this is *not* portable between ESPs)
self.message.send_at = "2022-10-13T18:02:00-11:30"
self.message.send()
params = self.get_send_params()
self.assertEqual(params['start_time'], "2022-10-13T18:02:00-11:30")
data = self.get_api_call_json()
self.assertEqual(data["options"]["start_time"], "2022-10-13T18:02:00-11:30")
def test_tags(self):
self.message.tags = ["receipt"]
self.message.send()
params = self.get_send_params()
self.assertEqual(params['campaign'], "receipt")
data = self.get_api_call_json()
self.assertEqual(data["campaign_id"], "receipt")
self.message.tags = ["receipt", "repeat-user"]
with self.assertRaisesMessage(AnymailUnsupportedFeature, 'multiple tags'):
@@ -398,66 +429,77 @@ class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase):
self.message.track_opens = True
self.message.track_clicks = False
self.message.send()
params = self.get_send_params()
self.assertEqual(params['track_opens'], True)
self.assertEqual(params['track_clicks'], False)
data = self.get_api_call_json()
self.assertEqual(data["options"]["open_tracking"], True)
self.assertEqual(data["options"]["click_tracking"], False)
# ...and the opposite way
self.message.track_opens = False
self.message.track_clicks = True
self.message.send()
params = self.get_send_params()
self.assertEqual(params['track_opens'], False)
self.assertEqual(params['track_clicks'], True)
data = self.get_api_call_json()
self.assertEqual(data["options"]["open_tracking"], False)
self.assertEqual(data["options"]["click_tracking"], True)
def test_template_id(self):
message = mail.EmailMultiAlternatives(from_email='from@example.com', to=['to@example.com'])
message.template_id = "welcome_template"
message.send()
params = self.get_send_params()
self.assertEqual(params['template'], "welcome_template")
data = self.get_api_call_json()
self.assertEqual(data["content"]["template_id"], "welcome_template")
# SparkPost disallows all content (even empty strings) with stored template:
self.assertNotIn('subject', params)
self.assertNotIn('text', params)
self.assertNotIn('html', params)
self.assertNotIn("subject", data["content"])
self.assertNotIn("text", data["content"])
self.assertNotIn("html", data["content"])
def test_merge_data(self):
self.set_mock_response(accepted=2)
self.set_mock_result(accepted=4) # two 'to' plus one 'cc' for each 'to'
self.message.to = ['alice@example.com', 'Bob <bob@example.com>']
self.message.body = "Hi %recipient.name%. Welcome to %recipient.group% at %recipient.site%."
self.message.cc = ['cc@example.com']
self.message.body = "Hi {{address.name}}. Welcome to {{group}} at {{site}}."
self.message.merge_data = {
'alice@example.com': {'name': "Alice", 'group': "Developers"},
'bob@example.com': {'name': "Bob"}, # and leave group undefined
'nobody@example.com': {'name': "Not a recipient for this message"},
}
self.message.merge_global_data = {'group': "Users", 'site': "ExampleCo"}
self.message.send()
params = self.get_send_params()
self.assertEqual(params['recipients'], [
{'address': {'email': 'alice@example.com'},
'substitution_data': {'name': "Alice", 'group': "Developers"}},
{'address': {'email': 'bob@example.com', 'name': 'Bob'},
'substitution_data': {'name': "Bob"}}
])
self.assertEqual(params['substitution_data'], {'group': "Users", 'site': "ExampleCo"})
data = self.get_api_call_json()
self.assertEqual({"group": "Users", "site": "ExampleCo"}, data["substitution_data"])
self.assertEqual([{
"address": {"email": "alice@example.com", "header_to": "alice@example.com"},
"substitution_data": {"name": "Alice", "group": "Developers"},
}, {
"address": {"email": "bob@example.com", "header_to": "Bob <bob@example.com>"},
"substitution_data": {"name": "Bob"},
}, { # duplicated for cc recipients...
"address": {"email": "cc@example.com", "header_to": "alice@example.com"},
"substitution_data": {"name": "Alice", "group": "Developers"},
}, {
"address": {"email": "cc@example.com", "header_to": "Bob <bob@example.com>"},
"substitution_data": {"name": "Bob"},
}], data["recipients"])
def test_merge_metadata(self):
self.set_mock_response(accepted=2)
self.set_mock_result(accepted=2)
self.message.to = ['alice@example.com', 'Bob <bob@example.com>']
self.message.merge_metadata = {
'alice@example.com': {'order_id': 123},
'bob@example.com': {'order_id': 678, 'tier': 'premium'},
}
self.message.metadata = {'notification_batch': 'zx912'}
self.message.send()
params = self.get_send_params()
self.assertEqual(params['recipients'], [
{'address': {'email': 'alice@example.com'},
'metadata': {'order_id': 123}},
{'address': {'email': 'bob@example.com', 'name': 'Bob'},
'metadata': {'order_id': 678, 'tier': 'premium'}}
])
self.assertEqual(params['metadata'], {'notification_batch': 'zx912'})
data = self.get_api_call_json()
self.assertEqual([{
"address": {"email": "alice@example.com", "header_to": "alice@example.com"},
"metadata": {"order_id": 123},
}, {
"address": {"email": "bob@example.com", "header_to": "Bob <bob@example.com>"},
"metadata": {"order_id": 678, "tier": "premium"}
}], data["recipients"])
self.assertEqual(data["metadata"], {"notification_batch": "zx912"})
def test_default_omits_options(self):
"""Make sure by default we don't send any ESP-specific options.
@@ -467,31 +509,42 @@ class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase):
that your ESP account settings apply by default.
"""
self.message.send()
params = self.get_send_params()
self.assertNotIn('campaign', params)
self.assertNotIn('metadata', params)
self.assertNotIn('start_time', params)
self.assertNotIn('substitution_data', params)
self.assertNotIn('template', params)
self.assertNotIn('track_clicks', params)
self.assertNotIn('track_opens', params)
data = self.get_api_call_json()
self.assertNotIn("campaign_id", data)
self.assertNotIn("metadata", data)
self.assertNotIn("options", data) # covers start_time, click_tracking, open_tracking
self.assertNotIn("substitution_data", data)
self.assertNotIn("template_id", data["content"])
def test_esp_extra(self):
self.message.esp_extra = {
'future_sparkpost_send_param': 'some-value',
"description": "The description",
"options": {
"transactional": True,
},
"content": {
"use_draft_template": True,
"ab_test_id": "highlight_support_links",
},
}
self.message.track_clicks = True
self.message.send()
params = self.get_send_params()
self.assertEqual(params['future_sparkpost_send_param'], 'some-value')
data = self.get_api_call_json()
self.assertEqual(data["description"], "The description")
self.assertEqual(data["options"], {
"transactional": True,
"click_tracking": True, # deep merge
})
self.assertDictMatches({
"use_draft_template": True,
"ab_test_id": "highlight_support_links",
"text": "Text Body", # deep merge
"subject": "Subject", # deep merge
}, data["content"])
def test_send_attaches_anymail_status(self):
"""The anymail_status should be attached to the message when it is sent """
response_content = {
'id': '9876543210',
'total_accepted_recipients': 1,
'total_rejected_recipients': 0,
}
self.set_mock_response(raw=response_content)
response_content = self.set_mock_result(accepted=1, rejected=0, id="9876543210")
msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],)
sent = msg.send()
self.assertEqual(sent, 1)
@@ -499,12 +552,12 @@ class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase):
self.assertEqual(msg.anymail_status.message_id, '9876543210')
self.assertEqual(msg.anymail_status.recipients['to1@example.com'].status, 'queued')
self.assertEqual(msg.anymail_status.recipients['to1@example.com'].message_id, '9876543210')
self.assertEqual(msg.anymail_status.esp_response, response_content)
self.assertEqual(msg.anymail_status.esp_response.content, response_content)
@override_settings(ANYMAIL_IGNORE_RECIPIENT_STATUS=True) # exception is tested later
def test_send_all_rejected(self):
"""The anymail_status should be 'rejected' when all recipients rejected"""
self.set_mock_response(accepted=0, rejected=2)
self.set_mock_result(accepted=0, rejected=2)
msg = mail.EmailMessage('Subject', 'Message', 'from@example.com',
['to1@example.com', 'to2@example.com'],)
msg.send()
@@ -514,7 +567,7 @@ class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase):
def test_send_some_rejected(self):
"""The anymail_status should be 'unknown' when some recipients accepted and some rejected"""
self.set_mock_response(accepted=1, rejected=1)
self.set_mock_result(accepted=1, rejected=1)
msg = mail.EmailMessage('Subject', 'Message', 'from@example.com',
['to1@example.com', 'to2@example.com'],)
msg.send()
@@ -525,7 +578,7 @@ class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase):
def test_send_unexpected_count(self):
"""The anymail_status should be 'unknown' when the total result count
doesn't match the number of recipients"""
self.set_mock_response(accepted=3, rejected=0) # but only 2 in the to-list
self.set_mock_result(accepted=3, rejected=0) # but only 2 in the to-list
msg = mail.EmailMessage('Subject', 'Message', 'from@example.com',
['to1@example.com', 'to2@example.com'],)
msg.send()
@@ -536,7 +589,7 @@ class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase):
# noinspection PyUnresolvedReferences
def test_send_failed_anymail_status(self):
""" If the send fails, anymail_status should contain initial values"""
self.set_mock_failure()
self.set_mock_response(status_code=400)
sent = self.message.send(fail_silently=True)
self.assertEqual(sent, 0)
self.assertIsNone(self.message.anymail_status.status)
@@ -547,20 +600,25 @@ class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase):
# noinspection PyUnresolvedReferences
def test_send_unparsable_response(self):
"""If the send succeeds, but result is unexpected format, should raise an API exception"""
response_content = {'wrong': 'format'}
response_content = b"""{"wrong": "format"}"""
self.set_mock_response(raw=response_content)
with self.assertRaises(AnymailAPIError):
self.message.send()
self.assertIsNone(self.message.anymail_status.status)
self.assertIsNone(self.message.anymail_status.message_id)
self.assertEqual(self.message.anymail_status.recipients, {})
self.assertEqual(self.message.anymail_status.esp_response, response_content)
self.assertEqual(self.message.anymail_status.esp_response.content, response_content)
# test_json_serialization_errors:
# Although SparkPost will raise JSON serialization errors, they're coming
# from deep within the python-sparkpost implementation. Since it's an
# implementation detail of that package, Anymail doesn't try to catch or
# modify those errors.
def test_json_serialization_errors(self):
"""Try to provide more information about non-json-serializable data"""
self.message.tags = [Decimal('19.99')] # yeah, don't do this
with self.assertRaises(AnymailSerializationError) as cm:
self.message.send()
print(self.get_api_call_json())
err = cm.exception
self.assertIsInstance(err, TypeError) # compatibility with json.dumps
self.assertIn("Don't know how to send this data to SparkPost", str(err)) # our added context
self.assertRegex(str(err), r"Decimal.*is not JSON serializable") # original message
@tag('sparkpost')
@@ -568,14 +626,14 @@ class SparkPostBackendRecipientsRefusedTests(SparkPostBackendMockAPITestCase):
"""Should raise AnymailRecipientsRefused when *all* recipients are rejected or invalid"""
def test_recipients_refused(self):
self.set_mock_response(accepted=0, rejected=2)
self.set_mock_result(accepted=0, rejected=2)
msg = mail.EmailMessage('Subject', 'Body', 'from@example.com',
['invalid@localhost', 'reject@example.com'])
with self.assertRaises(AnymailRecipientsRefused):
msg.send()
def test_fail_silently(self):
self.set_mock_response(accepted=0, rejected=2)
self.set_mock_result(accepted=0, rejected=2)
sent = mail.send_mail('Subject', 'Body', 'from@example.com',
['invalid@localhost', 'reject@example.com'],
fail_silently=True)
@@ -583,7 +641,7 @@ class SparkPostBackendRecipientsRefusedTests(SparkPostBackendMockAPITestCase):
def test_mixed_response(self):
"""If *any* recipients are valid or queued, no exception is raised"""
self.set_mock_response(accepted=2, rejected=2)
self.set_mock_result(accepted=2, rejected=2)
msg = mail.EmailMessage('Subject', 'Body', 'from@example.com',
['invalid@localhost', 'valid@example.com',
'reject@example.com', 'also.valid@example.com'])
@@ -599,48 +657,35 @@ class SparkPostBackendRecipientsRefusedTests(SparkPostBackendMockAPITestCase):
@override_settings(ANYMAIL_IGNORE_RECIPIENT_STATUS=True)
def test_settings_override(self):
"""No exception with ignore setting"""
self.set_mock_response(accepted=0, rejected=2)
self.set_mock_result(accepted=0, rejected=2)
sent = mail.send_mail('Subject', 'Body', 'from@example.com',
['invalid@localhost', 'reject@example.com'])
self.assertEqual(sent, 1) # refused message is included in sent count
@tag('sparkpost')
@override_settings(EMAIL_BACKEND="anymail.backends.sparkpost.EmailBackend")
class SparkPostBackendConfigurationTests(AnymailTestMixin, SimpleTestCase):
class SparkPostBackendConfigurationTests(SparkPostBackendMockAPITestCase):
"""Test various SparkPost client options"""
@override_settings(ANYMAIL={}) # clear SPARKPOST_API_KEY from SparkPostBackendMockAPITestCase
def test_missing_api_key(self):
with self.assertRaises(AnymailConfigurationError) as cm:
mail.get_connection() # this init's SparkPost without actually trying to send anything
mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com'])
errmsg = str(cm.exception)
# Make sure the error mentions the different places to set the key
self.assertRegex(errmsg, r'\bSPARKPOST_API_KEY\b')
self.assertRegex(errmsg, r'\bANYMAIL_SPARKPOST_API_KEY\b')
def test_api_key_in_env(self):
"""SparkPost package allows API key in env var; make sure Anymail works with that"""
with patch.dict(
os.environ,
{'SPARKPOST_API_KEY': 'key_from_environment'}):
conn = mail.get_connection()
# Poke into implementation details to verify:
self.assertIsNone(conn.api_key) # Anymail prop
self.assertEqual(conn.sp.api_key, 'key_from_environment') # SparkPost prop
@override_settings(ANYMAIL={
"SPARKPOST_API_URL": "https://api.eu.sparkpost.com/api/v1",
"SPARKPOST_API_KEY": "example-key",
"SPARKPOST_API_KEY": "test_api_key",
})
def test_sparkpost_api_url(self):
conn = mail.get_connection() # this init's the backend without sending anything
# Poke into implementation details to verify:
self.assertEqual(conn.sp.base_uri, "https://api.eu.sparkpost.com/api/v1")
mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com'])
self.assert_esp_called("https://api.eu.sparkpost.com/api/v1/transmissions/")
# can also override on individual connection (and even use non-versioned labs endpoint)
conn2 = mail.get_connection(api_url="https://api.sparkpost.com/api/labs")
self.assertEqual(conn2.sp.base_uri, "https://api.sparkpost.com/api/labs")
# double-check _FullSparkPostEndpoint won't interfere with additional str ops
self.assertEqual(conn.sp.base_uri + "/transmissions/send",
"https://api.eu.sparkpost.com/api/v1/transmissions/send")
connection = mail.get_connection(api_url="https://api.sparkpost.com/api/labs")
mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com'],
connection=connection)
self.assert_esp_called("https://api.sparkpost.com/api/labs/transmissions/")

View File

@@ -1,6 +1,5 @@
import os
import unittest
import warnings
from datetime import datetime, timedelta
from django.test import SimpleTestCase, override_settings, tag
@@ -41,19 +40,6 @@ class SparkPostBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
'test@test-sp.anymail.info', ['to@test.sink.sparkpostmail.com'])
self.message.attach_alternative('<p>HTML content</p>', "text/html")
# The SparkPost Python package uses requests directly, without managing sessions, and relies
# on GC to close connections. This leads to harmless (but valid) warnings about unclosed
# ssl.SSLSocket during cleanup: https://github.com/psf/requests/issues/1882
# There's not much we can do about that, short of switching from the SparkPost package
# to our own requests backend implementation (which *does* manage sessions properly).
# Unless/until we do that, filter the warnings to avoid test noise.
# Filter in TestCase.setUp because unittest resets the warning filters for each test.
# https://stackoverflow.com/a/26620811/647002
from anymail.backends.base_requests import AnymailRequestsBackend
from anymail.backends.sparkpost import EmailBackend as SparkPostBackend
assert not issubclass(SparkPostBackend, AnymailRequestsBackend) # else this filter can be removed
warnings.filterwarnings("ignore", message=r"unclosed <ssl\.SSLSocket", category=ResourceWarning)
def test_simple_send(self):
# Example of getting the SparkPost send status and transmission id from the message
sent_count = self.message.send()
@@ -73,6 +59,8 @@ class SparkPostBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
message = AnymailMessage(
subject="Anymail all-options integration test",
body="This is the text body",
# Caution: although SparkPost allows multiple From addresses,
# many ISPs will just bounce email that tries it...
from_email="Test From <test@test-sp.anymail.info>, also-from@test-sp.anymail.info",
to=["to1@test.sink.sparkpostmail.com", "Recipient 2 <to2@test.sink.sparkpostmail.com>"],
# Limit the live b/cc's to avoid running through our small monthly allowance:

View File

@@ -15,7 +15,7 @@ envlist =
# ... then prereleases (if available):
djangoDev-py{36,37,38}-all
# ... then partial installation (limit extras):
django31-py37-{none,amazon_ses,sparkpost}
django31-py37-{none,amazon_ses}
# ... then older versions of some dependencies:
django22-py37-all-old_urllib3
@@ -32,12 +32,10 @@ deps =
mock
extras =
all,amazon_ses: amazon_ses
all,sparkpost: sparkpost
setenv =
# tell runtests.py to limit some test tags based on extras factor
none: ANYMAIL_SKIP_TESTS=amazon_ses,sparkpost
none: ANYMAIL_SKIP_TESTS=amazon_ses
amazon_ses: ANYMAIL_ONLY_TEST=amazon_ses
sparkpost: ANYMAIL_ONLY_TEST=sparkpost
ignore_outcome =
# CI that wants to handle errors itself can set TOX_FORCE_IGNORE_OUTCOME=false
djangoDev: {env:TOX_FORCE_IGNORE_OUTCOME:true}