Skip to content

Commit

Permalink
SparkPost: error on features incompatible with template_id
Browse files Browse the repository at this point in the history
Raise an `AnymailUnsupportedFeature` error
when trying to use a `template_id` along with
other content payload fields that SparkPost
silently ignores when template_id is present.
  • Loading branch information
medmunds committed Jun 22, 2024
1 parent 5c2f2fd commit c4b2e08
Show file tree
Hide file tree
Showing 5 changed files with 107 additions and 8 deletions.
6 changes: 5 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ Breaking changes
(Anymail 10.0 switched to the SES v2 API by default. If your ``EMAIL_BACKEND``
setting has ``amazon_sesv2``, change that to just ``amazon_ses``.)

* **SparkPost:** When sending with a ``template_id``, Anymail now raises an
error if the message uses features that SparkPost will silently ignore. See
`docs <https://anymail.dev/en/latest/esps/sparkpost/#sparkpost-template-limitations>`__.

Features
~~~~~~~~

Expand Down Expand Up @@ -162,7 +166,7 @@ Features
should be no impact on your code. (Thanks to `@sblondon`_.)

* **Brevo (Sendinblue):** Add support for inbound email. (See
`docs <https://anymail.dev/en/stable/esps/sendinblue/#sendinblue-inbound>`_.)
`docs <https://anymail.dev/en/stable/esps/sendinblue/#sendinblue-inbound>`__.)

* **SendGrid:** Support multiple ``reply_to`` addresses.
(Thanks to `@gdvalderrama`_ for pointing out the new API.)
Expand Down
43 changes: 41 additions & 2 deletions anymail/backends/sparkpost.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from django.conf import settings
from django.utils.encoding import force_str

from ..exceptions import AnymailRequestsAPIError
from ..message import AnymailRecipientStatus
from ..utils import get_anymail_setting, update_deep
Expand Down Expand Up @@ -86,6 +89,7 @@ def get_api_endpoint(self):

def serialize_data(self):
self._finalize_recipients()
self._check_content_options()
return self.serialize_json(self.data)

def _finalize_recipients(self):
Expand Down Expand Up @@ -126,6 +130,31 @@ def _finalize_recipients(self):
for email in self.cc_and_bcc
)

# SparkPost silently ignores certain "content" payload fields
# when a template_id is used.
IGNORED_WITH_TEMPLATE_ID = {
# SparkPost API content.<field> -> feature name (for error message)
"attachments": "attachments",
"inline_images": "inline images",
"headers": "extra headers and/or cc recipients",
"from": "from_email",
"reply_to": "reply_to",
}

def _check_content_options(self):
if "template_id" in self.data["content"]:
# subject, text, and html will cause 422 API Error:
# "message": "Both content object and template_id are specified",
# "code": "1301"
# but others are silently ignored in a template send:
ignored = [
feature_name
for field, feature_name in self.IGNORED_WITH_TEMPLATE_ID.items()
if field in self.data["content"]
]
if ignored:
self.unsupported_feature("template_id with %s" % ", ".join(ignored))

#
# Payload construction
#
Expand All @@ -138,7 +167,8 @@ def init_payload(self):
}

def set_from_email(self, email):
self.data["content"]["from"] = email.address
if email:
self.data["content"]["from"] = email.address

def set_to(self, emails):
if emails:
Expand Down Expand Up @@ -293,13 +323,22 @@ def set_track_opens(self, track_opens):

def set_template_id(self, template_id):
self.data["content"]["template_id"] = template_id
# Must remove empty string "content" params when using stored template
# Must remove empty string "content" params when using stored template.
# (Non-empty params are left in place, to cause API error.)
for content_param in ["subject", "text", "html"]:
try:
if not self.data["content"][content_param]:
del self.data["content"][content_param]
except KeyError:
pass
# "from" is also silently ignored. Strip it if empty or DEFAULT_FROM_EMAIL,
# else leave in place to cause error in _check_content_options.
try:
from_email = self.data["content"]["from"]
if not from_email or from_email == force_str(settings.DEFAULT_FROM_EMAIL):
del self.data["content"]["from"]
except KeyError:
pass

def set_merge_data(self, merge_data):
for recipient in self.data["recipients"]:
Expand Down
30 changes: 29 additions & 1 deletion docs/esps/sparkpost.rst
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,29 @@ Limitations and quirks
management headers. (The list of allowed custom headers does not seem
to be documented.)

.. _sparkpost-template-limitations:

**Features incompatible with template_id**
When sending with a :attr:`~anymail.message.AnymailMessage.template_id`,
SparkPost doesn't support attachments, inline images, extra headers,
:attr:`!reply_to`, :attr:`!cc` recipients, or overriding the
:attr:`!from_email`, :attr:`!subject`, or body (text or html) when
sending the message. Some of these can be defined in the template itself,
but SparkPost (often) silently drops them when supplied to their
Transmissions send API.

.. versionchanged:: 11.0

Using features incompatible with :attr:`!template_id` will raise an
:exc:`~anymail.exceptions.AnymailUnsupportedFeature` error. In earlier
releases, Anymail would pass the incompatible content to SparkPost's
API, which in many cases would silently ignore it and send the message
anyway.

These limitations only apply when using stored templates (with a template_id),
not when using SparkPost's template language for on-the-fly templating
in a message's subject, body, etc.

**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
Expand Down Expand Up @@ -246,7 +269,8 @@ and :ref:`batch sending <batch-send>` with per-recipient merge data.
You can use a SparkPost stored template by setting a message's
:attr:`~anymail.message.AnymailMessage.template_id` to the
template's unique id. (When using a stored template, SparkPost prohibits
setting the EmailMessage's subject, text body, or html body.)
setting the EmailMessage's subject, text body, or html body, and has
:ref:`several other limitations <sparkpost-template-limitations>`.)

Alternatively, you can refer to merge fields directly in an EmailMessage's
subject, body, and other fields---the message itself is used as an
Expand All @@ -264,6 +288,7 @@ message attributes.
to=["alice@example.com", "Bob <bob@example.com>"]
)
message.template_id = "11806290401558530" # SparkPost id
message.from_email = None # must set after constructor (see below)
message.merge_data = {
'alice@example.com': {'name': "Alice", 'order_no': "12345"},
'bob@example.com': {'name': "Bob", 'order_no': "54321"},
Expand All @@ -279,6 +304,9 @@ message attributes.
},
}
When using a :attr:`~anymail.message.AnymailMessage.template_id`, you must set the
message's :attr:`!from_email` to ``None`` as shown above. SparkPost does not permit
specifying the from address at send time when using a stored template.

See `SparkPost's substitutions reference`_ for more information on templates and
batch send with SparkPost. If you need the special `"dynamic" keys for nested substitutions`_,
Expand Down
35 changes: 31 additions & 4 deletions tests/test_sparkpost_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
AnymailSerializationError,
AnymailUnsupportedFeature,
)
from anymail.message import attach_inline_image_file
from anymail.message import AnymailMessage, attach_inline_image_file

from .mock_requests_backend import RequestsBackendMockAPITestCase
from .utils import (
Expand Down Expand Up @@ -510,9 +510,8 @@ def test_tracking(self):
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 = mail.EmailMultiAlternatives(to=["to@example.com"])
message.from_email = None
message.template_id = "welcome_template"
message.send()
data = self.get_api_call_json()
Expand All @@ -521,6 +520,34 @@ def test_template_id(self):
self.assertNotIn("subject", data["content"])
self.assertNotIn("text", data["content"])
self.assertNotIn("html", data["content"])
self.assertNotIn("from", data["content"])

def test_template_id_ignores_default_from_email(self):
# No from_email, or from_email=None in constructor,
# uses settings.DEFAULT_FROM_EMAIL. We strip that with a template_id:
message = AnymailMessage(to=["to@example.com"], template_id="welcome_template")
self.assertIsNotNone(message.from_email) # because it's DEFAULT_FROM_EMAIL
message.send()
data = self.get_api_call_json()
self.assertNotIn("from", data["content"])

def test_unsupported_content_with_template_id(self):
# Make sure we raise an error for options that SparkPost
# silently ignores when sending with a template_id
message = AnymailMessage(
to=["to@example.com"],
from_email="non-default-from@example.com",
reply_to=["reply@example.com"],
headers={"X-Custom": "header"},
template_id="welcome_template",
)
message.attach(filename="test.txt", content="attachment", mimetype="text/plain")
with self.assertRaisesMessage(
AnymailUnsupportedFeature,
"template_id with attachments, extra headers and/or cc recipients,"
" from_email, reply_to",
):
message.send()

def test_merge_data(self):
self.set_mock_result(accepted=4) # two 'to' plus one 'cc' for each 'to'
Expand Down
1 change: 1 addition & 0 deletions tests/test_sparkpost_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ def test_stored_template(self):
"order": "12345",
},
)
message.from_email = None # from_email must come from stored template
message.send()
recipient_status = message.anymail_status.recipients
self.assertEqual(
Expand Down

0 comments on commit c4b2e08

Please sign in to comment.