mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 11:51:05 -05:00
SendinBlue: additional template/tags improvements
Additional changes related to SendinBlue improvements in #158: * Support multiple tags in webhooks (closes #162) * Remove additional outdated template code in backend * Update integration tests * Update docs and changelog; note breaking changes as discussed in #161
This commit is contained in:
@@ -30,6 +30,24 @@ vNext
|
|||||||
|
|
||||||
*Not yet released*
|
*Not yet released*
|
||||||
|
|
||||||
|
Breaking changes
|
||||||
|
~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
* **SendinBlue templates:** Support Sendinblue's new (ESP stored) Django templates and
|
||||||
|
new API for template sending. This removes most of the odd limitations in the older
|
||||||
|
(now-deprecated) SendinBlue template send API, but involves two breaking changes:
|
||||||
|
|
||||||
|
* You *must* `convert <https://help.sendinblue.com/hc/en-us/articles/360000991960>`_
|
||||||
|
each old Sendinblue template to the new language as you upgrade to Anymail vNext, or
|
||||||
|
certain features may be silently ignored on template sends (notably `reply_to` and
|
||||||
|
recipient display names).
|
||||||
|
|
||||||
|
* Sendinblue's API no longer supports sending attachments when using templates.
|
||||||
|
|
||||||
|
Ordinary, non-template sending is not affected by these changes. See
|
||||||
|
`docs <https://anymail.readthedocs.io/en/latest/esps/sendinblue/#batch-sending-merge-and-esp-templates>`_
|
||||||
|
for more info and alternatives. (Thanks `@Thorbenl`_.)
|
||||||
|
|
||||||
Features
|
Features
|
||||||
~~~~~~~~
|
~~~~~~~~
|
||||||
|
|
||||||
@@ -37,6 +55,8 @@ Features
|
|||||||
See `docs <https://anymail.readthedocs.io/en/latest/esps/mailgun/#batch-sending-merge-and-esp-templates>`__.
|
See `docs <https://anymail.readthedocs.io/en/latest/esps/mailgun/#batch-sending-merge-and-esp-templates>`__.
|
||||||
(Thanks `@anstosa`_.)
|
(Thanks `@anstosa`_.)
|
||||||
|
|
||||||
|
* **SendinBlue:** Support multiple `tags`. (Thanks `@Thorbenl`_.)
|
||||||
|
|
||||||
|
|
||||||
Other
|
Other
|
||||||
~~~~~
|
~~~~~
|
||||||
@@ -999,5 +1019,6 @@ Features
|
|||||||
.. _@mbk-ok: https://github.com/mbk-ok
|
.. _@mbk-ok: https://github.com/mbk-ok
|
||||||
.. _@RignonNoel: https://github.com/RignonNoel
|
.. _@RignonNoel: https://github.com/RignonNoel
|
||||||
.. _@sebbacon: https://github.com/sebbacon
|
.. _@sebbacon: https://github.com/sebbacon
|
||||||
|
.. _@Thorbenl: https://github.com/Thorbenl
|
||||||
.. _@varche1: https://github.com/varche1
|
.. _@varche1: https://github.com/varche1
|
||||||
.. _@yourcelf: https://github.com/yourcelf
|
.. _@yourcelf: https://github.com/yourcelf
|
||||||
|
|||||||
@@ -83,12 +83,8 @@ class SendinBluePayload(RequestsPayload):
|
|||||||
|
|
||||||
def serialize_data(self):
|
def serialize_data(self):
|
||||||
"""Performs any necessary serialization on self.data, and returns the result."""
|
"""Performs any necessary serialization on self.data, and returns the result."""
|
||||||
|
|
||||||
if not self.data['headers']:
|
if not self.data['headers']:
|
||||||
del self.data['headers'] # don't send empty headers
|
del self.data['headers'] # don't send empty headers
|
||||||
if self.data.get('templateId') and (self.data.pop('textContent', False) or self.data.pop('htmlContent', False)):
|
|
||||||
self.unsupported_feature("overriding template body content")
|
|
||||||
|
|
||||||
return self.serialize_json(self.data)
|
return self.serialize_json(self.data)
|
||||||
|
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -49,10 +49,17 @@ class SendinBlueTrackingWebhookView(AnymailBaseWebhookView):
|
|||||||
except (KeyError, ValueError):
|
except (KeyError, ValueError):
|
||||||
timestamp = None
|
timestamp = None
|
||||||
|
|
||||||
try:
|
|
||||||
tags = [esp_event["tag"]]
|
|
||||||
except KeyError:
|
|
||||||
tags = []
|
tags = []
|
||||||
|
try:
|
||||||
|
# If `tags` param set on send, webhook payload includes 'tags' array field.
|
||||||
|
tags = esp_event['tags']
|
||||||
|
except KeyError:
|
||||||
|
try:
|
||||||
|
# If `X-Mailin-Tag` header set on send, webhook payload includes single 'tag' string.
|
||||||
|
# (If header not set, webhook 'tag' will be the template name for template sends.)
|
||||||
|
tags = [esp_event['tag']]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
metadata = json.loads(esp_event["X-Mailin-custom"])
|
metadata = json.loads(esp_event["X-Mailin-custom"])
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ SendinBlue can handle.
|
|||||||
**HTML body required**
|
**HTML body required**
|
||||||
SendinBlue's API returns an error if you attempt to send a message with
|
SendinBlue's API returns an error if you attempt to send a message with
|
||||||
only a plain-text body. Be sure to :ref:`include HTML <sending-html>`
|
only a plain-text body. Be sure to :ref:`include HTML <sending-html>`
|
||||||
content for your messages.
|
content for your messages if you are not using a template.
|
||||||
|
|
||||||
(SendinBlue *does* allow HTML without a plain-text body. This is generally
|
(SendinBlue *does* allow HTML without a plain-text body. This is generally
|
||||||
not recommended, though, as some email systems treat HTML-only content as a
|
not recommended, though, as some email systems treat HTML-only content as a
|
||||||
@@ -130,10 +130,12 @@ SendinBlue can handle.
|
|||||||
Anymail has no way to communicate an attachment's desired content-type
|
Anymail has no way to communicate an attachment's desired content-type
|
||||||
to the SendinBlue API if the name is not set correctly.
|
to the SendinBlue API if the name is not set correctly.
|
||||||
|
|
||||||
**Additional template limitations**
|
**No attachments with templates**
|
||||||
If you are sending using a SendinBlue template, their API doesn't support overriding the template's
|
If you are sending using a SendinBlue template, their API doesn't support ordinary
|
||||||
body. See the :ref:`templates <sendinblue-templates>`
|
file attachments. Attempting to send an attachment with a template will result in the
|
||||||
section below.
|
SendinBlue API error message, "Please don't pass attachment content & templateId in same
|
||||||
|
request, instead use attachment url only."
|
||||||
|
See the :ref:`templates <sendinblue-templates>` section below.
|
||||||
|
|
||||||
**Single Reply-To**
|
**Single Reply-To**
|
||||||
SendinBlue's v3 API only supports a single Reply-To address.
|
SendinBlue's v3 API only supports a single Reply-To address.
|
||||||
@@ -164,29 +166,48 @@ SendinBlue can handle.
|
|||||||
Batch sending/merge and ESP templates
|
Batch sending/merge and ESP templates
|
||||||
-------------------------------------
|
-------------------------------------
|
||||||
|
|
||||||
SendinBlue supports :ref:`ESP stored templates <esp-stored-templates>`
|
SendinBlue supports :ref:`ESP stored templates <esp-stored-templates>` populated with
|
||||||
populated with global merge data for all recipients, but does not
|
global merge data for all recipients, but does not offer :ref:`batch sending <batch-send>`
|
||||||
offer :ref:`batch sending <batch-send>` with per-recipient merge data.
|
with per-recipient merge data. Anymail's :attr:`~anymail.message.AnymailMessage.merge_data`
|
||||||
Anymail's :attr:`~anymail.message.AnymailMessage.merge_data`
|
and :attr:`~anymail.message.AnymailMessage.merge_metadata` message attributes are not
|
||||||
and :attr:`~anymail.message.AnymailMessage.merge_metadata`
|
supported with the SendinBlue backend, but you can use Anymail's
|
||||||
message attributes are not supported with the SendinBlue backend.
|
:attr:`~anymail.message.AnymailMessage.merge_global_data` with SendinBlue templates.
|
||||||
|
|
||||||
|
SendinBlue supports two different template styles: a `new template language`_
|
||||||
|
that uses Django template syntax (with ``{{ param.NAME }}`` style substitutions),
|
||||||
|
and an "old" template language that used percent-delimited ``%NAME%`` style
|
||||||
|
substitutions. Anymail v7.0 and later require new style templates.
|
||||||
|
|
||||||
|
.. versionchanged:: 7.0
|
||||||
|
|
||||||
|
Anymail switched to a SendinBlue API that supports the new template language
|
||||||
|
and removes several limitations from the earlier template send API. But the new API
|
||||||
|
does not support attachments, and can behave oddly if used with old style templates.
|
||||||
|
|
||||||
|
.. caution::
|
||||||
|
|
||||||
|
Anymail v7.0 and later work *only* with Sendinblue's *new* template language. You should
|
||||||
|
follow SendinBlue's instructions to `convert each old template`_ to the new language.
|
||||||
|
|
||||||
|
Although unconverted old templates may appear to work with Anymail v7.0, some
|
||||||
|
features may not work properly. In particular, ``reply_to`` overrides and recipient
|
||||||
|
display names are silently ignored when *old* style templates are sent with the
|
||||||
|
*new* API used in Anymail v7.0.
|
||||||
|
|
||||||
To use a SendinBlue template, set the message's
|
To use a SendinBlue template, set the message's
|
||||||
:attr:`~anymail.message.AnymailMessage.template_id` to the numeric
|
:attr:`~anymail.message.AnymailMessage.template_id` to the numeric
|
||||||
SendinBlue template ID, and supply substitution attributes using
|
SendinBlue template ID, and supply substitution attributes using
|
||||||
the messages's :attr:`~anymail.message.AnymailMessage.merge_global_data`:
|
the message's :attr:`~anymail.message.AnymailMessage.merge_global_data`:
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
message = EmailMessage(
|
message = EmailMessage(
|
||||||
subject="My Subject", # optional for SendinBlue templates
|
|
||||||
body=None, # required for SendinBlue templates
|
|
||||||
to=["alice@example.com"] # single recipient...
|
to=["alice@example.com"] # single recipient...
|
||||||
# ...multiple to emails would all get the same message
|
# ...multiple to emails would all get the same message
|
||||||
# (and would all see each other's emails in the "to" header)
|
# (and would all see each other's emails in the "to" header)
|
||||||
)
|
)
|
||||||
message.from_email = None # required for SendinBlue templates
|
|
||||||
message.template_id = 3 # use this SendinBlue template
|
message.template_id = 3 # use this SendinBlue template
|
||||||
|
message.from_email = None # to use the template's default sender
|
||||||
message.merge_global_data = {
|
message.merge_global_data = {
|
||||||
'name': "Alice",
|
'name': "Alice",
|
||||||
'order_no': "12345",
|
'order_no': "12345",
|
||||||
@@ -194,13 +215,36 @@ the messages's :attr:`~anymail.message.AnymailMessage.merge_global_data`:
|
|||||||
}
|
}
|
||||||
|
|
||||||
Within your SendinBlue template body and subject, you can refer to merge
|
Within your SendinBlue template body and subject, you can refer to merge
|
||||||
variables using %-delimited names, e.g., `%order_no%` or `%ship_date%`
|
variables using Django template syntax, like ``{{ params.order_no }}`` or
|
||||||
from the example above.
|
``{{ params.ship_date }}`` for the example above.
|
||||||
|
|
||||||
Note that SendinBlue's API does not permit overriding a template's
|
The message's :class:`from_email <django.core.mail.EmailMessage>` (which defaults to
|
||||||
body. You *must* set it to `None` as shown above,
|
your :setting:`DEFAULT_FROM_EMAIL` setting) will override the template's default sender.
|
||||||
or Anymail will raise an :exc:`~anymail.exceptions.AnymailUnsupportedFeature`
|
If you want to use the template's sender, be sure to set ``from_email`` to ``None``
|
||||||
error (if you are not ignoring unsupported features).
|
*after* creating the message, as shown in the example above.
|
||||||
|
|
||||||
|
You can also override the template's subject and reply-to address (but not body)
|
||||||
|
using standard :class:`~django.core.mail.EmailMessage` attributes.
|
||||||
|
|
||||||
|
SendinBlue's template feature does not currently support providing attachment content
|
||||||
|
directly with the message---you'll get a SendinBlue API error if you try. If you must
|
||||||
|
send file attachments with SendinBlue templates, you can either upload them into
|
||||||
|
SendinBlue's template designer, or arrange to have the attachment content hosted on
|
||||||
|
a public URL and use Anymail's :attr:`~anymail.message.AnymailMessage.esp_extra`
|
||||||
|
to pass the URL to the SendinBlue API (see the Anymail SendinBlue integration tests
|
||||||
|
for an example of this). A better---and portable---option may be to avoid SendinBlue
|
||||||
|
templates and instead render the email in your Django code, allowing you to add any
|
||||||
|
file attachments you want. See :ref:`django-templates` for details.
|
||||||
|
|
||||||
|
(Note that SendinBlue doesn't support *inline* image attachments at all, whether you're
|
||||||
|
using a template or not.)
|
||||||
|
|
||||||
|
|
||||||
|
.. _new template language:
|
||||||
|
https://help.sendinblue.com/hc/en-us/articles/360000268730
|
||||||
|
|
||||||
|
.. _convert each old template:
|
||||||
|
https://help.sendinblue.com/hc/en-us/articles/360000991960
|
||||||
|
|
||||||
|
|
||||||
.. _sendinblue-webhooks:
|
.. _sendinblue-webhooks:
|
||||||
|
|||||||
@@ -344,19 +344,6 @@ class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase):
|
|||||||
self.assertEqual(data['subject'], 'My Subject')
|
self.assertEqual(data['subject'], 'My Subject')
|
||||||
self.assertEqual(data['to'], [{'email': "to@example.com", 'name': 'Recipient'}])
|
self.assertEqual(data['to'], [{'email': "to@example.com", 'name': 'Recipient'}])
|
||||||
|
|
||||||
def test_unsupported_template_overrides(self):
|
|
||||||
# SendinBlue doesn't allow overriding any template headers/content
|
|
||||||
message = mail.EmailMessage(to=['to@example.com'])
|
|
||||||
message.template_id = "9"
|
|
||||||
|
|
||||||
message.body = "nope, can't change text body"
|
|
||||||
with self.assertRaisesMessage(AnymailUnsupportedFeature, "overriding template body content"):
|
|
||||||
message.send()
|
|
||||||
message.content_subtype = "html"
|
|
||||||
with self.assertRaisesMessage(AnymailUnsupportedFeature, "overriding template body content"):
|
|
||||||
message.send()
|
|
||||||
message.body = None
|
|
||||||
|
|
||||||
def test_merge_data(self):
|
def test_merge_data(self):
|
||||||
self.message.merge_data = {
|
self.message.merge_data = {
|
||||||
'alice@example.com': {':name': "Alice", ':group': "Developers"},
|
'alice@example.com': {':name': "Alice", ':group': "Developers"},
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ class SendinBlueBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
|
|||||||
headers={"X-Anymail-Test": "value", "X-Anymail-Count": 3},
|
headers={"X-Anymail-Test": "value", "X-Anymail-Count": 3},
|
||||||
|
|
||||||
metadata={"meta1": "simple string", "meta2": 2},
|
metadata={"meta1": "simple string", "meta2": 2},
|
||||||
tags=["tag 1"], # SendinBlue only supports single tags
|
tags=["tag 1", "tag 2"],
|
||||||
)
|
)
|
||||||
message.attach_alternative('<p>HTML content</p>', "text/html") # SendinBlue requires an HTML body
|
message.attach_alternative('<p>HTML content</p>', "text/html") # SendinBlue requires an HTML body
|
||||||
|
|
||||||
@@ -76,22 +76,32 @@ class SendinBlueBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
|
|||||||
|
|
||||||
def test_template(self):
|
def test_template(self):
|
||||||
message = AnymailMessage(
|
message = AnymailMessage(
|
||||||
template_id=1, # There is a template with this id in the Anymail test account
|
template_id=5, # There is a *new-style* template with this id in the Anymail test account
|
||||||
to=["test+to1@anymail.info"], # SendinBlue doesn't allow recipient display names with templates
|
from_email='Sender <from@test-sb.anymail.info>', # Override template sender
|
||||||
reply_to=["reply@example.com"],
|
to=["Recipient <test+to1@anymail.info>"], # No batch send (so max one recipient suggested)
|
||||||
|
reply_to=["Do not reply <reply@example.com>"],
|
||||||
tags=["using-template"],
|
tags=["using-template"],
|
||||||
headers={"X-Anymail-Test": "group: A, variation: C"},
|
headers={"X-Anymail-Test": "group: A, variation: C"},
|
||||||
merge_global_data={
|
merge_global_data={
|
||||||
# The Anymail test template includes `%SHIP_DATE%` and `%ORDER_ID%` variables
|
# The Anymail test template includes `{{ params.SHIP_DATE }}`
|
||||||
|
# and `{{ params.ORDER_ID }}` substitutions
|
||||||
"SHIP_DATE": "yesterday",
|
"SHIP_DATE": "yesterday",
|
||||||
"ORDER_ID": "12345",
|
"ORDER_ID": "12345",
|
||||||
},
|
},
|
||||||
metadata={"customer-id": "ZXK9123", "meta2": 2},
|
metadata={"customer-id": "ZXK9123", "meta2": 2},
|
||||||
)
|
)
|
||||||
message.from_email = None # Required for SendinBlue templates
|
|
||||||
|
|
||||||
# Attachments don't work with templates in Sendinblue's newer API:
|
# Normal attachments don't work with Sendinblue templates:
|
||||||
# message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain")
|
# message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain")
|
||||||
|
# If you can host the attachment content on some publicly-accessible URL,
|
||||||
|
# this *non-portable* alternative allows sending attachments with templates:
|
||||||
|
message.esp_extra = {
|
||||||
|
'attachment': [{
|
||||||
|
'name': 'attachment1.txt',
|
||||||
|
# URL where Sendinblue can download the attachment content while sending:
|
||||||
|
'url': 'https://raw.githubusercontent.com/anymail/django-anymail/master/AUTHORS.txt',
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
message.send()
|
message.send()
|
||||||
self.assertEqual(message.anymail_status.status, {'queued'}) # SendinBlue always queues
|
self.assertEqual(message.anymail_status.status, {'queued'}) # SendinBlue always queues
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ class SendinBlueWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMi
|
|||||||
|
|
||||||
@tag('sendinblue')
|
@tag('sendinblue')
|
||||||
class SendinBlueDeliveryTestCase(WebhookTestCase):
|
class SendinBlueDeliveryTestCase(WebhookTestCase):
|
||||||
# SendinBlue's webhook payload data doesn't seem to be documented anywhere.
|
# SendinBlue's webhook payload data is partially documented at
|
||||||
# There's a list of webhook events at https://apidocs.sendinblue.com/webhooks/#3.
|
# https://help.sendinblue.com/hc/en-us/articles/360007666479,
|
||||||
|
# but it's not completely up to date.
|
||||||
# The payloads below were obtained through live testing.
|
# The payloads below were obtained through live testing.
|
||||||
|
|
||||||
def test_sent_event(self):
|
def test_sent_event(self):
|
||||||
@@ -40,7 +41,13 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
|
|||||||
"ts_epoch": 1520363423000, # 2018-03-06 19:10:23.000+00:00 -- UTC (milliseconds)
|
"ts_epoch": 1520363423000, # 2018-03-06 19:10:23.000+00:00 -- UTC (milliseconds)
|
||||||
|
|
||||||
"X-Mailin-custom": '{"meta": "data"}',
|
"X-Mailin-custom": '{"meta": "data"}',
|
||||||
"tag": "test-tag", # note: for template send, is template name if no other tag provided
|
# "tag" is JSON-serialized tags array if `tags` param set on send,
|
||||||
|
# else single tag string if `X-Mailin-Tag` header set on send,
|
||||||
|
# else template name if sent using a template,
|
||||||
|
# else not present.
|
||||||
|
# "tags" is tags list if `tags` param set on send, else not present.
|
||||||
|
"tag": '["tag1","tag2"]',
|
||||||
|
"tags": ["tag1", "tag2"],
|
||||||
"template_id": 12,
|
"template_id": 12,
|
||||||
"sending_ip": "333.33.33.33",
|
"sending_ip": "333.33.33.33",
|
||||||
}
|
}
|
||||||
@@ -58,7 +65,7 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
|
|||||||
self.assertIsNone(event.event_id) # SendinBlue does not provide a unique event id
|
self.assertIsNone(event.event_id) # SendinBlue does not provide a unique event id
|
||||||
self.assertEqual(event.recipient, "recipient@example.com")
|
self.assertEqual(event.recipient, "recipient@example.com")
|
||||||
self.assertEqual(event.metadata, {"meta": "data"})
|
self.assertEqual(event.metadata, {"meta": "data"})
|
||||||
self.assertEqual(event.tags, ["test-tag"])
|
self.assertEqual(event.tags, ["tag1", "tag2"])
|
||||||
|
|
||||||
def test_delivered_event(self):
|
def test_delivered_event(self):
|
||||||
raw_event = {
|
raw_event = {
|
||||||
@@ -91,6 +98,7 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
|
|||||||
"message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>",
|
"message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>",
|
||||||
# the leading space in the reason is as received in actual testing:
|
# the leading space in the reason is as received in actual testing:
|
||||||
"reason": " RecipientError: 550 5.5.0 Requested action not taken: mailbox unavailable.",
|
"reason": " RecipientError: 550 5.5.0 Requested action not taken: mailbox unavailable.",
|
||||||
|
"tag": "header-tag",
|
||||||
}
|
}
|
||||||
response = self.client.post('/anymail/sendinblue/tracking/',
|
response = self.client.post('/anymail/sendinblue/tracking/',
|
||||||
content_type='application/json', data=json.dumps(raw_event))
|
content_type='application/json', data=json.dumps(raw_event))
|
||||||
@@ -102,6 +110,7 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
|
|||||||
self.assertEqual(event.reject_reason, "bounced")
|
self.assertEqual(event.reject_reason, "bounced")
|
||||||
self.assertEqual(event.mta_response,
|
self.assertEqual(event.mta_response,
|
||||||
" RecipientError: 550 5.5.0 Requested action not taken: mailbox unavailable.")
|
" RecipientError: 550 5.5.0 Requested action not taken: mailbox unavailable.")
|
||||||
|
self.assertEqual(event.tags, ["header-tag"])
|
||||||
|
|
||||||
def test_soft_bounce_event(self):
|
def test_soft_bounce_event(self):
|
||||||
raw_event = {
|
raw_event = {
|
||||||
|
|||||||
Reference in New Issue
Block a user