mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 03:41:05 -05:00
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:
@@ -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.)
|
||||||
|
|||||||
@@ -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"]:
|
||||||
|
|||||||
@@ -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`_,
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user