Sendinblue: use latest API improvements (templates, tags)

Track Sendinblue API updates:
* Multiple tags are now supported
* When using a template, display name is now supported on 'to', 'bcc', 'cc' and 'replyTo'
* Templates now support overriding 'from_email' and 'subject'
* Templates no longer require separate API endpoint
* 'merge_global_data' can be used without templates
This commit is contained in:
Thorben Luepkes
2019-08-29 03:52:11 +02:00
committed by Mike Edmunds
parent 73a73ea01f
commit 989d56bd85
4 changed files with 33 additions and 151 deletions

View File

@@ -1,5 +1,4 @@
from requests.structures import CaseInsensitiveDict from requests.structures import CaseInsensitiveDict
from six.moves.urllib.parse import quote
from .base_requests import AnymailRequestsBackend, RequestsPayload from .base_requests import AnymailRequestsBackend, RequestsPayload
from ..exceptions import AnymailRequestsAPIError from ..exceptions import AnymailRequestsAPIError
@@ -67,7 +66,6 @@ class SendinBluePayload(RequestsPayload):
def __init__(self, message, defaults, backend, *args, **kwargs): def __init__(self, message, defaults, backend, *args, **kwargs):
self.all_recipients = [] # used for backend.parse_recipient_status self.all_recipients = [] # used for backend.parse_recipient_status
self.template_id = None
http_headers = kwargs.pop('headers', {}) http_headers = kwargs.pop('headers', {})
http_headers['api-key'] = backend.api_key http_headers['api-key'] = backend.api_key
@@ -76,9 +74,6 @@ class SendinBluePayload(RequestsPayload):
super(SendinBluePayload, self).__init__(message, defaults, backend, headers=http_headers, *args, **kwargs) super(SendinBluePayload, self).__init__(message, defaults, backend, headers=http_headers, *args, **kwargs)
def get_api_endpoint(self): def get_api_endpoint(self):
if self.template_id:
return "smtp/templates/%s/send" % quote(str(self.template_id), safe='')
else:
return "smtp/email" return "smtp/email"
def init_payload(self): def init_payload(self):
@@ -91,47 +86,10 @@ class SendinBluePayload(RequestsPayload):
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)):
# SendinBlue use different argument's name if we use template functionality
if self.template_id:
data = self._transform_data_for_templated_email(self.data)
else:
data = self.data
return self.serialize_json(data)
def _transform_data_for_templated_email(self, data):
"""
Transform the default Payload's data (used for basic transactional email) to
the data used by SendinBlue in case of a templated transactional email.
:param data: The data we want to transform
:return: The transformed data
"""
if data.pop('subject', False):
self.unsupported_feature("overriding template subject")
if data.pop('sender', False):
self.unsupported_feature("overriding template from_email")
if data.pop('textContent', False) or data.pop('htmlContent', False):
self.unsupported_feature("overriding template body content") self.unsupported_feature("overriding template body content")
transformation = { return self.serialize_json(self.data)
'to': 'emailTo',
'cc': 'emailCc',
'bcc': 'emailBcc',
}
for key, new_key in transformation.items():
if key in data:
if any(email.get('name') for email in data[key]):
self.unsupported_feature("display names in %s when sending with a template" % key)
data[new_key] = [email['email'] for email in data[key]]
del data[key]
if 'replyTo' in data:
if data['replyTo'].get('name'):
self.unsupported_feature("display names in reply_to when sending with a template")
data['replyTo'] = data['replyTo']['email']
return data
# #
# Payload construction # Payload construction
@@ -171,12 +129,10 @@ class SendinBluePayload(RequestsPayload):
def set_tags(self, tags): def set_tags(self, tags):
if len(tags) > 0: if len(tags) > 0:
self.data['headers']["X-Mailin-tag"] = tags[0] self.data['tags'] = tags
if len(tags) > 1:
self.unsupported_feature('multiple tags (%r)' % tags)
def set_template_id(self, template_id): def set_template_id(self, template_id):
self.template_id = template_id self.data['templateId'] = template_id
def set_text_body(self, body): def set_text_body(self, body):
if body: if body:
@@ -209,7 +165,7 @@ class SendinBluePayload(RequestsPayload):
self.unsupported_feature("merge_data") self.unsupported_feature("merge_data")
def set_merge_global_data(self, merge_global_data): def set_merge_global_data(self, merge_global_data):
self.data['attributes'] = merge_global_data self.data['params'] = merge_global_data
def set_metadata(self, metadata): def set_metadata(self, metadata):
# SendinBlue expects a single string payload # SendinBlue expects a single string payload

View File

@@ -40,7 +40,7 @@ Email Service Provider |Amazon SES| |Mailgun| |Mailje
:attr:`~AnymailMessage.metadata` Yes Yes Yes Yes Yes Yes Yes Yes :attr:`~AnymailMessage.metadata` Yes Yes Yes Yes Yes Yes Yes Yes
:attr:`~AnymailMessage.merge_metadata` No Yes Yes Yes Yes Yes No Yes :attr:`~AnymailMessage.merge_metadata` No Yes Yes Yes Yes Yes No Yes
:attr:`~AnymailMessage.send_at` No Yes No Yes No Yes No Yes :attr:`~AnymailMessage.send_at` No Yes No Yes No Yes No Yes
:attr:`~AnymailMessage.tags` Yes Yes Max 1 tag Yes Max 1 tag Yes Max 1 tag Max 1 tag :attr:`~AnymailMessage.tags` Yes Yes Max 1 tag Yes Max 1 tag Yes Yes Max 1 tag
:attr:`~AnymailMessage.track_clicks` No Yes Yes Yes Yes Yes No Yes :attr:`~AnymailMessage.track_clicks` No Yes Yes Yes Yes Yes No Yes
:attr:`~AnymailMessage.track_opens` No Yes Yes Yes Yes Yes No Yes :attr:`~AnymailMessage.track_opens` No Yes Yes Yes Yes Yes No Yes

View File

@@ -131,9 +131,8 @@ SendinBlue can handle.
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** **Additional template limitations**
If you are sending using a SendinBlue template, their API doesn't allow display If you are sending using a SendinBlue template, their API doesn't support overriding the template's
names in recipient or reply-to emails, and doesn't support overriding the template's body. See the :ref:`templates <sendinblue-templates>`
from_email, subject, or body. See the :ref:`templates <sendinblue-templates>`
section below. section below.
**Single Reply-To** **Single Reply-To**
@@ -142,15 +141,6 @@ SendinBlue can handle.
If you are ignoring unsupported features and have multiple reply addresses, If you are ignoring unsupported features and have multiple reply addresses,
Anymail will use only the first one. Anymail will use only the first one.
**Single tag**
SendinBlue supports a single message tag, which can be used for filtering in their
dashboard statistics and logs panels, and is available in tracking webhooks.
Anymail will pass the first of a message's :attr:`~anymail.message.AnymailMessage.tags`
to SendinBlue, using their :mailheader:`X-Mailin-tag` email header.
Trying to send a message with more than one tag will result in an error unless you
are ignoring unsupported features.
**Metadata** **Metadata**
Anymail passes :attr:`~anymail.message.AnymailMessage.metadata` to SendinBlue Anymail passes :attr:`~anymail.message.AnymailMessage.metadata` to SendinBlue
as a JSON-encoded string using their :mailheader:`X-Mailin-custom` email header. as a JSON-encoded string using their :mailheader:`X-Mailin-custom` email header.
@@ -189,7 +179,7 @@ the messages's :attr:`~anymail.message.AnymailMessage.merge_global_data`:
.. code-block:: python .. code-block:: python
message = EmailMessage( message = EmailMessage(
subject=None, # required for SendinBlue templates subject="My Subject", # optional for SendinBlue templates
body=None, # required 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
@@ -208,15 +198,10 @@ variables using %-delimited names, e.g., `%order_no%` or `%ship_date%`
from the example above. from the example above.
Note that SendinBlue's API does not permit overriding a template's Note that SendinBlue's API does not permit overriding a template's
subject, body, or from_email. You *must* set them to `None` as shown above, body. You *must* set it to `None` as shown above,
or Anymail will raise an :exc:`~anymail.exceptions.AnymailUnsupportedFeature` or Anymail will raise an :exc:`~anymail.exceptions.AnymailUnsupportedFeature`
error (if you are not ignoring unsupported features). error (if you are not ignoring unsupported features).
Also, SendinBlue's API does not permit display names in recipient or reply-to
emails when sending with a template. Code like `to=["Alice <alice@example.com>"]`
will result in an unsupported feature error. (SendinBlue supports display names
only in *non*-template sends.)
.. _sendinblue-webhooks: .. _sendinblue-webhooks:

View File

@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import json import json
from base64 import b64encode, b64decode from base64 import b64encode, b64decode
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal
@@ -16,7 +15,6 @@ from django.utils.timezone import get_fixed_timezone, override as override_curre
from anymail.exceptions import (AnymailAPIError, AnymailConfigurationError, AnymailSerializationError, from anymail.exceptions import (AnymailAPIError, AnymailConfigurationError, AnymailSerializationError,
AnymailUnsupportedFeature) AnymailUnsupportedFeature)
from anymail.message import attach_inline_image_file from anymail.message import attach_inline_image_file
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCasesMixin from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCasesMixin
from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin
@@ -309,15 +307,10 @@ class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase):
self.message.send() self.message.send()
def test_tag(self): def test_tag(self):
self.message.tags = ["receipt"] self.message.tags = ["receipt", "multiple"]
self.message.send() self.message.send()
data = self.get_api_call_json() data = self.get_api_call_json()
self.assertCountEqual(data['headers']["X-Mailin-tag"], "receipt") self.assertEqual(data['tags'], ["receipt", "multiple"])
def test_multiple_tags(self):
self.message.tags = ["receipt", "repeat-user"]
with self.assertRaises(AnymailUnsupportedFeature):
self.message.send()
def test_tracking(self): def test_tracking(self):
# Test one way... # Test one way...
@@ -336,56 +329,26 @@ class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase):
def test_template_id(self): def test_template_id(self):
# subject, body, and from_email must be None for SendinBlue template send: # subject, body, and from_email must be None for SendinBlue template send:
message = mail.EmailMessage( message = mail.EmailMessage(
subject=None, subject='My Subject',
body=None, body=None,
# recipient and reply_to display names are not allowed from_email='from@example.com',
to=['alice@example.com'], # single 'to' recommended (all 'to' get the same message) to=['Recipient <to@example.com>'], # single 'to' recommended (all 'to' get the same message)
cc=['cc1@example.com', 'cc2@example.com'], cc=['Recipient <cc1@example.com>', 'Recipient <cc2@example.com>'],
bcc=['bcc@example.com'], bcc=['Recipient <bcc@example.com>'],
reply_to=['reply@example.com'], reply_to=['Recipient <reply@example.com>'],
) )
message.from_email = None # from_email must be cleared after constructing EmailMessage
message.template_id = 12 # SendinBlue uses per-account numeric ID to identify templates message.template_id = 12 # SendinBlue uses per-account numeric ID to identify templates
message.merge_global_data = {
'buttonUrl': 'https://example.com',
}
message.send() message.send()
# Template API call uses different endpoint and payload format from normal send:
self.assert_esp_called('/v3/smtp/templates/12/send')
data = self.get_api_call_json() data = self.get_api_call_json()
self.assertEqual(data['emailTo'], ["alice@example.com"]) self.assertEqual(data['templateId'], 12)
self.assertEqual(data['emailCc'], ["cc1@example.com", "cc2@example.com"]) self.assertEqual(data['subject'], 'My Subject')
self.assertEqual(data['emailBcc'], ["bcc@example.com"]) self.assertEqual(data['to'], [{'email': "to@example.com", 'name': 'Recipient'}])
self.assertEqual(data['replyTo'], 'reply@example.com')
self.assertEqual(data['attributes'], {'buttonUrl': 'https://example.com'})
# Make sure these normal-send parameters didn't get left in:
self.assertNotIn('to', data)
self.assertNotIn('cc', data)
self.assertNotIn('bcc', data)
self.assertNotIn('sender', data)
self.assertNotIn('subject', data)
self.assertNotIn('textContent', data)
self.assertNotIn('htmlContent', data)
def test_unsupported_template_overrides(self): def test_unsupported_template_overrides(self):
# SendinBlue doesn't allow overriding any template headers/content # SendinBlue doesn't allow overriding any template headers/content
message = mail.EmailMessage(to=['to@example.com']) message = mail.EmailMessage(to=['to@example.com'])
message.template_id = "9" message.template_id = "9"
message.from_email = "from@example.com"
with self.assertRaisesMessage(AnymailUnsupportedFeature, "overriding template from_email"):
message.send()
message.from_email = None
message.subject = "nope, can't change template subject"
with self.assertRaisesMessage(AnymailUnsupportedFeature, "overriding template subject"):
message.send()
message.subject = None
message.body = "nope, can't change text body" message.body = "nope, can't change text body"
with self.assertRaisesMessage(AnymailUnsupportedFeature, "overriding template body content"): with self.assertRaisesMessage(AnymailUnsupportedFeature, "overriding template body content"):
message.send() message.send()
@@ -394,33 +357,6 @@ class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase):
message.send() message.send()
message.body = None message.body = None
def test_unsupported_template_display_names(self):
# SendinBlue doesn't support display names in template recipients or reply-to
message = mail.EmailMessage()
message.from_email = None
message.template_id = "9"
message.to = ["Recipient <to@example.com>"]
with self.assertRaisesMessage(AnymailUnsupportedFeature, "display names in to when sending with a template"):
message.send()
message.to = ["to@example.com"]
message.cc = ["Recipient <cc@example.com>"]
with self.assertRaisesMessage(AnymailUnsupportedFeature, "display names in cc when sending with a template"):
message.send()
message.cc = []
message.bcc = ["Recipient <bcc@example.com>"]
with self.assertRaisesMessage(AnymailUnsupportedFeature, "display names in bcc when sending with a template"):
message.send()
message.bcc = []
message.reply_to = ["Reply-To <reply@example.com>"]
with self.assertRaisesMessage(AnymailUnsupportedFeature,
"display names in reply_to when sending with a template"):
message.send()
message.reply_to = []
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"},
@@ -429,6 +365,14 @@ class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase):
with self.assertRaises(AnymailUnsupportedFeature): with self.assertRaises(AnymailUnsupportedFeature):
self.message.send() self.message.send()
def test_merge_global_data(self):
self.message.merge_global_data = {
'a': 'b'
}
self.message.send()
data = self.get_api_call_json()
self.assertEqual(data['params'], {'a': 'b'})
def test_default_omits_options(self): def test_default_omits_options(self):
"""Make sure by default we don't send any ESP-specific options. """Make sure by default we don't send any ESP-specific options.
@@ -445,7 +389,6 @@ class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase):
self.assertNotIn('atributes', data) self.assertNotIn('atributes', data)
def test_esp_extra(self): def test_esp_extra(self):
self.message.tags = ["tag"]
# SendinBlue doesn't offer any esp-extra but we will test # SendinBlue doesn't offer any esp-extra but we will test
# with some extra of SendGrid to see if it's work in the future # with some extra of SendGrid to see if it's work in the future
self.message.esp_extra = { self.message.esp_extra = {
@@ -467,8 +410,6 @@ class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase):
self.assertEqual(data['asm'], {'group_id': 1}) self.assertEqual(data['asm'], {'group_id': 1})
self.assertEqual(data['tracking_settings']['subscription_tracking'], self.assertEqual(data['tracking_settings']['subscription_tracking'],
{'enable': True, 'substitution_tag': "[unsubscribe_url]"}) {'enable': True, 'substitution_tag': "[unsubscribe_url]"})
# make sure we didn't overwrite Anymail message options:
self.assertEqual(data['headers']["X-Mailin-tag"], "tag")
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
def test_send_attaches_anymail_status(self): def test_send_attaches_anymail_status(self):