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) # Install without optional extras (don't need to cover entire matrix)
- { env: TOXENV=django31-py37-none, python: 3.7 } - { env: TOXENV=django31-py37-none, python: 3.7 }
- { env: TOXENV=django31-py37-amazon_ses, 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 # Test some specific older package versions
- { env: TOXENV=django22-py37-all-old_urllib3, python: 3.7 } - { 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 need to update it for compatibility with the new API. (See
`docs <https://anymail.readthedocs.io/en/latest/esps/mailjet/#esp-extra-support>`__.) `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 * 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 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 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 inheritance. (For some helpful background, see this comment about
`mixin superclass ordering <https://nedbatchelder.com/blog/201210/multiple_inheritance_is_hard.html#comment_13805>`__.) `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 v7.2.1
------ ------

View File

@@ -1,82 +1,46 @@
from .base import AnymailBaseBackend, BasePayload from .base_requests import AnymailRequestsBackend, RequestsPayload
from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled, AnymailConfigurationError from ..exceptions import AnymailRequestsAPIError
from ..message import AnymailRecipientStatus from ..message import AnymailRecipientStatus
from ..utils import get_anymail_setting from ..utils import get_anymail_setting, update_deep
try:
from sparkpost import SparkPost, SparkPostException
except ImportError as err:
raise AnymailImproperlyInstalled(missing_package='sparkpost', backend='sparkpost') from err
class EmailBackend(AnymailBaseBackend): class EmailBackend(AnymailRequestsBackend):
""" """
SparkPost Email Backend (using python-sparkpost client) SparkPost Email Backend
""" """
esp_name = "SparkPost" esp_name = "SparkPost"
def __init__(self, **kwargs): def __init__(self, **kwargs):
"""Init options from Django settings""" """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, 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)
api_url = get_anymail_setting('api_url', esp_name=self.esp_name, kwargs=kwargs,
# SPARKPOST_API_URL is optional - default is set by library; default="https://api.sparkpost.com/api/v1/")
# if provided, must be a full SparkPost API endpoint, including "/v1" if appropriate if not api_url.endswith("/"):
api_url = get_anymail_setting('api_url', esp_name=self.esp_name, kwargs=kwargs, default=None) api_url += "/"
extra_sparkpost_params = {} super().__init__(api_url, **kwargs)
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)
def build_message_payload(self, message, defaults): def build_message_payload(self, message, defaults):
return SparkPostPayload(message, defaults, self) 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): def parse_recipient_status(self, response, payload, message):
parsed_response = self.deserialize_json_response(response, payload, message)
try: try:
accepted = response['total_accepted_recipients'] results = parsed_response["results"]
rejected = response['total_rejected_recipients'] accepted = results["total_accepted_recipients"]
transmission_id = response['id'] rejected = results["total_rejected_recipients"]
transmission_id = results["id"]
except (KeyError, TypeError) as err: except (KeyError, TypeError) as err:
raise AnymailAPIError( raise AnymailRequestsAPIError("Invalid SparkPost API response format",
"%s in SparkPost.transmissions.send result %r" % (str(err), response), email_message=message, payload=payload,
backend=self, email_message=message, payload=payload, response=response, backend=self) from err
) 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.)
# If all are one or the other, we can report a specific status; # If all are one or the other, we can report a specific status;
# else just report 'unknown' for all recipients. # 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: if accepted == recipient_count and rejected == 0:
status = 'queued' status = 'queued'
elif rejected == recipient_count and accepted == 0: elif rejected == recipient_count and accepted == 0:
@@ -84,174 +48,202 @@ class EmailBackend(AnymailBaseBackend):
else: # mixed results, or wrong total else: # mixed results, or wrong total
status = 'unknown' status = 'unknown'
recipient_status = AnymailRecipientStatus(message_id=transmission_id, status=status) 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): class SparkPostPayload(RequestsPayload):
def init_payload(self): def __init__(self, message, defaults, backend, *args, **kwargs):
self.params = {} http_headers = {
self.all_recipients = [] 'Authorization': backend.api_key,
self.to_emails = [] 'Content-Type': 'application/json',
self.merge_data = {} }
self.merge_metadata = {} 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): def get_api_endpoint(self):
# Compose recipients param from to_emails and merge_data (if any) return "transmissions/"
recipients = []
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(): if self.is_batch():
# Build JSON recipient structures # For batch sends, must duplicate the cc/bcc for *every* to-recipient
for email in self.to_emails: # (using each to-recipient's metadata and substitutions).
rcpt = {'address': {'email': email.addr_spec}} extra_recipients = []
if email.display_name: for to_recipient in self.data["recipients"]:
rcpt['address']['name'] = email.display_name for email in self.cc_and_bcc:
try: extra = to_recipient.copy() # capture "metadata" and "substitutions", if any
rcpt['substitution_data'] = self.merge_data[email.addr_spec] extra["address"] = {
except KeyError: "email": email.addr_spec,
pass # no merge_data or none for this recipient "header_to": to_recipient["address"]["header_to"],
try: }
rcpt['metadata'] = self.merge_metadata[email.addr_spec] extra_recipients.append(extra)
except KeyError: self.data["recipients"].extend(extra_recipients)
pass # no merge_metadata or none for this recipient
recipients.append(rcpt)
else: else:
# Just use simple recipients list # For non-batch sends, we need to patch up *everyone's* displayed
recipients = [email.address for email in self.to_emails] # "To" header to show all the "To" recipients...
if recipients: full_to_header = ", ".join(
self.params['recipients'] = recipients 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): # Payload construction
for content_param in ['subject', 'text', 'html']: #
try:
if not self.params[content_param]:
del self.params[content_param]
except KeyError:
pass
return self.params def init_payload(self):
# The JSON payload:
self.data = {
"content": {},
"recipients": [],
}
def set_from_email_list(self, emails): def set_from_email_list(self, emails):
# SparkPost supports multiple From email addresses, # SparkPost supports multiple From email addresses,
# as a single comma-separated string # 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): def set_to(self, emails):
if emails: if emails:
self.to_emails = emails # bound to params['recipients'] in get_api_params # In the recipient address, "email" is the addr spec to deliver to,
self.all_recipients += emails # 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): def set_cc(self, emails):
# https://www.sparkpost.com/docs/faq/cc-bcc-with-rest-api/
if emails: if emails:
self.params['cc'] = [email.address for email in emails] # Add the Cc header, visible to all recipients:
self.all_recipients += emails 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): def set_bcc(self, emails):
if emails: if emails:
self.params['bcc'] = [email.address for email in emails] # Actual recipients are added later, in _finalize_recipients
self.all_recipients += emails self.cc_and_bcc += emails
self.recipients += emails
def set_subject(self, subject): def set_subject(self, subject):
self.params['subject'] = subject self.data["content"]["subject"] = subject
def set_reply_to(self, emails): def set_reply_to(self, emails):
if emails: if emails:
# reply_to is only documented as a single email, but this seems to work: self.data["content"]["reply_to"] = ", ".join(email.address for email in emails)
self.params['reply_to'] = ', '.join([email.address for email in emails])
def set_extra_headers(self, headers): def set_extra_headers(self, headers):
if 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): def set_text_body(self, body):
self.params['text'] = body self.data["content"]["text"] = body
def set_html_body(self, 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 # second html body could show up through multiple alternatives, or html body + alternative
self.unsupported_feature("multiple html parts") self.unsupported_feature("multiple html parts")
self.params['html'] = body self.data["content"]["html"] = body
def add_attachment(self, attachment): def add_alternative(self, content, mimetype):
if attachment.inline: if mimetype.lower() == "text/x-amp-html":
param = 'inline_images' if "amp_html" in self.data["content"]:
name = attachment.cid self.unsupported_feature("multiple html parts")
self.data["content"]["amp_html"] = content
else: else:
param = 'attachments' super().add_alternative(content, mimetype)
name = attachment.name or ''
self.params.setdefault(param, []).append({ def set_attachments(self, atts):
'type': attachment.mimetype, attachments = [{
'name': name, "name": att.name or "",
'data': attachment.b64content}) "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 # Anymail-specific payload construction
def set_envelope_sender(self, email): 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): 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): def set_send_at(self, send_at):
try: try:
self.params['start_time'] = send_at.replace(microsecond=0).isoformat() start_time = send_at.replace(microsecond=0).isoformat()
except (AttributeError, TypeError): 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): def set_tags(self, tags):
if len(tags) > 0: if len(tags) > 0:
self.params['campaign'] = tags[0] self.data["campaign_id"] = tags[0]
if len(tags) > 1: 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): 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): 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): def set_template_id(self, template_id):
# 'template' transmissions.send param becomes 'template_id' in API json 'content' self.data["content"]["template_id"] = template_id
self.params['template'] = 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): def set_merge_data(self, merge_data):
self.merge_data = merge_data # merged into params['recipients'] in get_api_params for recipient in self.data["recipients"]:
to_email = recipient["address"]["email"]
def set_merge_metadata(self, merge_metadata): if to_email in merge_data:
self.merge_metadata = merge_metadata # merged into params['recipients'] in get_api_params recipient["substitution_data"] = merge_data[to_email]
def set_merge_global_data(self, merge_global_data): 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 # ESP-specific payload construction
def set_esp_extra(self, extra): def set_esp_extra(self, extra):
self.params.update(extra) update_deep(self.data, 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))

View File

@@ -91,7 +91,7 @@ Or:
.. code-block:: console .. code-block:: console
$ pip install mock boto3 sparkpost # install test dependencies $ pip install mock boto3 # install test dependencies
$ python runtests.py $ python runtests.py
## this command can also run just a few test cases, e.g.: ## 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 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/ .. _SparkPost: https://www.sparkpost.com/
.. _Transmissions API: https://developers.sparkpost.com/api/transmissions/
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`.
Settings Settings
@@ -44,9 +42,6 @@ in your settings.py.
A SparkPost API key with at least the "Transmissions: Read/Write" permission. A SparkPost API key with at least the "Transmissions: Read/Write" permission.
(Manage API keys in your `SparkPost account API keys`_.) (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 .. code-block:: python
ANYMAIL = { 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"]`` root of the settings file if neither ``ANYMAIL["SPARKPOST_API_KEY"]``
nor ``ANYMAIL_SPARKPOST_API_KEY`` is set. 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 .. _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 .. rubric:: SPARKPOST_API_URL
The `SparkPost API Endpoint`_ to use. This setting is optional; if not provided, Anymail will The `SparkPost API Endpoint`_ to use. The default is ``"https://api.sparkpost.com/api/v1"``.
use the :pypi:`python-sparkpost` client default endpoint (``"https://api.sparkpost.com/api/v1"``).
Set this to use a SparkPost EU account, or to work with any other API endpoint including Set this to use a SparkPost EU account, or to work with any other API endpoint including
SparkPost Enterprise API and SparkPost Labs. 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). 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 .. _SparkPost API Endpoint: https://developers.sparkpost.com/api/index.html#header-api-endpoints
@@ -90,28 +89,47 @@ using :pypi:`python-sparkpost`.
esp_extra support esp_extra support
----------------- -----------------
To use SparkPost features not directly supported by Anymail, you can To use SparkPost features not directly supported by Anymail, you can set
set a message's :attr:`~anymail.message.AnymailMessage.esp_extra` to a message's :attr:`~anymail.message.AnymailMessage.esp_extra` to a `dict`
a `dict` of parameters for python-sparkpost's `transmissions.send method`_. of `transmissions API request body`_ data. Anymail will deeply merge your overrides
Any keys in your :attr:`esp_extra` dict will override Anymail's normal into the normal API payload it has constructed, with esp_extra taking precedence
values for that parameter. in conflicts.
Example: Example (you probably wouldn't combine all of these options at once):
.. code-block:: python .. code-block:: python
message.esp_extra = { message.esp_extra = {
'transactional': True, # treat as transactional for unsubscribe and suppression "options": {
'description': "Marketing test-run for new templates", # Treat as transactional for unsubscribe and suppression:
'use_draft_template': True, "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>` (You can also set `"esp_extra"` in Anymail's :ref:`global send defaults <send-defaults>`
to apply it to all messages.) to apply it to all messages.)
.. _transmissions.send method: .. _transmissions API request body:
https://python-sparkpost.readthedocs.io/en/latest/api/transmissions.html#sparkpost.transmissions.Transmissions.send 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*. (SparkPost's "recipient tags" are not available for tagging *messages*.
They're associated with individual *addresses* in stored recipient lists.) 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** **Envelope sender may use domain only**
Anymail's :attr:`~anymail.message.AnymailMessage.envelope_sender` is used to Anymail's :attr:`~anymail.message.AnymailMessage.envelope_sender` is used to
populate SparkPost's `'return_path'` parameter. Anymail supplies the full 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` :meth:`connection.send_messages`. See :ref:`django:topics-sending-multiple-emails`
in the Django docs for more info and an example. 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 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 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 API call. See :ref:`batch-send` for details, and be sure to check the

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
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
@@ -41,19 +40,6 @@ class SparkPostBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
'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()
@@ -73,6 +59,8 @@ class SparkPostBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
message = AnymailMessage( message = AnymailMessage(
subject="Anymail all-options integration test", subject="Anymail all-options integration test",
body="This is the text body", 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", 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>"], 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: # 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): # ... then prereleases (if available):
djangoDev-py{36,37,38}-all djangoDev-py{36,37,38}-all
# ... then partial installation (limit extras): # ... then partial installation (limit extras):
django31-py37-{none,amazon_ses,sparkpost} django31-py37-{none,amazon_ses}
# ... then older versions of some dependencies: # ... then older versions of some dependencies:
django22-py37-all-old_urllib3 django22-py37-all-old_urllib3
@@ -32,12 +32,10 @@ deps =
mock mock
extras = extras =
all,amazon_ses: amazon_ses all,amazon_ses: amazon_ses
all,sparkpost: sparkpost
setenv = setenv =
# tell runtests.py to limit some test tags based on extras factor # 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 amazon_ses: ANYMAIL_ONLY_TEST=amazon_ses
sparkpost: ANYMAIL_ONLY_TEST=sparkpost
ignore_outcome = ignore_outcome =
# CI that wants to handle errors itself can set TOX_FORCE_IGNORE_OUTCOME=false # CI that wants to handle errors itself can set TOX_FORCE_IGNORE_OUTCOME=false
djangoDev: {env:TOX_FORCE_IGNORE_OUTCOME:true} djangoDev: {env:TOX_FORCE_IGNORE_OUTCOME:true}