diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a85d32e..5e4bf9e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -40,6 +40,9 @@ Breaking changes Features ~~~~~~~~ +* **Sendinblue:** Support delayed sending using Anymail's `send_at` option. + (Thanks to `@dimitrisor`_ for noting Sendinblue's public beta release + of this capability.) * Support customizing the requests.Session for requests-based backends, and document how this can be used to mount an adapter that simplifies automatic retry logic. (Thanks to `@dgilmanAIDENTIFIED`_.) @@ -1366,6 +1369,7 @@ Features .. _@coupa-anya: https://github.com/coupa-anya .. _@decibyte: https://github.com/decibyte .. _@dgilmanAIDENTIFIED: https://github.com/dgilmanAIDENTIFIED +.. _@dimitrisor: https://github.com/dimitrisor .. _@dominik-lekse: https://github.com/dominik-lekse .. _@erikdrums: https://github.com/erikdrums .. _@ewingrj: https://github.com/ewingrj diff --git a/anymail/backends/sendinblue.py b/anymail/backends/sendinblue.py index 4db75af..1b69225 100644 --- a/anymail/backends/sendinblue.py +++ b/anymail/backends/sendinblue.py @@ -162,3 +162,10 @@ class SendinBluePayload(RequestsPayload): def set_metadata(self, metadata): # SendinBlue expects a single string payload self.data['headers']["X-Mailin-custom"] = self.serialize_json(metadata) + + def set_send_at(self, send_at): + try: + start_time_iso = send_at.isoformat(timespec="milliseconds") + except (AttributeError, TypeError): + start_time_iso = send_at # assume user already formatted + self.data['scheduledAt'] = start_time_iso diff --git a/docs/esps/index.rst b/docs/esps/index.rst index 122b0c5..9d3c7b6 100644 --- a/docs/esps/index.rst +++ b/docs/esps/index.rst @@ -40,7 +40,7 @@ Email Service Provider |Amazon SES| |Mailgun| |Mailje :attr:`~AnymailMessage.envelope_sender` Yes Domain only Yes Domain only Yes No No No Yes :attr:`~AnymailMessage.metadata` Yes Yes Yes Yes No Yes Yes Yes Yes :attr:`~AnymailMessage.merge_metadata` No Yes Yes Yes No Yes Yes No Yes -:attr:`~AnymailMessage.send_at` No Yes No Yes No No Yes No Yes +:attr:`~AnymailMessage.send_at` No Yes No Yes No No Yes Yes Yes :attr:`~AnymailMessage.tags` Yes Yes Max 1 tag Yes Max 1 tag Max 1 tag Yes Yes Max 1 tag :attr:`~AnymailMessage.track_clicks` No Yes Yes Yes No Yes Yes No Yes :attr:`~AnymailMessage.track_opens` No Yes Yes Yes No Yes Yes No Yes diff --git a/docs/esps/sendinblue.rst b/docs/esps/sendinblue.rst index bc7493a..64770e3 100644 --- a/docs/esps/sendinblue.rst +++ b/docs/esps/sendinblue.rst @@ -78,18 +78,20 @@ set a message's :attr:`~anymail.message.AnymailMessage.esp_extra` to a `dict` that will be merged into the json sent to Sendinblue's `smtp/email API`_. -Example: +For example, you could set Sendinblue's *batchId* for use with +their `batched scheduled sending`_: .. code-block:: python message.esp_extra = { - 'hypotheticalFutureSendinblueParam': '2022', # merged into send params + 'batchId': '275d3289-d5cb-4768-9460-a990054b6c81', # merged into send params } (You can also set `"esp_extra"` in Anymail's :ref:`global send defaults ` to apply it to all messages.) +.. _batched scheduled sending: https://developers.sendinblue.com/docs/schedule-batch-sendings .. _smtp/email API: https://developers.sendinblue.com/v3.0/reference#sendtransacemail @@ -141,8 +143,10 @@ Sendinblue can handle. as a JSON-encoded string using their :mailheader:`X-Mailin-custom` email header. The metadata is available in tracking webhooks. -**No delayed sending** - Sendinblue does not support :attr:`~anymail.message.AnymailMessage.send_at`. +**Delayed sending** + .. versionadded:: 9.0 + Earlier versions of Anymail did not support :attr:`~anymail.message.AnymailMessage.send_at` + with Sendinblue. **No click-tracking or open-tracking options** Sendinblue does not provide a way to control open or click tracking for individual diff --git a/tests/test_sendinblue_backend.py b/tests/test_sendinblue_backend.py index f053f42..cdcbb32 100644 --- a/tests/test_sendinblue_backend.py +++ b/tests/test_sendinblue_backend.py @@ -1,6 +1,6 @@ import json from base64 import b64encode, b64decode -from datetime import datetime +from datetime import date, datetime, timezone from decimal import Decimal from email.mime.base import MIMEBase from email.mime.image import MIMEImage @@ -294,10 +294,41 @@ class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase): 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) + self.message.send_at = datetime(2016, 3, 4, 5, 6, 7, 8000, tzinfo=utc_minus_8) + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data['scheduledAt'], "2016-03-04T05:06:07.008-08:00") - with self.assertRaises(AnymailUnsupportedFeature): - self.message.send() + # Explicit UTC: + self.message.send_at = datetime(2016, 3, 4, 5, 6, 7, tzinfo=timezone.utc) + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data['scheduledAt'], "2016-03-04T05:06:07.000+00:00") + + # Timezone-naive datetime assumed to be Django current_timezone + # (also checks stripping microseconds) + self.message.send_at = datetime(2022, 10, 11, 12, 13, 14, 567) + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data['scheduledAt'], "2022-10-11T12:13:14.000+06:00") + + # Date-only treated as midnight in current timezone + self.message.send_at = date(2022, 10, 22) + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data['scheduledAt'], "2022-10-22T00:00:00.000+06:00") + + # POSIX timestamp + self.message.send_at = 1651820889 # 2022-05-06 07:08:09 UTC + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data['scheduledAt'], "2022-05-06T07:08:09.000+00:00") + + # String passed unchanged (this is *not* portable between ESPs) + self.message.send_at = "2022-10-13T18:02:00.123-11:30" + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data['scheduledAt'], "2022-10-13T18:02:00.123-11:30") def test_tag(self): self.message.tags = ["receipt", "multiple"] diff --git a/tests/test_sendinblue_integration.py b/tests/test_sendinblue_integration.py index 52b4164..1934cd5 100644 --- a/tests/test_sendinblue_integration.py +++ b/tests/test_sendinblue_integration.py @@ -1,5 +1,6 @@ import os import unittest +from datetime import datetime, timedelta from email.utils import formataddr from django.test import SimpleTestCase, override_settings, tag @@ -55,6 +56,7 @@ class SendinBlueBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): self.assertEqual(anymail_status.message_id, message_id) def test_all_options(self): + send_at = datetime.now() + timedelta(minutes=2) message = AnymailMessage( subject="Anymail SendinBlue all-options integration test", body="This is the text body", @@ -66,6 +68,7 @@ class SendinBlueBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): headers={"X-Anymail-Test": "value", "X-Anymail-Count": 3}, metadata={"meta1": "simple string", "meta2": 2}, + send_at=send_at, tags=["tag 1", "tag 2"], ) message.attach_alternative('

HTML content

', "text/html") # SendinBlue requires an HTML body