From dc2b4b4e7ac66ba794e6ebb6fee727aaeff701be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rignon=20No=C3=ABl?= Date: Mon, 26 Feb 2018 12:46:10 -0500 Subject: [PATCH] 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.) --- AUTHORS.txt | 1 + anymail/backends/sendinblue.py | 229 +++++++++++++ docs/esps/index.rst | 40 +-- docs/esps/sendinblue.rst | 90 +++++ tests/test_sendinblue_backend.py | 474 +++++++++++++++++++++++++++ tests/test_sendinblue_integration.py | 107 ++++++ 6 files changed, 922 insertions(+), 19 deletions(-) create mode 100644 anymail/backends/sendinblue.py create mode 100644 docs/esps/sendinblue.rst create mode 100644 tests/test_sendinblue_backend.py create mode 100644 tests/test_sendinblue_integration.py diff --git a/AUTHORS.txt b/AUTHORS.txt index 1c1d3c2..c0727d8 100644 --- a/AUTHORS.txt +++ b/AUTHORS.txt @@ -6,6 +6,7 @@ Calvin Jeong Peter Wu Charlie DeTar Jonathan Baugh +Noel Rignon Anymail was forked from Djrill, which included contributions from: diff --git a/anymail/backends/sendinblue.py b/anymail/backends/sendinblue.py new file mode 100644 index 0000000..7f2a30d --- /dev/null +++ b/anymail/backends/sendinblue.py @@ -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) diff --git a/docs/esps/index.rst b/docs/esps/index.rst index 851d146..b86d685 100644 --- a/docs/esps/index.rst +++ b/docs/esps/index.rst @@ -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 ` ---------------------------------------------------------------------------------------------------------------------- -: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 ` and :ref:`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 ` ---------------------------------------------------------------------------------------------------------------------- -|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` diff --git a/docs/esps/sendinblue.rst b/docs/esps/sendinblue.rst new file mode 100644 index 0000000..247c3f9 --- /dev/null +++ b/docs/esps/sendinblue.rst @@ -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": "", + } + +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. diff --git a/tests/test_sendinblue_backend.py b/tests/test_sendinblue_backend.py new file mode 100644 index 0000000..ddd8b15 --- /dev/null +++ b/tests/test_sendinblue_backend.py @@ -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 ', + ['Recipient #1 ', 'to2@example.com'], + cc=['Carbon Copy ', 'cc2@example.com'], + bcc=['Blind Copy ', '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 '], + bcc=['bcc1@example.com', 'Also BCC '], + cc=['cc1@example.com', 'Also CC '], + headers={'Reply-To': 'another@example.com', + 'X-MyHeader': 'my value', + 'Message-ID': ''}) # 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': "", + }) + + def test_html_message(self): + text_content = 'This is an important message.' + html_content = '

This is an important message.

' + 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 = '

This is an important message.

' + 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" '} + 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" \u2019

', 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'

\u2019

') + + 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 = '

This has an inline image.

' % 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("

First html is OK

", "text/html") + self.message.attach_alternative("

And maybe second html, too

", "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']) diff --git a/tests/test_sendinblue_integration.py b/tests/test_sendinblue_integration.py new file mode 100644 index 0000000..35bb2ac --- /dev/null +++ b/tests/test_sendinblue_integration.py @@ -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('

HTML content

', "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" ', + to=["to1@example.com", '"Recipient 2, OK?" '], + cc=["cc1@example.com", "Copy 2 "], + bcc=["bcc1@example.com", "Blind Copy 2 "], + reply_to=['"Reply, with comma" '], # 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('

HTML content

', "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))