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:
Mike Edmunds
2019-09-04 15:45:08 -07:00
committed by GitHub
parent fd558e904e
commit 0a8887913c
7 changed files with 127 additions and 53 deletions

View File

@@ -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

View File

@@ -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)
# #

View File

@@ -49,10 +49,17 @@ class SendinBlueTrackingWebhookView(AnymailBaseWebhookView):
except (KeyError, ValueError): except (KeyError, ValueError):
timestamp = None timestamp = None
tags = []
try: try:
tags = [esp_event["tag"]] # If `tags` param set on send, webhook payload includes 'tags' array field.
tags = esp_event['tags']
except KeyError: except KeyError:
tags = [] 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"])

View File

@@ -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:

View File

@@ -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"},

View File

@@ -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

View File

@@ -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 = {