Add SendinBlue backend

Add support for sending transactional email through SendinBlue. (Thanks to @RignonNoel.)

Partially implements #84. (Tracking webhooks will be a separate PR. SendinBlue doesn't support inbound handling.)
This commit is contained in:
Rignon Noël
2018-02-26 12:46:10 -05:00
committed by Mike Edmunds
parent fffd762f56
commit dc2b4b4e7a
6 changed files with 922 additions and 19 deletions

View File

@@ -6,6 +6,7 @@ Calvin Jeong
Peter Wu
Charlie DeTar
Jonathan Baugh
Noel Rignon
Anymail was forked from Djrill, which included contributions from:

View File

@@ -0,0 +1,229 @@
from requests.structures import CaseInsensitiveDict
from .base_requests import AnymailRequestsBackend, RequestsPayload
from ..exceptions import AnymailRequestsAPIError
from ..message import AnymailRecipientStatus
from ..utils import get_anymail_setting, parse_address_list
class EmailBackend(AnymailRequestsBackend):
"""
SendinBlue v3 API Email Backend
"""
esp_name = "SendinBlue"
def __init__(self, **kwargs):
"""Init options from Django settings"""
esp_name = self.esp_name
self.api_key = get_anymail_setting(
'api_key',
esp_name=esp_name,
kwargs=kwargs,
allow_bare=True,
)
api_url = get_anymail_setting(
'api_url',
esp_name=esp_name,
kwargs=kwargs,
default="https://api.sendinblue.com/v3",
)
if not api_url.endswith("/"):
api_url += "/"
super(EmailBackend, self).__init__(api_url, **kwargs)
def build_message_payload(self, message, defaults):
return SendinBluePayload(message, defaults, self)
def raise_for_status(self, response, payload, message):
if response.status_code < 200 or response.status_code >= 300:
raise AnymailRequestsAPIError(
email_message=message,
payload=payload,
response=response,
backend=self,
)
def parse_recipient_status(self, response, payload, message):
# SendinBlue doesn't give any detail on a success
# https://developers.sendinblue.com/docs/responses
message_id = None
if response.content != b'':
parsed_response = self.deserialize_json_response(response, payload, message)
try:
message_id = parsed_response['messageId']
except (KeyError, TypeError):
raise AnymailRequestsAPIError("Invalid SendinBlue API response format",
email_message=message, payload=payload, response=response,
backend=self)
status = AnymailRecipientStatus(message_id=message_id, status="queued")
return {recipient.addr_spec: status for recipient in payload.all_recipients}
class SendinBluePayload(RequestsPayload):
def __init__(self, message, defaults, backend, *args, **kwargs):
self.all_recipients = [] # used for backend.parse_recipient_status
self.template_id = None
http_headers = kwargs.pop('headers', {})
http_headers['api-key'] = backend.api_key
http_headers['Content-Type'] = 'application/json'
super(SendinBluePayload, self).__init__(message, defaults, backend, headers=http_headers, *args, **kwargs)
def get_api_endpoint(self):
if self.template_id:
return "smtp/templates/%s/send" % (self.template_id)
else:
return "smtp/email"
def init_payload(self):
self.data = { # becomes json
'headers': CaseInsensitiveDict()
}
def serialize_data(self):
"""Performs any necessary serialization on self.data, and returns the result."""
headers = self.data["headers"]
if "Reply-To" in headers:
# Reply-To must be in its own param
reply_to = headers.pop('Reply-To')
self.set_reply_to(parse_address_list([reply_to]))
if len(headers) > 0:
self.data["headers"] = dict(headers) # flatten to normal dict for json serialization
else:
del self.data["headers"] # don't send empty headers
# 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 'subject' in data:
self.unsupported_feature("overriding template subject")
if 'subject' in data:
self.unsupported_feature("overriding template from_email")
if 'textContent' in data or 'htmlContent' in data:
self.unsupported_feature("overriding template body content")
transformation = {
'to': 'emailTo',
'cc': 'emailCc',
'bcc': 'emailBcc',
}
for key in data:
if key in transformation:
new_key = transformation[key]
list_email = list()
for email in data.pop(key):
if 'name' in email:
self.unsupported_feature("display names in (%r) when sending with a template" % key)
list_email.append(email.get('email'))
data[new_key] = list_email
if 'replyTo' in data:
if 'name' in data['replyTo']:
self.unsupported_feature("display names in (replyTo) when sending with a template")
data['replyTo'] = data['replyTo']['email']
return data
#
# Payload construction
#
@staticmethod
def email_object(email):
"""Converts EmailAddress to SendinBlue API array"""
email_object = dict()
email_object['email'] = email.addr_spec
if email.display_name:
email_object['name'] = email.display_name
return email_object
def set_from_email(self, email):
self.data["sender"] = self.email_object(email)
def set_recipients(self, recipient_type, emails):
assert recipient_type in ["to", "cc", "bcc"]
if emails:
self.data[recipient_type] = [self.email_object(email) for email in emails]
self.all_recipients += emails # used for backend.parse_recipient_status
def set_subject(self, subject):
if subject != "": # see note in set_text_body about template rendering
self.data["subject"] = subject
def set_reply_to(self, emails):
# SendinBlue only supports a single address in the reply_to API param.
if len(emails) > 1:
self.unsupported_feature("multiple reply_to addresses")
if len(emails) > 0:
self.data['replyTo'] = self.email_object(emails[0])
def set_extra_headers(self, headers):
for key in headers.keys():
self.data['headers'][key] = headers[key]
def set_tags(self, tags):
if len(tags) > 0:
self.data['headers']["X-Mailin-tag"] = tags[0]
if len(tags) > 1:
self.unsupported_feature('multiple tags (%r)' % tags)
def set_template_id(self, template_id):
self.template_id = template_id
def set_text_body(self, body):
if body:
self.data['textContent'] = body
def set_html_body(self, body):
if body:
if "htmlContent" in self.data:
self.unsupported_feature("multiple html parts")
self.data['htmlContent'] = body
def add_attachment(self, attachment):
"""Converts attachments to SendinBlue API {name, base64} array"""
att = {
'name': attachment.name or '',
'content': attachment.b64content,
}
if attachment.inline:
self.unsupported_feature("inline attachments")
self.data.setdefault("attachment", []).append(att)
def set_esp_extra(self, extra):
self.data.update(extra)
def set_merge_data(self, merge_data):
"""SendinBlue doesn't support special attributes for each recipient"""
self.unsupported_feature("merge_data")
def set_merge_global_data(self, merge_global_data):
self.data['attributes'] = merge_global_data
def set_metadata(self, metadata):
# SendinBlue expects a single string payload
self.data['headers']["X-Mailin-custom"] = self.serialize_json(metadata)

View File

@@ -17,6 +17,7 @@ and notes about any quirks or limitations:
mandrill
postmark
sendgrid
sendinblue
sparkpost
@@ -27,32 +28,32 @@ The table below summarizes the Anymail features supported for each ESP.
.. currentmodule:: anymail.message
============================================ ========== ========== ========== ========== ========== ===========
Email Service Provider |Mailgun| |Mailjet| |Mandrill| |Postmark| |SendGrid| |SparkPost|
============================================ ========== ========== ========== ========== ========== ===========
============================================ ========== ========== ========== ========== ========== ============ ===========
Email Service Provider |Mailgun| |Mailjet| |Mandrill| |Postmark| |SendGrid| |SendinBlue| |SparkPost|
============================================ ========== ========== ========== ========== ========== ============ ===========
.. rubric:: :ref:`Anymail send options <anymail-send-options>`
---------------------------------------------------------------------------------------------------------------------
:attr:`~AnymailMessage.metadata` Yes Yes Yes No Yes Yes
:attr:`~AnymailMessage.send_at` Yes No Yes No Yes Yes
:attr:`~AnymailMessage.tags` Yes Max 1 tag Yes Max 1 tag Yes Max 1 tag
:attr:`~AnymailMessage.track_clicks` Yes Yes Yes Yes Yes Yes
:attr:`~AnymailMessage.track_opens` Yes Yes Yes Yes Yes Yes
-----------------------------------------------------------------------------------------------------------------------------------
:attr:`~AnymailMessage.metadata` Yes Yes Yes No Yes Yes Yes
:attr:`~AnymailMessage.send_at` Yes No Yes No Yes No Yes
:attr:`~AnymailMessage.tags` Yes Max 1 tag Yes Max 1 tag Yes Max 1 tag Max 1 tag
:attr:`~AnymailMessage.track_clicks` Yes Yes Yes Yes Yes No Yes
:attr:`~AnymailMessage.track_opens` Yes Yes Yes Yes Yes No Yes
.. rubric:: :ref:`templates-and-merge`
---------------------------------------------------------------------------------------------------------------------
:attr:`~AnymailMessage.template_id` No Yes Yes Yes Yes Yes
:attr:`~AnymailMessage.merge_data` Yes Yes Yes No Yes Yes
:attr:`~AnymailMessage.merge_global_data` (emulated) Yes Yes Yes Yes Yes
-----------------------------------------------------------------------------------------------------------------------------------
:attr:`~AnymailMessage.template_id` No Yes Yes Yes Yes Yes Yes
:attr:`~AnymailMessage.merge_data` Yes Yes Yes No Yes No Yes
:attr:`~AnymailMessage.merge_global_data` (emulated) Yes Yes Yes Yes Yes Yes
.. rubric:: :ref:`Status <esp-send-status>` and :ref:`event tracking <event-tracking>`
---------------------------------------------------------------------------------------------------------------------
:attr:`~AnymailMessage.anymail_status` Yes Yes Yes Yes Yes Yes
|AnymailTrackingEvent| from webhooks Yes Yes Yes Yes Yes Yes
-----------------------------------------------------------------------------------------------------------------------------------
:attr:`~AnymailMessage.anymail_status` Yes Yes Yes Yes Yes Yes Yes
|AnymailTrackingEvent| from webhooks Yes Yes Yes Yes Yes No Yes
.. rubric:: :ref:`Inbound handling <inbound>`
---------------------------------------------------------------------------------------------------------------------
|AnymailInboundEvent| from webhooks Yes Yes Yes Yes Yes Yes
============================================ ========== ========== ========== ========== ========== ===========
-----------------------------------------------------------------------------------------------------------------------------------
|AnymailInboundEvent| from webhooks Yes Yes Yes Yes Yes No Yes
============================================ ========== ========== ========== ========== ========== ============ ===========
Trying to choose an ESP? Please **don't** start with this table. It's far more
@@ -65,6 +66,7 @@ meaningless. (And even specific features don't matter if you don't plan to use t
.. |Mandrill| replace:: :ref:`mandrill-backend`
.. |Postmark| replace:: :ref:`postmark-backend`
.. |SendGrid| replace:: :ref:`sendgrid-backend`
.. |SendinBlue| replace:: :ref:`sendinblue-backend`
.. |SparkPost| replace:: :ref:`sparkpost-backend`
.. |AnymailTrackingEvent| replace:: :class:`~anymail.signals.AnymailTrackingEvent`
.. |AnymailInboundEvent| replace:: :class:`~anymail.signals.AnymailInboundEvent`

90
docs/esps/sendinblue.rst Normal file
View File

@@ -0,0 +1,90 @@
.. _sendinblue-backend:
SendinBlue
========
Anymail integrates with the `SendinBlue`_ email service, using their `Web API v3`_.
.. important::
**Troubleshooting:**
If your SendinBlue messages aren't being delivered as expected, be sure to look for
events in your SendinBlue `statistic panel`_.
SendGrid detects certain types of errors only *after* the send API call appears
to succeed, and reports these errors in the statistic panel.
.. _SendinBlue: https://www.sendinblue.com/
.. _Web API v3: https://developers.sendinblue.com/docs
.. _statistic panel: https://app-smtp.sendinblue.com/statistics
Settings
--------
.. rubric:: EMAIL_BACKEND
To use Anymail's SendinBlue backend, set:
.. code-block:: python
EMAIL_BACKEND = "anymail.backends.sendinblue.EmailBackend"
in your settings.py.
.. setting:: ANYMAIL_SENDINBLUE_API_KEY
.. rubric:: SENDINBLUE_API_KEY
The API key can be retrieved from the
`account settings`_. Make sure to get the
key for the version of the API you're
using..)
Required.
.. code-block:: python
ANYMAIL = {
...
"SENDINBLUE_API_KEY": "<your API key>",
}
Anymail will also look for ``SENDINBLUE_API_KEY`` at the
root of the settings file if neither ``ANYMAIL["SENDINBLUE_API_KEY"]``
nor ``ANYMAIL_SENDINBLUE_API_KEY`` is set.
.. _account settings: https://account.sendinblue.com/advanced/api
Limitations and quirks
----------------------
**Single Reply-To**
SendinBlue's v3 API only supports a single Reply-To address.
If your message has multiple reply addresses, you'll get an
:exc:`~anymail.exceptions.AnymailUnsupportedFeature` error---or
if you've enabled :setting:`ANYMAIL_IGNORE_UNSUPPORTED_FEATURES`,
Anymail will use only the first one.
**Attachment content-type**
Attachment content-type is determined from the filename
extension and you can't specify a different one. Trying
to send an attachment without a name or a name without
an extension generates an error with SendinBlue's API.
**Inline images**
SendinBlue doesn't support inline images at all, it
only support basic attachment.
**Email's display-names**
Email's display-names are only supported
**without** :attr:`template_id`. If you specify
a :attr:`template_id` all display-names will be hidden.
**Template's limitation**
If you use a template you will suffer some limitations:
you can't change the subject or/and the body, and all email's
display-names will be hidden.

View File

@@ -0,0 +1,474 @@
# -*- coding: utf-8 -*-
import json
from base64 import b64encode, b64decode
from datetime import datetime
from decimal import Decimal
from email.mime.base import MIMEBase
from email.mime.image import MIMEImage
import six
from django.core import mail
from django.test import SimpleTestCase
from django.test.utils import override_settings
from django.utils.timezone import get_fixed_timezone, override as override_current_timezone
from anymail.exceptions import (AnymailAPIError, AnymailConfigurationError, AnymailSerializationError,
AnymailUnsupportedFeature)
from anymail.message import attach_inline_image_file
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCasesMixin
from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin
# noinspection PyUnresolvedReferences
longtype = int if six.PY3 else long # NOQA: F821
@override_settings(EMAIL_BACKEND='anymail.backends.sendinblue.EmailBackend',
ANYMAIL={'SENDINBLUE_API_KEY': 'test_api_key'})
class SendinBlueBackendMockAPITestCase(RequestsBackendMockAPITestCase):
# SendinBlue v3 success responses are empty
DEFAULT_RAW_RESPONSE = b'{"messageId":"<201801020304.1234567890@smtp-relay.mailin.fr>"}'
DEFAULT_STATUS_CODE = 201 # SendinBlue v3 uses '201 Created' for success (in most cases)
def setUp(self):
super(SendinBlueBackendMockAPITestCase, self).setUp()
# Simple message useful for many tests
self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com'])
class SendinBlueBackendStandardEmailTests(SendinBlueBackendMockAPITestCase):
"""Test backend support for Django standard email features"""
def test_send_mail(self):
"""Test basic API for simple send"""
mail.send_mail('Subject here', 'Here is the message.',
'from@sender.example.com', ['to@example.com'], fail_silently=False)
self.assert_esp_called('https://api.sendinblue.com/v3/smtp/email')
http_headers = self.get_api_call_headers()
self.assertEqual(http_headers["api-key"], "test_api_key")
self.assertEqual(http_headers["Content-Type"], "application/json")
data = self.get_api_call_json()
self.assertEqual(data['subject'], "Subject here")
self.assertEqual(data['textContent'], "Here is the message.")
self.assertEqual(data['sender'], {'email': "from@sender.example.com"})
def test_name_addr(self):
"""Make sure RFC2822 name-addr format (with display-name) is allowed
(Test both sender and recipient addresses)
"""
msg = mail.EmailMessage(
'Subject', 'Message', 'From Name <from@example.com>',
['Recipient #1 <to1@example.com>', 'to2@example.com'],
cc=['Carbon Copy <cc1@example.com>', 'cc2@example.com'],
bcc=['Blind Copy <bcc1@example.com>', 'bcc2@example.com'])
msg.send()
data = self.get_api_call_json()
self.assertEqual(data['sender'], {'email': "from@example.com", 'name': "From Name"})
def test_email_message(self):
email = mail.EmailMessage(
'Subject', 'Body goes here', 'from@example.com',
['to1@example.com', 'Also To <to2@example.com>'],
bcc=['bcc1@example.com', 'Also BCC <bcc2@example.com>'],
cc=['cc1@example.com', 'Also CC <cc2@example.com>'],
headers={'Reply-To': 'another@example.com',
'X-MyHeader': 'my value',
'Message-ID': '<mycustommsgid@sales.example.com>'}) # should override backend msgid
email.send()
data = self.get_api_call_json()
self.assertEqual(data['sender'], {'email': "from@example.com"})
self.assertEqual(data['subject'], "Subject")
self.assertEqual(data['textContent'], "Body goes here")
self.assertEqual(data['replyTo'], {'email': "another@example.com"})
self.assertEqual(data['headers'], {
'X-MyHeader': "my value",
'Message-ID': "<mycustommsgid@sales.example.com>",
})
def test_html_message(self):
text_content = 'This is an important message.'
html_content = '<p>This is an <strong>important</strong> message.</p>'
email = mail.EmailMultiAlternatives('Subject', text_content,
'from@example.com', ['to@example.com'])
email.attach_alternative(html_content, "text/html")
email.send()
data = self.get_api_call_json()
self.assertEqual(data['textContent'], text_content)
self.assertEqual(data['htmlContent'], html_content)
# Don't accidentally send the html part as an attachment:
self.assertNotIn('attachments', data)
def test_html_only_message(self):
html_content = '<p>This is an <strong>important</strong> message.</p>'
email = mail.EmailMessage('Subject', html_content, 'from@example.com', ['to@example.com'])
email.content_subtype = "html" # Main content is now text/html
email.send()
data = self.get_api_call_json()
self.assertEqual(data['htmlContent'], html_content)
self.assertNotIn('textContent', data)
def test_extra_headers(self):
self.message.extra_headers = {'X-Custom': 'string', 'X-Num': 123, 'X-Long': longtype(123),
'Reply-To': '"Do Not Reply" <noreply@example.com>'}
self.message.send()
data = self.get_api_call_json()
self.assertEqual(data['headers']['X-Custom'], 'string')
self.assertEqual(data['headers']['X-Num'], 123)
self.assertEqual(data['headers']['X-Long'], 123)
# Reply-To must be moved to separate param
self.assertNotIn('Reply-To', data['headers'])
self.assertEqual(data['replyTo'], {'name': "Do Not Reply", 'email': "noreply@example.com"})
def test_extra_headers_serialization_error(self):
self.message.extra_headers = {'X-Custom': Decimal(12.5)}
with self.assertRaisesMessage(AnymailSerializationError, "Decimal"):
self.message.send()
def test_reply_to(self):
self.message.reply_to = ['"Reply recipient" <reply@example.com']
self.message.send()
data = self.get_api_call_json()
self.assertEqual(data['replyTo'], {'name': "Reply recipient", 'email': "reply@example.com"})
def test_multiple_reply_to(self):
# SendinBlue v3 only allows a single reply address
self.message.reply_to = ['"Reply recipient" <reply@example.com', 'reply2@example.com']
with self.assertRaises(AnymailUnsupportedFeature):
self.message.send()
@override_settings(ANYMAIL_IGNORE_UNSUPPORTED_FEATURES=True)
def test_multiple_reply_to_ignore_unsupported(self):
# Should use first Reply-To if ignoring unsupported features
self.message.reply_to = ['"Reply recipient" <reply@example.com', 'reply2@example.com']
self.message.send()
data = self.get_api_call_json()
self.assertEqual(data['replyTo'], {'name': "Reply recipient", 'email': "reply@example.com"})
def test_attachments(self):
text_content = "* Item one\n* Item two\n* Item three"
self.message.attach(filename="test.txt", content=text_content, mimetype="text/plain")
# Should guess mimetype if not provided...
png_content = b"PNG\xb4 pretend this is the contents of a png file"
self.message.attach(filename="test.png", content=png_content)
# Should work with a MIMEBase object (also tests no filename)...
pdf_content = b"PDF\xb4 pretend this is valid pdf data"
mimeattachment = MIMEBase('application', 'pdf')
mimeattachment.set_payload(pdf_content)
self.message.attach(mimeattachment)
self.message.send()
data = self.get_api_call_json()
self.assertEqual(len(data['attachment']), 3)
attachments = data['attachment']
self.assertEqual(attachments[0], {
'name': "test.txt",
'content': b64encode(text_content.encode('utf-8')).decode('ascii')})
self.assertEqual(attachments[1], {
'name': "test.png",
'content': b64encode(png_content).decode('ascii')})
self.assertEqual(attachments[2], {
'name': "",
'content': b64encode(pdf_content).decode('ascii')})
def test_unicode_attachment_correctly_decoded(self):
self.message.attach(u"Une pièce jointe.html", u'<p>\u2019</p>', mimetype='text/html')
self.message.send()
attachment = self.get_api_call_json()['attachment'][0]
self.assertEqual(attachment['name'], u'Une pièce jointe.html')
self.assertEqual(b64decode(attachment['content']).decode('utf-8'), u'<p>\u2019</p>')
def test_embedded_images(self):
# SendinBlue doesn't support inline image
# inline image are just added as a content attachment
image_filename = SAMPLE_IMAGE_FILENAME
image_path = sample_image_path(image_filename)
cid = attach_inline_image_file(self.message, image_path) # Read from a png file
html_content = '<p>This has an <img src="cid:%s" alt="inline" /> image.</p>' % cid
self.message.attach_alternative(html_content, "text/html")
with self.assertRaises(AnymailUnsupportedFeature):
self.message.send()
def test_attached_images(self):
image_filename = SAMPLE_IMAGE_FILENAME
image_path = sample_image_path(image_filename)
image_data = sample_image_content(image_filename)
self.message.attach_file(image_path) # option 1: attach as a file
image = MIMEImage(image_data) # option 2: construct the MIMEImage and attach it directly
self.message.attach(image)
self.message.send()
image_data_b64 = b64encode(image_data).decode('ascii')
data = self.get_api_call_json()
self.assertEqual(data['attachment'][0], {
'name': image_filename, # the named one
'content': image_data_b64,
})
self.assertEqual(data['attachment'][1], {
'name': '', # the unnamed one
'content': image_data_b64,
})
def test_multiple_html_alternatives(self):
self.message.body = "Text body"
self.message.attach_alternative("<p>First html is OK</p>", "text/html")
self.message.attach_alternative("<p>And maybe second html, too</p>", "text/html")
with self.assertRaises(AnymailUnsupportedFeature):
self.message.send()
def test_non_html_alternative(self):
self.message.body = "Text body"
self.message.attach_alternative("{'maybe': 'allowed'}", "application/json")
with self.assertRaises(AnymailUnsupportedFeature):
self.message.send()
def test_api_failure(self):
self.set_mock_response(status_code=400)
with self.assertRaisesMessage(AnymailAPIError, "SendinBlue API response 400"):
mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com'])
# Make sure fail_silently is respected
self.set_mock_response(status_code=400)
sent = mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com'], fail_silently=True)
self.assertEqual(sent, 0)
def test_api_error_includes_details(self):
"""AnymailAPIError should include ESP's error message"""
# JSON error response:
error_response = b"""{
"code": "invalid_parameter",
"message": "valid sender email required"
}"""
self.set_mock_response(status_code=400, raw=error_response)
with self.assertRaises(AnymailAPIError) as cm:
self.message.send()
err = cm.exception
self.assertIn("code", str(err))
self.assertIn("message", str(err))
# No content in the error response:
self.set_mock_response(status_code=502, raw=None)
with self.assertRaises(AnymailAPIError):
self.message.send()
class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase):
"""Test backend support for Anymail added features"""
def test_metadata(self):
self.message.metadata = {'user_id': "12345", 'items': 6, 'float': 98.6, 'long': longtype(123)}
self.message.send()
data = self.get_api_call_json()
metadata = json.loads(data['headers']['X-Mailin-custom'])
self.assertEqual(metadata['user_id'], "12345")
self.assertEqual(metadata['items'], 6)
self.assertEqual(metadata['float'], 98.6)
self.assertEqual(metadata['long'], longtype(123))
def test_send_at(self):
utc_plus_6 = get_fixed_timezone(6 * 60)
utc_minus_8 = get_fixed_timezone(-8 * 60)
with override_current_timezone(utc_plus_6):
# Timezone-aware datetime converted to UTC:
self.message.send_at = datetime(2016, 3, 4, 5, 6, 7, tzinfo=utc_minus_8)
with self.assertRaises(AnymailUnsupportedFeature):
self.message.send()
def test_tag(self):
self.message.tags = ["receipt"]
self.message.send()
data = self.get_api_call_json()
self.assertCountEqual(data['headers']["X-Mailin-tag"], "receipt")
def test_multiple_tags(self):
self.message.tags = ["receipt", "repeat-user"]
with self.assertRaises(AnymailUnsupportedFeature):
self.message.send()
def test_tracking(self):
# Test one way...
self.message.track_clicks = False
self.message.track_opens = True
with self.assertRaises(AnymailUnsupportedFeature):
self.message.send()
# ...and the opposite way
self.message.track_clicks = True
self.message.track_opens = False
with self.assertRaises(AnymailUnsupportedFeature):
self.message.send()
def test_template_id(self):
# SendinBlue use incremental ID to identify templates
self.message.template_id = "12"
self.message.merge_global_data = {
'buttonUrl': 'https://mydomain.com',
}
# SendinBlue doesn't support (if we use a template):
# - subject
# - body
# - display name of emails
self.message.subject = ''
self.message.body = ''
self.message.to = ['alice@example.com', 'bob@example.com']
self.message.cc = ['cc@example.com']
self.message.bcc = ['bcc@example.com']
self.message.reply_to = ['reply@example.com']
self.message.send()
self.assert_esp_called('/v3/smtp/templates/12/send')
data = self.get_api_call_json()
self.assertEqual(data['emailTo'], ["alice@example.com", "bob@example.com"])
self.assertEqual(data['emailCc'], ["cc@example.com"])
self.assertEqual(data['emailBcc'], ["bcc@example.com"])
self.assertEqual(data['replyTo'], 'reply@example.com')
self.assertEqual(data['attributes']['buttonUrl'], "https://mydomain.com")
def test_template_id_with_empty_body(self):
message = mail.EmailMessage(from_email='from@example.com', to=['to@example.com'])
message.template_id = "9"
message.send()
data = self.get_api_call_json()
self.assertNotIn('htmlcontent', data)
self.assertNotIn('textContent', data) # neither text nor html body
self.assertNotIn('subject', data)
def test_merge_data(self):
self.message.merge_data = {
'alice@example.com': {':name': "Alice", ':group': "Developers"},
'bob@example.com': {':name': "Bob"}, # and leave :group undefined
}
with self.assertRaises(AnymailUnsupportedFeature):
self.message.send()
def test_default_omits_options(self):
"""Make sure by default we don't send any ESP-specific options.
Options not specified by the caller should be omitted entirely from
the API call (*not* sent as False or empty). This ensures
that your ESP account settings apply by default.
"""
self.message.send()
data = self.get_api_call_json()
self.assertNotIn('attachment', data)
self.assertNotIn('tag', data)
self.assertNotIn('headers', data)
self.assertNotIn('replyTo', data)
self.assertNotIn('atributes', data)
def test_esp_extra(self):
self.message.tags = ["tag"]
# 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
self.message.esp_extra = {
'ip_pool_name': "transactional",
'asm': { # subscription management
'group_id': 1,
},
'tracking_settings': {
'subscription_tracking': {
'enable': True,
'substitution_tag': '[unsubscribe_url]',
},
},
}
self.message.send()
data = self.get_api_call_json()
# merged from esp_extra:
self.assertEqual(data['ip_pool_name'], "transactional")
self.assertEqual(data['asm'], {'group_id': 1})
self.assertEqual(data['tracking_settings']['subscription_tracking'],
{'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
def test_send_attaches_anymail_status(self):
""" The anymail_status should be attached to the message when it is sent """
# the DEFAULT_RAW_RESPONSE above is the *only* success response SendinBlue returns,
# so no need to override it here
msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],)
sent = msg.send()
self.assertEqual(sent, 1)
self.assertEqual(msg.anymail_status.status, {'queued'})
self.assertEqual(msg.anymail_status.recipients['to1@example.com'].status, 'queued')
self.assertEqual(msg.anymail_status.esp_response.content, self.DEFAULT_RAW_RESPONSE)
self.assertEqual(
msg.anymail_status.message_id,
json.loads(msg.anymail_status.esp_response.content.decode('utf-8'))['messageId']
)
self.assertEqual(
msg.anymail_status.recipients['to1@example.com'].message_id,
json.loads(msg.anymail_status.esp_response.content.decode('utf-8'))['messageId']
)
# noinspection PyUnresolvedReferences
def test_send_failed_anymail_status(self):
""" If the send fails, anymail_status should contain initial values"""
self.set_mock_response(status_code=500)
sent = self.message.send(fail_silently=True)
self.assertEqual(sent, 0)
self.assertIsNone(self.message.anymail_status.status)
self.assertEqual(self.message.anymail_status.recipients, {})
self.assertIsNone(self.message.anymail_status.esp_response)
def test_json_serialization_errors(self):
"""Try to provide more information about non-json-serializable data"""
self.message.esp_extra = {'total': Decimal('19.99')}
with self.assertRaises(AnymailSerializationError) as cm:
self.message.send()
err = cm.exception
self.assertIsInstance(err, TypeError) # compatibility with json.dumps
self.assertIn("Don't know how to send this data to SendinBlue", str(err)) # our added context
self.assertRegex(str(err), r"Decimal.*is not JSON serializable") # original message
class SendinBlueBackendRecipientsRefusedTests(SendinBlueBackendMockAPITestCase):
"""Should raise AnymailRecipientsRefused when *all* recipients are rejected or invalid"""
# SendinBlue doesn't check email bounce or complaint lists at time of send --
# it always just queues the message. You'll need to listen for the "rejected"
# and "failed" events to detect refused recipients.
pass # not applicable to this backend
class SendinBlueBackendSessionSharingTestCase(SessionSharingTestCasesMixin, SendinBlueBackendMockAPITestCase):
"""Requests session sharing tests"""
pass # tests are defined in the mixin
@override_settings(EMAIL_BACKEND="anymail.backends.sendinblue.EmailBackend")
class SendinBlueBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin):
"""Test ESP backend without required settings in place"""
def test_missing_auth(self):
with self.assertRaisesRegex(AnymailConfigurationError, r'\bSENDINBLUE_API_KEY\b'):
mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com'])

View File

@@ -0,0 +1,107 @@
import os
import unittest
from django.test import SimpleTestCase
from django.test.utils import override_settings
from anymail.exceptions import AnymailAPIError
from anymail.message import AnymailMessage
from .utils import AnymailTestMixin, RUN_LIVE_TESTS
SENDINBLUE_TEST_API_KEY = os.getenv('SENDINBLUE_TEST_API_KEY')
@unittest.skipUnless(RUN_LIVE_TESTS, "RUN_LIVE_TESTS disabled in this environment")
@unittest.skipUnless(SENDINBLUE_TEST_API_KEY,
"Set SENDINBLUE_TEST_API_KEY environment variable "
"to run SendinBlue integration tests")
@override_settings(ANYMAIL_SENDINBLUE_API_KEY=SENDINBLUE_TEST_API_KEY,
ANYMAIL_SENDINBLUE_SEND_DEFAULTS=dict(),
EMAIL_BACKEND="anymail.backends.sendinblue.EmailBackend")
class SendinBlueBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
"""SendinBlue v3 API integration tests
SendinBlue doesn't have sandbox so these tests run
against the **live** SendinBlue API, using the
environment variable `SENDINBLUE_TEST_API_KEY` as the API key
If those variables are not set, these tests won't run.
https://developers.sendinblue.com/docs/faq#section-how-can-i-test-the-api-
"""
def setUp(self):
super(SendinBlueBackendIntegrationTests, self).setUp()
self.message = AnymailMessage('Anymail SendinBlue integration test', 'Text content',
'from@example.com', ['to@example.com'])
self.message.attach_alternative('<p>HTML content</p>', "text/html")
def test_simple_send(self):
# Example of getting the SendinBlue send status and message id from the message
sent_count = self.message.send()
self.assertEqual(sent_count, 1)
anymail_status = self.message.anymail_status
sent_status = anymail_status.recipients['to@example.com'].status
message_id = anymail_status.recipients['to@example.com'].message_id
self.assertEqual(sent_status, 'queued') # SendinBlue always queues
self.assertRegex(message_id, r'\<.+@smtp-relay\.mailin\.fr\>') # should use from_email's domain
self.assertEqual(anymail_status.status, {sent_status}) # set of all recipient statuses
self.assertEqual(anymail_status.message_id, message_id)
def test_all_options_without_template(self):
message = AnymailMessage(
subject="Anymail all-options integration test",
body="This is the text body",
from_email='"Test From, with comma" <from@example.com>',
to=["to1@example.com", '"Recipient 2, OK?" <to2@example.com>'],
cc=["cc1@example.com", "Copy 2 <cc2@example.com>"],
bcc=["bcc1@example.com", "Blind Copy 2 <bcc2@example.com>"],
reply_to=['"Reply, with comma" <reply@example.com>'], # SendinBlue API v3 only supports single reply-to
tags=["tag 1"],
headers={"X-Anymail-Test": "value", "X-Anymail-Count": 3},
merge_global_data={
'global': 'global_value'
},
metadata={"meta1": "simple string", "meta2": 2},
)
message.attach_alternative('<p>HTML content</p>', "text/html") # SendinBlue need an HTML content to work
message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain")
message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv")
message.send()
self.assertEqual(message.anymail_status.status, {'queued'}) # SendinBlue always queues
def test_all_options_with_template(self):
message = AnymailMessage(
template_id='1',
to=["to1@example.com", 'to2@example.com'],
cc=["cc1@example.com", "cc2@example.com"],
bcc=["bcc1@example.com", "bcc2@example.com"],
reply_to=['reply@example.com'], # SendinBlue API v3 only supports single reply-to
tags=["tag 1"],
headers={"X-Anymail-Test": "value", "X-Anymail-Count": 3},
merge_global_data={
'global': 'global_value'
},
metadata={"meta1": "simple string", "meta2": 2},
)
message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain")
message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv")
message.send()
self.assertEqual(message.anymail_status.status, {'queued'}) # SendinBlue always queues
@override_settings(ANYMAIL_SENDINBLUE_API_KEY="Hey, that's not an API key!")
def test_invalid_api_key(self):
with self.assertRaises(AnymailAPIError) as cm:
self.message.send()
err = cm.exception
self.assertEqual(err.status_code, 401)
# Make sure the exception message includes SendinBlue's response:
self.assertIn("Key not found", str(err))