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 This is an important message. This is an important message.
\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 image.
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"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))