SparkPost: error on features incompatible with template_id

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.
This commit is contained in:
Mike Edmunds
2024-06-22 14:30:30 -07:00
parent 5c2f2fd35a
commit c4b2e08b16
5 changed files with 107 additions and 8 deletions

View File

@@ -41,6 +41,10 @@ Breaking changes
(Anymail 10.0 switched to the SES v2 API by default. If your ``EMAIL_BACKEND`` (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``.) 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 Features
~~~~~~~~ ~~~~~~~~
@@ -162,7 +166,7 @@ Features
should be no impact on your code. (Thanks to `@sblondon`_.) should be no impact on your code. (Thanks to `@sblondon`_.)
* **Brevo (Sendinblue):** Add support for inbound email. (See * **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. * **SendGrid:** Support multiple ``reply_to`` addresses.
(Thanks to `@gdvalderrama`_ for pointing out the new API.) (Thanks to `@gdvalderrama`_ for pointing out the new API.)

View File

@@ -1,3 +1,6 @@
from django.conf import settings
from django.utils.encoding import force_str
from ..exceptions import AnymailRequestsAPIError from ..exceptions import AnymailRequestsAPIError
from ..message import AnymailRecipientStatus from ..message import AnymailRecipientStatus
from ..utils import get_anymail_setting, update_deep from ..utils import get_anymail_setting, update_deep
@@ -86,6 +89,7 @@ class SparkPostPayload(RequestsPayload):
def serialize_data(self): def serialize_data(self):
self._finalize_recipients() self._finalize_recipients()
self._check_content_options()
return self.serialize_json(self.data) return self.serialize_json(self.data)
def _finalize_recipients(self): def _finalize_recipients(self):
@@ -126,6 +130,31 @@ class SparkPostPayload(RequestsPayload):
for email in self.cc_and_bcc 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 # Payload construction
# #
@@ -138,6 +167,7 @@ class SparkPostPayload(RequestsPayload):
} }
def set_from_email(self, email): def set_from_email(self, email):
if email:
self.data["content"]["from"] = email.address self.data["content"]["from"] = email.address
def set_to(self, emails): def set_to(self, emails):
@@ -293,13 +323,22 @@ class SparkPostPayload(RequestsPayload):
def set_template_id(self, template_id): def set_template_id(self, template_id):
self.data["content"]["template_id"] = 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"]: for content_param in ["subject", "text", "html"]:
try: try:
if not self.data["content"][content_param]: if not self.data["content"][content_param]:
del self.data["content"][content_param] del self.data["content"][content_param]
except KeyError: except KeyError:
pass 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): def set_merge_data(self, merge_data):
for recipient in self.data["recipients"]: for recipient in self.data["recipients"]:

View File

@@ -215,6 +215,29 @@ Limitations and quirks
management headers. (The list of allowed custom headers does not seem management headers. (The list of allowed custom headers does not seem
to be documented.) 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** **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
@@ -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 You can use a SparkPost stored template by setting a message's
:attr:`~anymail.message.AnymailMessage.template_id` to the :attr:`~anymail.message.AnymailMessage.template_id` to the
template's unique id. (When using a stored template, SparkPost prohibits 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 Alternatively, you can refer to merge fields directly in an EmailMessage's
subject, body, and other fields---the message itself is used as an subject, body, and other fields---the message itself is used as an
@@ -264,6 +288,7 @@ message attributes.
to=["alice@example.com", "Bob <bob@example.com>"] to=["alice@example.com", "Bob <bob@example.com>"]
) )
message.template_id = "11806290401558530" # SparkPost id message.template_id = "11806290401558530" # SparkPost id
message.from_email = None # must set after constructor (see below)
message.merge_data = { message.merge_data = {
'alice@example.com': {'name': "Alice", 'order_no': "12345"}, 'alice@example.com': {'name': "Alice", 'order_no': "12345"},
'bob@example.com': {'name': "Bob", 'order_no': "54321"}, 'bob@example.com': {'name': "Bob", 'order_no': "54321"},
@@ -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 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`_, batch send with SparkPost. If you need the special `"dynamic" keys for nested substitutions`_,

View File

@@ -19,7 +19,7 @@ from anymail.exceptions import (
AnymailSerializationError, AnymailSerializationError,
AnymailUnsupportedFeature, 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 .mock_requests_backend import RequestsBackendMockAPITestCase
from .utils import ( from .utils import (
@@ -510,9 +510,8 @@ class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase):
self.assertEqual(data["options"]["click_tracking"], True) self.assertEqual(data["options"]["click_tracking"], True)
def test_template_id(self): def test_template_id(self):
message = mail.EmailMultiAlternatives( message = mail.EmailMultiAlternatives(to=["to@example.com"])
from_email="from@example.com", to=["to@example.com"] message.from_email = None
)
message.template_id = "welcome_template" message.template_id = "welcome_template"
message.send() message.send()
data = self.get_api_call_json() data = self.get_api_call_json()
@@ -521,6 +520,34 @@ class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase):
self.assertNotIn("subject", data["content"]) self.assertNotIn("subject", data["content"])
self.assertNotIn("text", data["content"]) self.assertNotIn("text", data["content"])
self.assertNotIn("html", 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): def test_merge_data(self):
self.set_mock_result(accepted=4) # two 'to' plus one 'cc' for each 'to' self.set_mock_result(accepted=4) # two 'to' plus one 'cc' for each 'to'

View File

@@ -153,6 +153,7 @@ class SparkPostBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"order": "12345", "order": "12345",
}, },
) )
message.from_email = None # from_email must come from stored template
message.send() message.send()
recipient_status = message.anymail_status.recipients recipient_status = message.anymail_status.recipients
self.assertEqual( self.assertEqual(