mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 03:41:05 -05:00
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:
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
|
||||
------
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
4
setup.py
4
setup.py
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
"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")
|
||||
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,
|
||||
}
|
||||
}).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/")
|
||||
|
||||
@@ -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:
|
||||
|
||||
6
tox.ini
6
tox.ini
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user