Skip to content

Commit c4b2e08

Browse files
committed
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.
1 parent 5c2f2fd commit c4b2e08

File tree

5 files changed

+107
-8
lines changed

5 files changed

+107
-8
lines changed

CHANGELOG.rst

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ Breaking changes
4141
(Anymail 10.0 switched to the SES v2 API by default. If your ``EMAIL_BACKEND``
4242
setting has ``amazon_sesv2``, change that to just ``amazon_ses``.)
4343

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

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

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

167171
* **SendGrid:** Support multiple ``reply_to`` addresses.
168172
(Thanks to `@gdvalderrama`_ for pointing out the new API.)

anymail/backends/sparkpost.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from django.conf import settings
2+
from django.utils.encoding import force_str
3+
14
from ..exceptions import AnymailRequestsAPIError
25
from ..message import AnymailRecipientStatus
36
from ..utils import get_anymail_setting, update_deep
@@ -86,6 +89,7 @@ def get_api_endpoint(self):
8689

8790
def serialize_data(self):
8891
self._finalize_recipients()
92+
self._check_content_options()
8993
return self.serialize_json(self.data)
9094

9195
def _finalize_recipients(self):
@@ -126,6 +130,31 @@ def _finalize_recipients(self):
126130
for email in self.cc_and_bcc
127131
)
128132

133+
# SparkPost silently ignores certain "content" payload fields
134+
# when a template_id is used.
135+
IGNORED_WITH_TEMPLATE_ID = {
136+
# SparkPost API content.<field> -> feature name (for error message)
137+
"attachments": "attachments",
138+
"inline_images": "inline images",
139+
"headers": "extra headers and/or cc recipients",
140+
"from": "from_email",
141+
"reply_to": "reply_to",
142+
}
143+
144+
def _check_content_options(self):
145+
if "template_id" in self.data["content"]:
146+
# subject, text, and html will cause 422 API Error:
147+
# "message": "Both content object and template_id are specified",
148+
# "code": "1301"
149+
# but others are silently ignored in a template send:
150+
ignored = [
151+
feature_name
152+
for field, feature_name in self.IGNORED_WITH_TEMPLATE_ID.items()
153+
if field in self.data["content"]
154+
]
155+
if ignored:
156+
self.unsupported_feature("template_id with %s" % ", ".join(ignored))
157+
129158
#
130159
# Payload construction
131160
#
@@ -138,7 +167,8 @@ def init_payload(self):
138167
}
139168

140169
def set_from_email(self, email):
141-
self.data["content"]["from"] = email.address
170+
if email:
171+
self.data["content"]["from"] = email.address
142172

143173
def set_to(self, emails):
144174
if emails:
@@ -293,13 +323,22 @@ def set_track_opens(self, track_opens):
293323

294324
def set_template_id(self, template_id):
295325
self.data["content"]["template_id"] = template_id
296-
# Must remove empty string "content" params when using stored template
326+
# Must remove empty string "content" params when using stored template.
327+
# (Non-empty params are left in place, to cause API error.)
297328
for content_param in ["subject", "text", "html"]:
298329
try:
299330
if not self.data["content"][content_param]:
300331
del self.data["content"][content_param]
301332
except KeyError:
302333
pass
334+
# "from" is also silently ignored. Strip it if empty or DEFAULT_FROM_EMAIL,
335+
# else leave in place to cause error in _check_content_options.
336+
try:
337+
from_email = self.data["content"]["from"]
338+
if not from_email or from_email == force_str(settings.DEFAULT_FROM_EMAIL):
339+
del self.data["content"]["from"]
340+
except KeyError:
341+
pass
303342

304343
def set_merge_data(self, merge_data):
305344
for recipient in self.data["recipients"]:

docs/esps/sparkpost.rst

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,29 @@ Limitations and quirks
215215
management headers. (The list of allowed custom headers does not seem
216216
to be documented.)
217217

218+
.. _sparkpost-template-limitations:
219+
220+
**Features incompatible with template_id**
221+
When sending with a :attr:`~anymail.message.AnymailMessage.template_id`,
222+
SparkPost doesn't support attachments, inline images, extra headers,
223+
:attr:`!reply_to`, :attr:`!cc` recipients, or overriding the
224+
:attr:`!from_email`, :attr:`!subject`, or body (text or html) when
225+
sending the message. Some of these can be defined in the template itself,
226+
but SparkPost (often) silently drops them when supplied to their
227+
Transmissions send API.
228+
229+
.. versionchanged:: 11.0
230+
231+
Using features incompatible with :attr:`!template_id` will raise an
232+
:exc:`~anymail.exceptions.AnymailUnsupportedFeature` error. In earlier
233+
releases, Anymail would pass the incompatible content to SparkPost's
234+
API, which in many cases would silently ignore it and send the message
235+
anyway.
236+
237+
These limitations only apply when using stored templates (with a template_id),
238+
not when using SparkPost's template language for on-the-fly templating
239+
in a message's subject, body, etc.
240+
218241
**Envelope sender may use domain only**
219242
Anymail's :attr:`~anymail.message.AnymailMessage.envelope_sender` is used to
220243
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.
246269
You can use a SparkPost stored template by setting a message's
247270
:attr:`~anymail.message.AnymailMessage.template_id` to the
248271
template's unique id. (When using a stored template, SparkPost prohibits
249-
setting the EmailMessage's subject, text body, or html body.)
272+
setting the EmailMessage's subject, text body, or html body, and has
273+
:ref:`several other limitations <sparkpost-template-limitations>`.)
250274

251275
Alternatively, you can refer to merge fields directly in an EmailMessage's
252276
subject, body, and other fields---the message itself is used as an
@@ -264,6 +288,7 @@ message attributes.
264288
265289
)
266290
message.template_id = "11806290401558530" # SparkPost id
291+
message.from_email = None # must set after constructor (see below)
267292
message.merge_data = {
268293
'[email protected]': {'name': "Alice", 'order_no': "12345"},
269294
'[email protected]': {'name': "Bob", 'order_no': "54321"},
@@ -279,6 +304,9 @@ message attributes.
279304
},
280305
}
281306
307+
When using a :attr:`~anymail.message.AnymailMessage.template_id`, you must set the
308+
message's :attr:`!from_email` to ``None`` as shown above. SparkPost does not permit
309+
specifying the from address at send time when using a stored template.
282310

283311
See `SparkPost's substitutions reference`_ for more information on templates and
284312
batch send with SparkPost. If you need the special `"dynamic" keys for nested substitutions`_,

tests/test_sparkpost_backend.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
AnymailSerializationError,
2020
AnymailUnsupportedFeature,
2121
)
22-
from anymail.message import attach_inline_image_file
22+
from anymail.message import AnymailMessage, attach_inline_image_file
2323

2424
from .mock_requests_backend import RequestsBackendMockAPITestCase
2525
from .utils import (
@@ -510,9 +510,8 @@ def test_tracking(self):
510510
self.assertEqual(data["options"]["click_tracking"], True)
511511

512512
def test_template_id(self):
513-
message = mail.EmailMultiAlternatives(
514-
515-
)
513+
message = mail.EmailMultiAlternatives(to=["[email protected]"])
514+
message.from_email = None
516515
message.template_id = "welcome_template"
517516
message.send()
518517
data = self.get_api_call_json()
@@ -521,6 +520,34 @@ def test_template_id(self):
521520
self.assertNotIn("subject", data["content"])
522521
self.assertNotIn("text", data["content"])
523522
self.assertNotIn("html", data["content"])
523+
self.assertNotIn("from", data["content"])
524+
525+
def test_template_id_ignores_default_from_email(self):
526+
# No from_email, or from_email=None in constructor,
527+
# uses settings.DEFAULT_FROM_EMAIL. We strip that with a template_id:
528+
message = AnymailMessage(to=["[email protected]"], template_id="welcome_template")
529+
self.assertIsNotNone(message.from_email) # because it's DEFAULT_FROM_EMAIL
530+
message.send()
531+
data = self.get_api_call_json()
532+
self.assertNotIn("from", data["content"])
533+
534+
def test_unsupported_content_with_template_id(self):
535+
# Make sure we raise an error for options that SparkPost
536+
# silently ignores when sending with a template_id
537+
message = AnymailMessage(
538+
539+
from_email="[email protected]",
540+
reply_to=["[email protected]"],
541+
headers={"X-Custom": "header"},
542+
template_id="welcome_template",
543+
)
544+
message.attach(filename="test.txt", content="attachment", mimetype="text/plain")
545+
with self.assertRaisesMessage(
546+
AnymailUnsupportedFeature,
547+
"template_id with attachments, extra headers and/or cc recipients,"
548+
" from_email, reply_to",
549+
):
550+
message.send()
524551

525552
def test_merge_data(self):
526553
self.set_mock_result(accepted=4) # two 'to' plus one 'cc' for each 'to'

tests/test_sparkpost_integration.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ def test_stored_template(self):
153153
"order": "12345",
154154
},
155155
)
156+
message.from_email = None # from_email must come from stored template
156157
message.send()
157158
recipient_status = message.anymail_status.recipients
158159
self.assertEqual(

0 commit comments

Comments
 (0)