Brevo: Rename SendinBlue to Brevo

- Replace "SendinBlue" with "Brevo"
  throughout the code.
- Maintain deprecated compatibility
  versions on the old names/URLs.
  (Split into separate commit
  to make renamed files more
  obvious.)
- Update docs to reflect change,
  provide migration advice.
- Update integration workflow.
This commit is contained in:
Mike Edmunds
2024-03-11 18:46:52 -07:00
parent 14d451659e
commit c7ee59c3ca
12 changed files with 326 additions and 197 deletions

View File

@@ -40,6 +40,7 @@ jobs:
# combination, to avoid rapidly consuming the testing accounts' entire send allotments. # combination, to avoid rapidly consuming the testing accounts' entire send allotments.
config: config:
- { tox: django41-py310-amazon_ses, python: "3.10" } - { tox: django41-py310-amazon_ses, python: "3.10" }
- { tox: django41-py310-brevo, python: "3.10" }
- { tox: django41-py310-mailersend, python: "3.10" } - { tox: django41-py310-mailersend, python: "3.10" }
- { tox: django41-py310-mailgun, python: "3.10" } - { tox: django41-py310-mailgun, python: "3.10" }
- { tox: django41-py310-mailjet, python: "3.10" } - { tox: django41-py310-mailjet, python: "3.10" }
@@ -48,7 +49,6 @@ jobs:
- { tox: django41-py310-postmark, python: "3.10" } - { tox: django41-py310-postmark, python: "3.10" }
- { tox: django41-py310-resend, python: "3.10" } - { tox: django41-py310-resend, python: "3.10" }
- { tox: django41-py310-sendgrid, python: "3.10" } - { tox: django41-py310-sendgrid, python: "3.10" }
- { tox: django41-py310-sendinblue, python: "3.10" }
- { tox: django41-py310-sparkpost, python: "3.10" } - { tox: django41-py310-sparkpost, python: "3.10" }
- { tox: django41-py310-unisender_go, python: "3.10" } - { tox: django41-py310-unisender_go, python: "3.10" }
@@ -77,6 +77,8 @@ jobs:
ANYMAIL_TEST_AMAZON_SES_DOMAIN: ${{ secrets.ANYMAIL_TEST_AMAZON_SES_DOMAIN }} ANYMAIL_TEST_AMAZON_SES_DOMAIN: ${{ secrets.ANYMAIL_TEST_AMAZON_SES_DOMAIN }}
ANYMAIL_TEST_AMAZON_SES_REGION_NAME: ${{ secrets.ANYMAIL_TEST_AMAZON_SES_REGION_NAME }} ANYMAIL_TEST_AMAZON_SES_REGION_NAME: ${{ secrets.ANYMAIL_TEST_AMAZON_SES_REGION_NAME }}
ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY: ${{ secrets.ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY }} ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY: ${{ secrets.ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY }}
ANYMAIL_TEST_BREVO_API_KEY: ${{ secrets.ANYMAIL_TEST_BREVO_API_KEY }}
ANYMAIL_TEST_BREVO_DOMAIN: ${{ vars.ANYMAIL_TEST_BREVO_DOMAIN }}
ANYMAIL_TEST_MAILERSEND_API_TOKEN: ${{ secrets.ANYMAIL_TEST_MAILERSEND_API_TOKEN }} ANYMAIL_TEST_MAILERSEND_API_TOKEN: ${{ secrets.ANYMAIL_TEST_MAILERSEND_API_TOKEN }}
ANYMAIL_TEST_MAILERSEND_DOMAIN: ${{ secrets.ANYMAIL_TEST_MAILERSEND_DOMAIN }} ANYMAIL_TEST_MAILERSEND_DOMAIN: ${{ secrets.ANYMAIL_TEST_MAILERSEND_DOMAIN }}
ANYMAIL_TEST_MAILGUN_API_KEY: ${{ secrets.ANYMAIL_TEST_MAILGUN_API_KEY }} ANYMAIL_TEST_MAILGUN_API_KEY: ${{ secrets.ANYMAIL_TEST_MAILGUN_API_KEY }}
@@ -94,8 +96,6 @@ jobs:
ANYMAIL_TEST_SENDGRID_API_KEY: ${{ secrets.ANYMAIL_TEST_SENDGRID_API_KEY }} ANYMAIL_TEST_SENDGRID_API_KEY: ${{ secrets.ANYMAIL_TEST_SENDGRID_API_KEY }}
ANYMAIL_TEST_SENDGRID_DOMAIN: ${{ secrets.ANYMAIL_TEST_SENDGRID_DOMAIN }} ANYMAIL_TEST_SENDGRID_DOMAIN: ${{ secrets.ANYMAIL_TEST_SENDGRID_DOMAIN }}
ANYMAIL_TEST_SENDGRID_TEMPLATE_ID: ${{ secrets.ANYMAIL_TEST_SENDGRID_TEMPLATE_ID }} ANYMAIL_TEST_SENDGRID_TEMPLATE_ID: ${{ secrets.ANYMAIL_TEST_SENDGRID_TEMPLATE_ID }}
ANYMAIL_TEST_SENDINBLUE_API_KEY: ${{ secrets.ANYMAIL_TEST_SENDINBLUE_API_KEY }}
ANYMAIL_TEST_SENDINBLUE_DOMAIN: ${{ secrets.ANYMAIL_TEST_SENDINBLUE_DOMAIN }}
ANYMAIL_TEST_SPARKPOST_API_KEY: ${{ secrets.ANYMAIL_TEST_SPARKPOST_API_KEY }} ANYMAIL_TEST_SPARKPOST_API_KEY: ${{ secrets.ANYMAIL_TEST_SPARKPOST_API_KEY }}
ANYMAIL_TEST_SPARKPOST_DOMAIN: ${{ secrets.ANYMAIL_TEST_SPARKPOST_DOMAIN }} ANYMAIL_TEST_SPARKPOST_DOMAIN: ${{ secrets.ANYMAIL_TEST_SPARKPOST_DOMAIN }}
ANYMAIL_TEST_UNISENDER_GO_API_KEY: ${{ secrets.ANYMAIL_TEST_UNISENDER_GO_API_KEY }} ANYMAIL_TEST_UNISENDER_GO_API_KEY: ${{ secrets.ANYMAIL_TEST_UNISENDER_GO_API_KEY }}

View File

@@ -30,6 +30,16 @@ vNext
*unreleased changes* *unreleased changes*
Deprecations
~~~~~~~~~~~~
* **SendinBlue:** Rename "SendinBlue" to "Brevo" throughout Anymail's code.
This affects the email backend name, settings names, and webhook URLs.
The old names will continue to work for now, but are deprecated. See
`Updating code from SendinBlue to Brevo <https://anymail.dev/en/latest/esps/brevo/#brevo-rename>`__
for details.
Features Features
~~~~~~~~ ~~~~~~~~

View File

@@ -8,10 +8,10 @@ from .base_requests import AnymailRequestsBackend, RequestsPayload
class EmailBackend(AnymailRequestsBackend): class EmailBackend(AnymailRequestsBackend):
""" """
SendinBlue v3 API Email Backend Brevo v3 API Email Backend
""" """
esp_name = "SendinBlue" esp_name = "Brevo"
def __init__(self, **kwargs): def __init__(self, **kwargs):
"""Init options from Django settings""" """Init options from Django settings"""
@@ -33,11 +33,11 @@ class EmailBackend(AnymailRequestsBackend):
super().__init__(api_url, **kwargs) super().__init__(api_url, **kwargs)
def build_message_payload(self, message, defaults): def build_message_payload(self, message, defaults):
return SendinBluePayload(message, defaults, self) return BrevoPayload(message, defaults, self)
def parse_recipient_status(self, response, payload, message): def parse_recipient_status(self, response, payload, message):
# SendinBlue doesn't give any detail on a success # Brevo doesn't give any detail on a success, other than messageId
# https://developers.sendinblue.com/docs/responses # https://developers.brevo.com/reference/sendtransacemail
message_id = None message_id = None
message_ids = [] message_ids = []
@@ -51,7 +51,7 @@ class EmailBackend(AnymailRequestsBackend):
message_ids = parsed_response["messageIds"] message_ids = parsed_response["messageIds"]
except (KeyError, TypeError) as err: except (KeyError, TypeError) as err:
raise AnymailRequestsAPIError( raise AnymailRequestsAPIError(
"Invalid SendinBlue API response format", "Invalid Brevo API response format",
email_message=message, email_message=message,
payload=payload, payload=payload,
response=response, response=response,
@@ -70,7 +70,7 @@ class EmailBackend(AnymailRequestsBackend):
return recipient_status return recipient_status
class SendinBluePayload(RequestsPayload): class BrevoPayload(RequestsPayload):
def __init__(self, message, defaults, backend, *args, **kwargs): def __init__(self, message, defaults, backend, *args, **kwargs):
self.all_recipients = [] # used for backend.parse_recipient_status self.all_recipients = [] # used for backend.parse_recipient_status
self.to_recipients = [] # used for backend.parse_recipient_status self.to_recipients = [] # used for backend.parse_recipient_status
@@ -124,7 +124,7 @@ class SendinBluePayload(RequestsPayload):
@staticmethod @staticmethod
def email_object(email): def email_object(email):
"""Converts EmailAddress to SendinBlue API array""" """Converts EmailAddress to Brevo API array"""
email_object = dict() email_object = dict()
email_object["email"] = email.addr_spec email_object["email"] = email.addr_spec
if email.display_name: if email.display_name:
@@ -147,14 +147,14 @@ class SendinBluePayload(RequestsPayload):
self.data["subject"] = subject self.data["subject"] = subject
def set_reply_to(self, emails): def set_reply_to(self, emails):
# SendinBlue only supports a single address in the reply_to API param. # Brevo only supports a single address in the reply_to API param.
if len(emails) > 1: if len(emails) > 1:
self.unsupported_feature("multiple reply_to addresses") self.unsupported_feature("multiple reply_to addresses")
if len(emails) > 0: if len(emails) > 0:
self.data["replyTo"] = self.email_object(emails[0]) self.data["replyTo"] = self.email_object(emails[0])
def set_extra_headers(self, headers): def set_extra_headers(self, headers):
# SendinBlue requires header values to be strings (not integers) as of 11/2022. # Brevo requires header values to be strings (not integers) as of 11/2022.
# Stringify ints and floats; anything else is the caller's responsibility. # Stringify ints and floats; anything else is the caller's responsibility.
self.data["headers"].update( self.data["headers"].update(
{ {
@@ -182,7 +182,7 @@ class SendinBluePayload(RequestsPayload):
self.data["htmlContent"] = body self.data["htmlContent"] = body
def add_attachment(self, attachment): def add_attachment(self, attachment):
"""Converts attachments to SendinBlue API {name, base64} array""" """Converts attachments to Brevo API {name, base64} array"""
att = { att = {
"name": attachment.name or "", "name": attachment.name or "",
"content": attachment.b64content, "content": attachment.b64content,
@@ -204,7 +204,7 @@ class SendinBluePayload(RequestsPayload):
self.data["params"] = merge_global_data self.data["params"] = merge_global_data
def set_metadata(self, metadata): def set_metadata(self, metadata):
# SendinBlue expects a single string payload # Brevo expects a single string payload
self.data["headers"]["X-Mailin-custom"] = self.serialize_json(metadata) self.data["headers"]["X-Mailin-custom"] = self.serialize_json(metadata)
self.metadata = metadata # needed in serialize_data for batch send self.metadata = metadata # needed in serialize_data for batch send

View File

@@ -4,6 +4,7 @@ from .webhooks.amazon_ses import (
AmazonSESInboundWebhookView, AmazonSESInboundWebhookView,
AmazonSESTrackingWebhookView, AmazonSESTrackingWebhookView,
) )
from .webhooks.brevo import BrevoInboundWebhookView, BrevoTrackingWebhookView
from .webhooks.mailersend import ( from .webhooks.mailersend import (
MailerSendInboundWebhookView, MailerSendInboundWebhookView,
MailerSendTrackingWebhookView, MailerSendTrackingWebhookView,
@@ -15,10 +16,6 @@ from .webhooks.postal import PostalInboundWebhookView, PostalTrackingWebhookView
from .webhooks.postmark import PostmarkInboundWebhookView, PostmarkTrackingWebhookView from .webhooks.postmark import PostmarkInboundWebhookView, PostmarkTrackingWebhookView
from .webhooks.resend import ResendTrackingWebhookView from .webhooks.resend import ResendTrackingWebhookView
from .webhooks.sendgrid import SendGridInboundWebhookView, SendGridTrackingWebhookView from .webhooks.sendgrid import SendGridInboundWebhookView, SendGridTrackingWebhookView
from .webhooks.sendinblue import (
SendinBlueInboundWebhookView,
SendinBlueTrackingWebhookView,
)
from .webhooks.sparkpost import ( from .webhooks.sparkpost import (
SparkPostInboundWebhookView, SparkPostInboundWebhookView,
SparkPostTrackingWebhookView, SparkPostTrackingWebhookView,
@@ -32,6 +29,11 @@ urlpatterns = [
AmazonSESInboundWebhookView.as_view(), AmazonSESInboundWebhookView.as_view(),
name="amazon_ses_inbound_webhook", name="amazon_ses_inbound_webhook",
), ),
path(
"brevo/inbound/",
BrevoInboundWebhookView.as_view(),
name="brevo_inbound_webhook",
),
path( path(
"mailersend/inbound/", "mailersend/inbound/",
MailerSendInboundWebhookView.as_view(), MailerSendInboundWebhookView.as_view(),
@@ -66,11 +68,6 @@ urlpatterns = [
SendGridInboundWebhookView.as_view(), SendGridInboundWebhookView.as_view(),
name="sendgrid_inbound_webhook", name="sendgrid_inbound_webhook",
), ),
path(
"sendinblue/inbound/",
SendinBlueInboundWebhookView.as_view(),
name="sendinblue_inbound_webhook",
),
path( path(
"sparkpost/inbound/", "sparkpost/inbound/",
SparkPostInboundWebhookView.as_view(), SparkPostInboundWebhookView.as_view(),
@@ -81,6 +78,11 @@ urlpatterns = [
AmazonSESTrackingWebhookView.as_view(), AmazonSESTrackingWebhookView.as_view(),
name="amazon_ses_tracking_webhook", name="amazon_ses_tracking_webhook",
), ),
path(
"brevo/tracking/",
BrevoTrackingWebhookView.as_view(),
name="brevo_tracking_webhook",
),
path( path(
"mailersend/tracking/", "mailersend/tracking/",
MailerSendTrackingWebhookView.as_view(), MailerSendTrackingWebhookView.as_view(),
@@ -116,11 +118,6 @@ urlpatterns = [
SendGridTrackingWebhookView.as_view(), SendGridTrackingWebhookView.as_view(),
name="sendgrid_tracking_webhook", name="sendgrid_tracking_webhook",
), ),
path(
"sendinblue/tracking/",
SendinBlueTrackingWebhookView.as_view(),
name="sendinblue_tracking_webhook",
),
path( path(
"sparkpost/tracking/", "sparkpost/tracking/",
SparkPostTrackingWebhookView.as_view(), SparkPostTrackingWebhookView.as_view(),

View File

@@ -19,12 +19,14 @@ from ..utils import get_anymail_setting
from .base import AnymailBaseWebhookView from .base import AnymailBaseWebhookView
class SendinBlueBaseWebhookView(AnymailBaseWebhookView): class BrevoBaseWebhookView(AnymailBaseWebhookView):
esp_name = "SendinBlue" esp_name = "Brevo"
class SendinBlueTrackingWebhookView(SendinBlueBaseWebhookView): class BrevoTrackingWebhookView(BrevoBaseWebhookView):
"""Handler for SendinBlue delivery and engagement tracking webhooks""" """Handler for Brevo delivery and engagement tracking webhooks"""
# https://developers.brevo.com/docs/transactional-webhooks
signal = tracking signal = tracking
@@ -33,15 +35,13 @@ class SendinBlueTrackingWebhookView(SendinBlueBaseWebhookView):
if "items" in esp_event: if "items" in esp_event:
# This is an inbound webhook post # This is an inbound webhook post
raise AnymailConfigurationError( raise AnymailConfigurationError(
"You seem to have set SendinBlue's *inbound* webhook URL " f"You seem to have set Brevo's *inbound* webhook URL "
"to Anymail's SendinBlue *tracking* webhook URL." f"to Anymail's {self.esp_name} *tracking* webhook URL."
) )
return [self.esp_to_anymail_event(esp_event)] return [self.esp_to_anymail_event(esp_event)]
# SendinBlue's webhook payload data doesn't seem to be documented anywhere.
# There's a list of webhook events at https://apidocs.sendinblue.com/webhooks/#3.
event_types = { event_types = {
# Map SendinBlue event type: Anymail normalized (event type, reject reason) # Map Brevo event type: Anymail normalized (event type, reject reason)
# received even if message won't be sent (e.g., before "blocked"): # received even if message won't be sent (e.g., before "blocked"):
"request": (EventType.QUEUED, None), "request": (EventType.QUEUED, None),
"delivered": (EventType.DELIVERED, None), "delivered": (EventType.DELIVERED, None),
@@ -67,7 +67,7 @@ class SendinBlueTrackingWebhookView(SendinBlueBaseWebhookView):
recipient = esp_event.get("email") recipient = esp_event.get("email")
try: try:
# SendinBlue supplies "ts", "ts_event" and "date" fields, which seem to be # Brevo supplies "ts", "ts_event" and "date" fields, which seem to be
# based on the timezone set in the account preferences (and possibly with # based on the timezone set in the account preferences (and possibly with
# inconsistent DST adjustment). "ts_epoch" is the only field that seems to # inconsistent DST adjustment). "ts_epoch" is the only field that seems to
# be consistently UTC; it's in milliseconds # be consistently UTC; it's in milliseconds
@@ -98,7 +98,7 @@ class SendinBlueTrackingWebhookView(SendinBlueBaseWebhookView):
return AnymailTrackingEvent( return AnymailTrackingEvent(
description=None, description=None,
esp_event=esp_event, esp_event=esp_event,
# SendinBlue doesn't provide a unique event id: # Brevo doesn't provide a unique event id:
event_id=None, event_id=None,
event_type=event_type, event_type=event_type,
message_id=esp_event.get("message-id"), message_id=esp_event.get("message-id"),
@@ -113,8 +113,10 @@ class SendinBlueTrackingWebhookView(SendinBlueBaseWebhookView):
) )
class SendinBlueInboundWebhookView(SendinBlueBaseWebhookView): class BrevoInboundWebhookView(BrevoBaseWebhookView):
"""Handler for SendinBlue inbound email webhooks""" """Handler for Brevo inbound email webhooks"""
# https://developers.brevo.com/docs/inbound-parse-webhooks#parsed-email-payload
signal = inbound signal = inbound
@@ -141,10 +143,10 @@ class SendinBlueInboundWebhookView(SendinBlueBaseWebhookView):
try: try:
esp_events = payload["items"] esp_events = payload["items"]
except KeyError: except KeyError:
# This is not n inbound webhook post # This is not an inbound webhook post
raise AnymailConfigurationError( raise AnymailConfigurationError(
"You seem to have set SendinBlue's *tracking* webhook URL " f"You seem to have set Brevo's *tracking* webhook URL "
"to Anymail's SendinBlue *inbound* webhook URL." f"to Anymail's {self.esp_name} *inbound* webhook URL."
) )
else: else:
return [self.esp_to_anymail_event(esp_event) for esp_event in esp_events] return [self.esp_to_anymail_event(esp_event) for esp_event in esp_events]
@@ -199,7 +201,7 @@ class SendinBlueInboundWebhookView(SendinBlueBaseWebhookView):
) )
def _fetch_attachment(self, attachment): def _fetch_attachment(self, attachment):
# Download attachment content from SendinBlue API. # Download attachment content from Brevo API.
# FUTURE: somehow defer download until attachment is accessed? # FUTURE: somehow defer download until attachment is accessed?
token = attachment["DownloadToken"] token = attachment["DownloadToken"]
url = urljoin(self.api_url, f"inbound/attachments/{quote(token, safe='')}") url = urljoin(self.api_url, f"inbound/attachments/{quote(token, safe='')}")

View File

@@ -4,14 +4,24 @@
Brevo Brevo
===== =====
.. Docs note: esps/sendinblue is redirected to esps/brevo in ReadTheDocs config.
Please preserve existing _sendinblue-* ref labels, so that redirected link
anchors work properly (in old links from external sites). E.g.:
an old link: https://anymail.dev/en/stable/esps/sendinblue#sendinblue-templates
redirects to: https://anymail.dev/en/stable/esps/brevo#sendinblue-templates
which is also: https://anymail.dev/en/stable/esps/brevo#brevo-templates
(There's no need to create _sendinblue-* duplicates of any new _brevo-* labels.)
Anymail integrates with the `Brevo`_ email service (formerly Sendinblue), using their `API v3`_. Anymail integrates with the `Brevo`_ email service (formerly Sendinblue), using their `API v3`_.
Brevo's transactional API does not support some basic email features, such as Brevo's transactional API does not support some basic email features, such as
inline images. Be sure to review the :ref:`limitations <sendinblue-limitations>` below. inline images. Be sure to review the :ref:`limitations <brevo-limitations>` below.
.. versionchanged:: 10.1 .. versionchanged:: 10.3
Brevo was called "Sendinblue" until May, 2023. To avoid unnecessary code changes, SendinBlue rebranded as Brevo in May, 2023. Anymail 10.3 uses the new
Anymail still uses the old name in code (settings, backend, webhook urls, etc.). name throughout its code; earlier versions used the old name. Code that
refers to "SendinBlue" should continue to work, but is now deprecated.
See :ref:`brevo-rename` for details.
.. important:: .. important::
@@ -36,14 +46,14 @@ To use Anymail's Brevo backend, set:
.. code-block:: python .. code-block:: python
EMAIL_BACKEND = "anymail.backends.sendinblue.EmailBackend" EMAIL_BACKEND = "anymail.backends.brevo.EmailBackend"
in your settings.py. in your settings.py.
.. setting:: ANYMAIL_SENDINBLUE_API_KEY .. setting:: ANYMAIL_BREVO_API_KEY
.. rubric:: SENDINBLUE_API_KEY .. rubric:: BREVO_API_KEY
The API key can be retrieved from your Brevo `SMTP & API settings`_ on the The API key can be retrieved from your Brevo `SMTP & API settings`_ on the
"API Keys" tab (don't try to use an SMTP key). Required. "API Keys" tab (don't try to use an SMTP key). Required.
@@ -55,23 +65,23 @@ Anymail. If you don't see a v3 key listed, use "Create a New API Key".)
ANYMAIL = { ANYMAIL = {
... ...
"SENDINBLUE_API_KEY": "<your v3 API key>", "BREVO_API_KEY": "<your v3 API key>",
} }
Anymail will also look for ``SENDINBLUE_API_KEY`` at the Anymail will also look for ``BREVO_API_KEY`` at the
root of the settings file if neither ``ANYMAIL["SENDINBLUE_API_KEY"]`` root of the settings file if neither ``ANYMAIL["BREVO_API_KEY"]``
nor ``ANYMAIL_SENDINBLUE_API_KEY`` is set. nor ``ANYMAIL_BREVO_API_KEY`` is set.
.. _SMTP & API settings: https://app.brevo.com/settings/keys/api .. _SMTP & API settings: https://app.brevo.com/settings/keys/api
.. setting:: ANYMAIL_SENDINBLUE_API_URL .. setting:: ANYMAIL_BREVO_API_URL
.. rubric:: SENDINBLUE_API_URL .. rubric:: BREVO_API_URL
The base url for calling the Brevo API. The base url for calling the Brevo API.
The default is ``SENDINBLUE_API_URL = "https://api.brevo.com/v3/"`` The default is ``BREVO_API_URL = "https://api.brevo.com/v3/"``
(It's unlikely you would need to change this.) (It's unlikely you would need to change this.)
.. versionchanged:: 10.1 .. versionchanged:: 10.1
@@ -79,6 +89,7 @@ The default is ``SENDINBLUE_API_URL = "https://api.brevo.com/v3/"``
Earlier Anymail releases used ``https://api.sendinblue.com/v3/``. Earlier Anymail releases used ``https://api.sendinblue.com/v3/``.
.. _brevo-esp-extra:
.. _sendinblue-esp-extra: .. _sendinblue-esp-extra:
esp_extra support esp_extra support
@@ -106,6 +117,7 @@ to apply it to all messages.)
.. _smtp/email API: https://developers.brevo.com/reference/sendtransacemail .. _smtp/email API: https://developers.brevo.com/reference/sendtransacemail
.. _brevo-limitations:
.. _sendinblue-limitations: .. _sendinblue-limitations:
Limitations and quirks Limitations and quirks
@@ -192,6 +204,7 @@ Brevo can handle.
on individual messages. on individual messages.
.. _brevo-templates:
.. _sendinblue-templates: .. _sendinblue-templates:
Batch sending/merge and ESP templates Batch sending/merge and ESP templates
@@ -267,9 +280,9 @@ message's headers: ``message.extra_headers = {"idempotencyKey": "...uuid..."}``.
.. caution:: .. caution::
**Sendinblue "old template language" not supported** **"Old template language" not supported**
Sendinblue once supported two different template styles: a "new" template Brevo once supported two different template styles: a "new" template
language that uses Django-like template syntax (with ``{{ param.NAME }}`` language that uses Django-like template syntax (with ``{{ param.NAME }}``
substitutions), and an "old" template language that used percent-delimited substitutions), and an "old" template language that used percent-delimited
``%NAME%`` substitutions. ``%NAME%`` substitutions.
@@ -299,17 +312,18 @@ message's headers: ``message.extra_headers = {"idempotencyKey": "...uuid..."}``.
https://help.brevo.com/hc/en-us/articles/360000991960 https://help.brevo.com/hc/en-us/articles/360000991960
.. _brevo-webhooks:
.. _sendinblue-webhooks: .. _sendinblue-webhooks:
Status tracking webhooks Status tracking webhooks
------------------------ ------------------------
If you are using Anymail's normalized :ref:`status tracking <event-tracking>`, add If you are using Anymail's normalized :ref:`status tracking <event-tracking>`, add
the url at Brevo's site under `Transactional > Email > Settings > Webhook`_. the url at Brevo's site under `Transactional > Email > Settings > Webhook`_.
The "URL to call" is: The "URL to call" is:
:samp:`https://{random}:{random}@{yoursite.example.com}/anymail/sendinblue/tracking/` :samp:`https://{random}:{random}@{yoursite.example.com}/anymail/brevo/tracking/`
* *random:random* is an :setting:`ANYMAIL_WEBHOOK_SECRET` shared secret * *random:random* is an :setting:`ANYMAIL_WEBHOOK_SECRET` shared secret
* *yoursite.example.com* is your Django site * *yoursite.example.com* is your Django site
@@ -336,10 +350,17 @@ For example, it's not uncommon to receive a "delivered" event before the corresp
The event's :attr:`~anymail.signals.AnymailTrackingEvent.esp_event` field will be The event's :attr:`~anymail.signals.AnymailTrackingEvent.esp_event` field will be
a `dict` of raw webhook data received from Brevo. a `dict` of raw webhook data received from Brevo.
.. versionchanged:: 10.3
Older Anymail versions used a tracking webhook URL containing "sendinblue" rather
than "brevo". The old URL will still work, but is deprecated. See :ref:`brevo-rename`
below.
.. _Transactional > Email > Settings > Webhook: https://app-smtp.brevo.com/webhook .. _Transactional > Email > Settings > Webhook: https://app-smtp.brevo.com/webhook
.. _brevo-inbound:
.. _sendinblue-inbound: .. _sendinblue-inbound:
Inbound webhook Inbound webhook
@@ -353,7 +374,7 @@ guide to enable inbound service and add Anymail's inbound webhook.
At the "Creating the webhook" step, set the ``"url"`` param to: At the "Creating the webhook" step, set the ``"url"`` param to:
:samp:`https://{random}:{random}@{yoursite.example.com}/anymail/sendinblue/inbound/` :samp:`https://{random}:{random}@{yoursite.example.com}/anymail/brevo/inbound/`
* *random:random* is an :setting:`ANYMAIL_WEBHOOK_SECRET` shared secret * *random:random* is an :setting:`ANYMAIL_WEBHOOK_SECRET` shared secret
* *yoursite.example.com* is your Django site * *yoursite.example.com* is your Django site
@@ -364,6 +385,12 @@ by entering your API key in "Header" field above the example, and then clicking
"Try It!". The `webhooks management APIs`_ and `inbound events list API`_ can "Try It!". The `webhooks management APIs`_ and `inbound events list API`_ can
be helpful for diagnosing inbound issues. be helpful for diagnosing inbound issues.
.. versionchanged:: 10.3
Older Anymail versions used an inbound webhook URL containing "sendinblue" rather
than "brevo". The old URL will still work, but is deprecated. See :ref:`brevo-rename`
below.
.. _Inbound parsing webhooks: .. _Inbound parsing webhooks:
https://developers.brevo.com/docs/inbound-parse-webhooks https://developers.brevo.com/docs/inbound-parse-webhooks
@@ -371,3 +398,101 @@ be helpful for diagnosing inbound issues.
https://developers.brevo.com/reference/getwebhooks-1 https://developers.brevo.com/reference/getwebhooks-1
.. _inbound events list API: .. _inbound events list API:
https://developers.brevo.com/reference/getinboundemailevents https://developers.brevo.com/reference/getinboundemailevents
.. _brevo-rename:
Updating code from SendinBlue to Brevo
--------------------------------------
SendinBlue rebranded as Brevo in May, 2023. Anymail 10.3 has switched
to the new name.
If your code refers to the old "sendinblue" name
(in :setting:`!EMAIL_BACKEND` and :setting:`!ANYMAIL` settings, :attr:`!esp_name`
checks, or elsewhere) you should update it to use "brevo" instead.
If you are using Anymail's tracking or inbound webhooks, you should
also update the webhook URLs you've configured at Brevo.
For compatibility, code and URLs using the old name are still functional in Anymail.
But they will generate deprecation warnings, and may be removed in a future release.
To update your code:
.. setting:: ANYMAIL_SENDINBLUE_API_KEY
.. setting:: ANYMAIL_SENDINBLUE_API_URL
1. In your settings.py, update the :setting:`!EMAIL_BACKEND`
and rename any ``"SENDINBLUE_..."`` settings to ``"BREVO_..."``:
.. code-block:: diff
- EMAIL_BACKEND = "anymail.backends.sendinblue.EmailBackend" # old
+ EMAIL_BACKEND = "anymail.backends.brevo.EmailBackend" # new
ANYMAIL = {
...
- "SENDINBLUE_API_KEY": "<your v3 API key>", # old
+ "BREVO_API_KEY": "<your v3 API key>", # new
# (Also change "SENDINBLUE_API_URL" to "BREVO_API_URL" if present)
# If you are using Brevo-specific global send defaults, change:
- "SENDINBLUE_SEND_DEFAULTS" = {...}, # old
+ "BREVO_SEND_DEFAULTS" = {...}, # new
}
2. If you are using Anymail's status tracking webhook,
go to Brevo's dashboard (under `Transactional > Email > Settings > Webhook`_),
and change the end or the URL from ``.../anymail/sendinblue/tracking/``
to ``.../anymail/brevo/tracking/``. (Or use the code below to automate this.)
In your :ref:`tracking signal receiver function <signal-receivers>`,
if you are examining the ``esp_name`` parameter, the name will change
once you have updated the webhook URL. If you had been checking
whether ``esp_name == "SendinBlue"``, change that to check if
``esp_name == "Brevo"``.
3. If you are using Anymail's inbound handling, update the inbound webhook
URL to change ``.../anymail/sendinblue/inbound/`` to ``.../anymail/brevo/inbound/``.
You will need to use Brevo's webhooks API to make the change---see below.
In your :ref:`inbound signal receiver function <inbound-signal-receivers>`,
if you are examining the ``esp_name`` parameter, the name will change
once you have updated the webhook URL. If you had been checking
whether ``esp_name == "SendinBlue"``, change that to check if
``esp_name == "Brevo"``.
That should be everything, but to double check you may want to search your
code for any remaining references to "sendinblue" (case-insensitive).
(E.g., ``grep -r -i sendinblue``.)
To update both the tracking and inbound webhook URLs using Brevo's `webhooks API`_,
you could run something like this Python code:
.. code-block:: python
# Update Brevo webhook URLs to replace "anymail/sendinblue" with "anymail/brevo".
import requests
BREVO_API_KEY = "<your API key>"
headers = {
"accept": "application/json",
"api-key": BREVO_API_KEY,
}
response = requests.get("https://api.brevo.com/v3/webhooks", headers=headers)
response.raise_for_status()
webhooks = response.json()
for webhook in webhooks:
if "anymail/sendinblue" in webhook["url"]:
response = requests.put(
f"https://api.brevo.com/v3/webhooks/{webhook['id']}",
headers=headers,
json={
"url": webhook["url"].replace("anymail/sendinblue", "anymail/brevo")
}
)
response.raise_for_status()
.. _webhooks API: https://developers.brevo.com/reference/updatewebhook-1

View File

@@ -66,6 +66,7 @@ dependencies = [
# (For simplicity, requests is included in the base dependencies.) # (For simplicity, requests is included in the base dependencies.)
# (Do not use underscores in extra names: they get normalized to hyphens.) # (Do not use underscores in extra names: they get normalized to hyphens.)
amazon-ses = ["boto3"] amazon-ses = ["boto3"]
brevo = []
mailersend = [] mailersend = []
mailgun = [] mailgun = []
mailjet = [] mailjet = []

View File

@@ -32,19 +32,16 @@ from .utils import (
) )
@tag("sendinblue") @tag("brevo")
@override_settings( @override_settings(
EMAIL_BACKEND="anymail.backends.sendinblue.EmailBackend", EMAIL_BACKEND="anymail.backends.brevo.EmailBackend",
ANYMAIL={"SENDINBLUE_API_KEY": "test_api_key"}, ANYMAIL={"BREVO_API_KEY": "test_api_key"},
) )
class SendinBlueBackendMockAPITestCase(RequestsBackendMockAPITestCase): class BrevoBackendMockAPITestCase(RequestsBackendMockAPITestCase):
# SendinBlue v3 success responses are empty
DEFAULT_RAW_RESPONSE = ( DEFAULT_RAW_RESPONSE = (
b'{"messageId":"<201801020304.1234567890@smtp-relay.mailin.fr>"}' b'{"messageId":"<201801020304.1234567890@smtp-relay.mailin.fr>"}'
) )
DEFAULT_STATUS_CODE = ( DEFAULT_STATUS_CODE = 201 # Brevo v3 uses '201 Created' for success (in most cases)
201 # SendinBlue v3 uses '201 Created' for success (in most cases)
)
def setUp(self): def setUp(self):
super().setUp() super().setUp()
@@ -54,8 +51,8 @@ class SendinBlueBackendMockAPITestCase(RequestsBackendMockAPITestCase):
) )
@tag("sendinblue") @tag("brevo")
class SendinBlueBackendStandardEmailTests(SendinBlueBackendMockAPITestCase): class BrevoBackendStandardEmailTests(BrevoBackendMockAPITestCase):
"""Test backend support for Django standard email features""" """Test backend support for Django standard email features"""
def test_send_mail(self): def test_send_mail(self):
@@ -204,7 +201,7 @@ class SendinBlueBackendStandardEmailTests(SendinBlueBackendMockAPITestCase):
) )
def test_multiple_reply_to(self): def test_multiple_reply_to(self):
# SendinBlue v3 only allows a single reply address # Brevo v3 only allows a single reply address
self.message.reply_to = [ self.message.reply_to = [
'"Reply recipient" <reply@example.com', '"Reply recipient" <reply@example.com',
"reply2@example.com", "reply2@example.com",
@@ -274,7 +271,7 @@ class SendinBlueBackendStandardEmailTests(SendinBlueBackendMockAPITestCase):
) )
def test_embedded_images(self): def test_embedded_images(self):
# SendinBlue doesn't support inline image # Brevo doesn't support inline image
# inline image are just added as a content attachment # inline image are just added as a content attachment
image_filename = SAMPLE_IMAGE_FILENAME image_filename = SAMPLE_IMAGE_FILENAME
@@ -339,7 +336,7 @@ class SendinBlueBackendStandardEmailTests(SendinBlueBackendMockAPITestCase):
def test_api_failure(self): def test_api_failure(self):
self.set_mock_response(status_code=400) self.set_mock_response(status_code=400)
with self.assertRaisesMessage(AnymailAPIError, "SendinBlue API response 400"): with self.assertRaisesMessage(AnymailAPIError, "Brevo API response 400"):
mail.send_mail("Subject", "Body", "from@example.com", ["to@example.com"]) mail.send_mail("Subject", "Body", "from@example.com", ["to@example.com"])
# Make sure fail_silently is respected # Make sure fail_silently is respected
@@ -373,12 +370,12 @@ class SendinBlueBackendStandardEmailTests(SendinBlueBackendMockAPITestCase):
self.message.send() self.message.send()
@tag("sendinblue") @tag("brevo")
class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase): class BrevoBackendAnymailFeatureTests(BrevoBackendMockAPITestCase):
"""Test backend support for Anymail added features""" """Test backend support for Anymail added features"""
def test_envelope_sender(self): def test_envelope_sender(self):
# SendinBlue does not have a way to change envelope sender. # Brevo does not have a way to change envelope sender.
self.message.envelope_sender = "anything@bounces.example.com" self.message.envelope_sender = "anything@bounces.example.com"
with self.assertRaisesMessage(AnymailUnsupportedFeature, "envelope_sender"): with self.assertRaisesMessage(AnymailUnsupportedFeature, "envelope_sender"):
self.message.send() self.message.send()
@@ -459,7 +456,7 @@ class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase):
self.message.send() self.message.send()
def test_template_id(self): def test_template_id(self):
# subject, body, and from_email must be None for SendinBlue template send: # subject, body, and from_email must be None for Brevo template send:
message = mail.EmailMessage( message = mail.EmailMessage(
subject="My Subject", subject="My Subject",
body=None, body=None,
@@ -470,7 +467,7 @@ class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase):
bcc=["Recipient <bcc@example.com>"], bcc=["Recipient <bcc@example.com>"],
reply_to=["Recipient <reply@example.com>"], reply_to=["Recipient <reply@example.com>"],
) )
# SendinBlue uses per-account numeric ID to identify templates: # Brevo uses per-account numeric ID to identify templates:
message.template_id = 12 message.template_id = 12
message.send() message.send()
data = self.get_api_call_json() data = self.get_api_call_json()
@@ -603,7 +600,7 @@ class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase):
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
def test_send_attaches_anymail_status(self): def test_send_attaches_anymail_status(self):
"""The anymail_status should be attached to the message when it is sent""" """The anymail_status should be attached to the message when it is sent"""
# the DEFAULT_RAW_RESPONSE above is the *only* success response SendinBlue # the DEFAULT_RAW_RESPONSE above is the *only* success response Brevo
# returns, so no need to override it here # returns, so no need to override it here
msg = mail.EmailMessage( msg = mail.EmailMessage(
"Subject", "Subject",
@@ -652,39 +649,37 @@ class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase):
err = cm.exception err = cm.exception
self.assertIsInstance(err, TypeError) # compatibility with json.dumps self.assertIsInstance(err, TypeError) # compatibility with json.dumps
# our added context: # our added context:
self.assertIn("Don't know how to send this data to SendinBlue", str(err)) self.assertIn("Don't know how to send this data to Brevo", str(err))
# original message # original message
self.assertRegex(str(err), r"Decimal.*is not JSON serializable") self.assertRegex(str(err), r"Decimal.*is not JSON serializable")
@tag("sendinblue") @tag("brevo")
class SendinBlueBackendRecipientsRefusedTests(SendinBlueBackendMockAPITestCase): class BrevoBackendRecipientsRefusedTests(BrevoBackendMockAPITestCase):
""" """
Should raise AnymailRecipientsRefused when *all* recipients are rejected or invalid Should raise AnymailRecipientsRefused when *all* recipients are rejected or invalid
""" """
# SendinBlue doesn't check email bounce or complaint lists at time of send -- # Brevo 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" # it always just queues the message. You'll need to listen for the "rejected"
# and "failed" events to detect refused recipients. # and "failed" events to detect refused recipients.
pass # not applicable to this backend pass # not applicable to this backend
@tag("sendinblue") @tag("brevo")
class SendinBlueBackendSessionSharingTestCase( class BrevoBackendSessionSharingTestCase(
SessionSharingTestCases, SendinBlueBackendMockAPITestCase SessionSharingTestCases, BrevoBackendMockAPITestCase
): ):
"""Requests session sharing tests""" """Requests session sharing tests"""
pass # tests are defined in SessionSharingTestCases pass # tests are defined in SessionSharingTestCases
@tag("sendinblue") @tag("brevo")
@override_settings(EMAIL_BACKEND="anymail.backends.sendinblue.EmailBackend") @override_settings(EMAIL_BACKEND="anymail.backends.brevo.EmailBackend")
class SendinBlueBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase): class BrevoBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase):
"""Test ESP backend without required settings in place""" """Test ESP backend without required settings in place"""
def test_missing_auth(self): def test_missing_auth(self):
with self.assertRaisesRegex( with self.assertRaisesRegex(AnymailConfigurationError, r"\bBREVO_API_KEY\b"):
AnymailConfigurationError, r"\bSENDINBLUE_API_KEY\b"
):
mail.send_mail("Subject", "Message", "from@example.com", ["to@example.com"]) mail.send_mail("Subject", "Message", "from@example.com", ["to@example.com"])

View File

@@ -7,15 +7,15 @@ from responses.matchers import header_matcher
from anymail.exceptions import AnymailConfigurationError from anymail.exceptions import AnymailConfigurationError
from anymail.inbound import AnymailInboundMessage from anymail.inbound import AnymailInboundMessage
from anymail.signals import AnymailInboundEvent from anymail.signals import AnymailInboundEvent
from anymail.webhooks.sendinblue import SendinBlueInboundWebhookView from anymail.webhooks.brevo import BrevoInboundWebhookView
from .utils import sample_email_content, sample_image_content from .utils import sample_email_content, sample_image_content
from .webhook_cases import WebhookTestCase from .webhook_cases import WebhookTestCase
@tag("sendinblue") @tag("brevo")
@override_settings(ANYMAIL_SENDINBLUE_API_KEY="test-api-key") @override_settings(ANYMAIL_BREVO_API_KEY="test-api-key")
class SendinBlueInboundTestCase(WebhookTestCase): class BrevoInboundTestCase(WebhookTestCase):
def test_inbound_basics(self): def test_inbound_basics(self):
# Actual (sanitized) Brevo inbound message payload 7/2023 # Actual (sanitized) Brevo inbound message payload 7/2023
raw_event = { raw_event = {
@@ -54,16 +54,16 @@ class SendinBlueInboundTestCase(WebhookTestCase):
} }
response = self.client.post( response = self.client.post(
"/anymail/sendinblue/inbound/", "/anymail/brevo/inbound/",
content_type="application/json", content_type="application/json",
data={"items": [raw_event]}, data={"items": [raw_event]},
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with( kwargs = self.assert_handler_called_once_with(
self.inbound_handler, self.inbound_handler,
sender=SendinBlueInboundWebhookView, sender=BrevoInboundWebhookView,
event=ANY, event=ANY,
esp_name="SendinBlue", esp_name="Brevo",
) )
# AnymailInboundEvent # AnymailInboundEvent
event = kwargs["event"] event = kwargs["event"]
@@ -123,15 +123,15 @@ class SendinBlueInboundTestCase(WebhookTestCase):
} }
} }
self.client.post( self.client.post(
"/anymail/sendinblue/inbound/", "/anymail/brevo/inbound/",
content_type="application/json", content_type="application/json",
data={"items": [raw_event]}, data={"items": [raw_event]},
) )
kwargs = self.assert_handler_called_once_with( kwargs = self.assert_handler_called_once_with(
self.inbound_handler, self.inbound_handler,
sender=SendinBlueInboundWebhookView, sender=BrevoInboundWebhookView,
event=ANY, event=ANY,
esp_name="SendinBlue", esp_name="Brevo",
) )
event = kwargs["event"] event = kwargs["event"]
message = event.message message = event.message
@@ -203,16 +203,16 @@ class SendinBlueInboundTestCase(WebhookTestCase):
) )
response = self.client.post( response = self.client.post(
"/anymail/sendinblue/inbound/", "/anymail/brevo/inbound/",
content_type="application/json", content_type="application/json",
data={"items": [raw_event]}, data={"items": [raw_event]},
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with( kwargs = self.assert_handler_called_once_with(
self.inbound_handler, self.inbound_handler,
sender=SendinBlueInboundWebhookView, sender=BrevoInboundWebhookView,
event=ANY, event=ANY,
esp_name="SendinBlue", esp_name="Brevo",
) )
event = kwargs["event"] event = kwargs["event"]
message = event.message message = event.message
@@ -235,12 +235,12 @@ class SendinBlueInboundTestCase(WebhookTestCase):
def test_misconfigured_tracking(self): def test_misconfigured_tracking(self):
errmsg = ( errmsg = (
"You seem to have set SendinBlue's *tracking* webhook URL" "You seem to have set Brevo's *tracking* webhook URL"
" to Anymail's SendinBlue *inbound* webhook URL." " to Anymail's Brevo *inbound* webhook URL."
) )
with self.assertRaisesMessage(AnymailConfigurationError, errmsg): with self.assertRaisesMessage(AnymailConfigurationError, errmsg):
self.client.post( self.client.post(
"/anymail/sendinblue/inbound/", "/anymail/brevo/inbound/",
content_type="application/json", content_type="application/json",
data={"event": "delivered"}, data={"event": "delivered"},
) )

View File

@@ -10,39 +10,39 @@ from anymail.message import AnymailMessage
from .utils import AnymailTestMixin from .utils import AnymailTestMixin
ANYMAIL_TEST_SENDINBLUE_API_KEY = os.getenv("ANYMAIL_TEST_SENDINBLUE_API_KEY") ANYMAIL_TEST_BREVO_API_KEY = os.getenv("ANYMAIL_TEST_BREVO_API_KEY")
ANYMAIL_TEST_SENDINBLUE_DOMAIN = os.getenv("ANYMAIL_TEST_SENDINBLUE_DOMAIN") ANYMAIL_TEST_BREVO_DOMAIN = os.getenv("ANYMAIL_TEST_BREVO_DOMAIN")
@tag("sendinblue", "live") @tag("brevo", "live")
@unittest.skipUnless( @unittest.skipUnless(
ANYMAIL_TEST_SENDINBLUE_API_KEY and ANYMAIL_TEST_SENDINBLUE_DOMAIN, ANYMAIL_TEST_BREVO_API_KEY and ANYMAIL_TEST_BREVO_DOMAIN,
"Set ANYMAIL_TEST_SENDINBLUE_API_KEY and ANYMAIL_TEST_SENDINBLUE_DOMAIN " "Set ANYMAIL_TEST_BREVO_API_KEY and ANYMAIL_TEST_BREVO_DOMAIN "
"environment variables to run SendinBlue integration tests", "environment variables to run Brevo integration tests",
) )
@override_settings( @override_settings(
ANYMAIL_SENDINBLUE_API_KEY=ANYMAIL_TEST_SENDINBLUE_API_KEY, ANYMAIL_BREVO_API_KEY=ANYMAIL_TEST_BREVO_API_KEY,
ANYMAIL_SENDINBLUE_SEND_DEFAULTS=dict(), ANYMAIL_BREVO_SEND_DEFAULTS=dict(),
EMAIL_BACKEND="anymail.backends.sendinblue.EmailBackend", EMAIL_BACKEND="anymail.backends.brevo.EmailBackend",
) )
class SendinBlueBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): class BrevoBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"""SendinBlue v3 API integration tests """Brevo v3 API integration tests
SendinBlue doesn't have sandbox so these tests run Brevo doesn't have sandbox so these tests run
against the **live** SendinBlue API, using the against the **live** Brevo API, using the
environment variable `ANYMAIL_TEST_SENDINBLUE_API_KEY` as the API key, environment variable `ANYMAIL_TEST_BREVO_API_KEY` as the API key,
and `ANYMAIL_TEST_SENDINBLUE_DOMAIN` to construct sender addresses. and `ANYMAIL_TEST_BREVO_DOMAIN` to construct sender addresses.
If those variables are not set, these tests won't run. If those variables are not set, these tests won't run.
https://developers.sendinblue.com/docs/faq#section-how-can-i-test-the-api- https://developers.brevo.com/docs/faq#how-can-i-test-the-api
""" """
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.from_email = "from@%s" % ANYMAIL_TEST_SENDINBLUE_DOMAIN self.from_email = "from@%s" % ANYMAIL_TEST_BREVO_DOMAIN
self.message = AnymailMessage( self.message = AnymailMessage(
"Anymail SendinBlue integration test", "Anymail Brevo integration test",
"Text content", "Text content",
self.from_email, self.from_email,
["test+to1@anymail.dev"], ["test+to1@anymail.dev"],
@@ -50,7 +50,7 @@ class SendinBlueBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
self.message.attach_alternative("<p>HTML content</p>", "text/html") self.message.attach_alternative("<p>HTML content</p>", "text/html")
def test_simple_send(self): def test_simple_send(self):
# Example of getting the SendinBlue send status and message id from the message # Example of getting the Brevo send status and message id from the message
sent_count = self.message.send() sent_count = self.message.send()
self.assertEqual(sent_count, 1) self.assertEqual(sent_count, 1)
@@ -58,7 +58,7 @@ class SendinBlueBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
sent_status = anymail_status.recipients["test+to1@anymail.dev"].status sent_status = anymail_status.recipients["test+to1@anymail.dev"].status
message_id = anymail_status.recipients["test+to1@anymail.dev"].message_id message_id = anymail_status.recipients["test+to1@anymail.dev"].message_id
self.assertEqual(sent_status, "queued") # SendinBlue always queues self.assertEqual(sent_status, "queued") # Brevo always queues
# Message-ID can be ...@smtp-relay.mail.fr or .sendinblue.com: # Message-ID can be ...@smtp-relay.mail.fr or .sendinblue.com:
self.assertRegex(message_id, r"\<.+@.+\>") self.assertRegex(message_id, r"\<.+@.+\>")
# set of all recipient statuses: # set of all recipient statuses:
@@ -68,27 +68,27 @@ class SendinBlueBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
def test_all_options(self): def test_all_options(self):
send_at = datetime.now() + timedelta(minutes=2) send_at = datetime.now() + timedelta(minutes=2)
message = AnymailMessage( message = AnymailMessage(
subject="Anymail SendinBlue all-options integration test", subject="Anymail Brevo all-options integration test",
body="This is the text body", body="This is the text body",
from_email=formataddr(("Test From, with comma", self.from_email)), from_email=formataddr(("Test From, with comma", self.from_email)),
to=["test+to1@anymail.dev", '"Recipient 2, OK?" <test+to2@anymail.dev>'], to=["test+to1@anymail.dev", '"Recipient 2, OK?" <test+to2@anymail.dev>'],
cc=["test+cc1@anymail.dev", "Copy 2 <test+cc2@anymail.dev>"], cc=["test+cc1@anymail.dev", "Copy 2 <test+cc2@anymail.dev>"],
bcc=["test+bcc1@anymail.dev", "Blind Copy 2 <test+bcc2@anymail.dev>"], bcc=["test+bcc1@anymail.dev", "Blind Copy 2 <test+bcc2@anymail.dev>"],
# SendinBlue API v3 only supports single reply-to # Brevo API v3 only supports single reply-to
reply_to=['"Reply, with comma" <reply@example.com>'], reply_to=['"Reply, with comma" <reply@example.com>'],
headers={"X-Anymail-Test": "value", "X-Anymail-Count": 3}, headers={"X-Anymail-Test": "value", "X-Anymail-Count": 3},
metadata={"meta1": "simple string", "meta2": 2}, metadata={"meta1": "simple string", "meta2": 2},
send_at=send_at, send_at=send_at,
tags=["tag 1", "tag 2"], tags=["tag 1", "tag 2"],
) )
# SendinBlue requires an HTML body: # Brevo requires an HTML body:
message.attach_alternative("<p>HTML content</p>", "text/html") message.attach_alternative("<p>HTML content</p>", "text/html")
message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain") message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain")
message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv") message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv")
message.send() message.send()
# SendinBlue always queues: # Brevo always queues:
self.assertEqual(message.anymail_status.status, {"queued"}) self.assertEqual(message.anymail_status.status, {"queued"})
self.assertRegex(message.anymail_status.message_id, r"\<.+@.+\>") self.assertRegex(message.anymail_status.message_id, r"\<.+@.+\>")
@@ -118,7 +118,7 @@ class SendinBlueBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
message.attach("attachment1.txt", "Here is some\ntext", "text/plain") message.attach("attachment1.txt", "Here is some\ntext", "text/plain")
message.send() message.send()
# SendinBlue always queues: # Brevo always queues:
self.assertEqual(message.anymail_status.status, {"queued"}) self.assertEqual(message.anymail_status.status, {"queued"})
recipient_status = message.anymail_status.recipients recipient_status = message.anymail_status.recipients
self.assertEqual(recipient_status["test+to1@anymail.dev"].status, "queued") self.assertEqual(recipient_status["test+to1@anymail.dev"].status, "queued")
@@ -135,11 +135,11 @@ class SendinBlueBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
recipient_status["test+to2@anymail.dev"].message_id, recipient_status["test+to2@anymail.dev"].message_id,
) )
@override_settings(ANYMAIL_SENDINBLUE_API_KEY="Hey, that's not an API key!") @override_settings(ANYMAIL_BREVO_API_KEY="Hey, that's not an API key!")
def test_invalid_api_key(self): def test_invalid_api_key(self):
with self.assertRaises(AnymailAPIError) as cm: with self.assertRaises(AnymailAPIError) as cm:
self.message.send() self.message.send()
err = cm.exception err = cm.exception
self.assertEqual(err.status_code, 401) self.assertEqual(err.status_code, 401)
# Make sure the exception message includes SendinBlue's response: # Make sure the exception message includes Brevo's response:
self.assertIn("Key not found", str(err)) self.assertIn("Key not found", str(err))

View File

@@ -6,16 +6,16 @@ from django.test import tag
from anymail.exceptions import AnymailConfigurationError from anymail.exceptions import AnymailConfigurationError
from anymail.signals import AnymailTrackingEvent from anymail.signals import AnymailTrackingEvent
from anymail.webhooks.sendinblue import SendinBlueTrackingWebhookView from anymail.webhooks.brevo import BrevoTrackingWebhookView
from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase
@tag("sendinblue") @tag("brevo")
class SendinBlueWebhookSecurityTestCase(WebhookBasicAuthTestCase): class BrevoWebhookSecurityTestCase(WebhookBasicAuthTestCase):
def call_webhook(self): def call_webhook(self):
return self.client.post( return self.client.post(
"/anymail/sendinblue/tracking/", "/anymail/brevo/tracking/",
content_type="application/json", content_type="application/json",
data=json.dumps({}), data=json.dumps({}),
) )
@@ -23,23 +23,22 @@ class SendinBlueWebhookSecurityTestCase(WebhookBasicAuthTestCase):
# Actual tests are in WebhookBasicAuthTestCase # Actual tests are in WebhookBasicAuthTestCase
@tag("sendinblue") @tag("brevo")
class SendinBlueDeliveryTestCase(WebhookTestCase): class BrevoDeliveryTestCase(WebhookTestCase):
# SendinBlue's webhook payload data is partially documented at # Brevo's webhook payload data is documented at
# https://help.sendinblue.com/hc/en-us/articles/360007666479, # https://developers.brevo.com/docs/transactional-webhooks.
# but it's not completely up to date.
# The payloads below were obtained through live testing. # The payloads below were obtained through live testing.
def test_sent_event(self): def test_sent_event(self):
raw_event = { raw_event = {
"event": "request", "event": "request",
"email": "recipient@example.com", "email": "recipient@example.com",
"id": 9999999, # this seems to be SendinBlue account id (not an event id) "id": 9999999, # this seems to be Brevo account id (not an event id)
"message-id": "<201803062010.27287306012@smtp-relay.mailin.fr>", "message-id": "<201803062010.27287306012@smtp-relay.mailin.fr>",
"subject": "Test subject", "subject": "Test subject",
# From a message sent at 2018-03-06 11:10:23-08:00 # From a message sent at 2018-03-06 11:10:23-08:00
# (2018-03-06 19:10:23+00:00)... # (2018-03-06 19:10:23+00:00)...
"date": "2018-03-06 11:10:23", # tz from SendinBlue account's preferences "date": "2018-03-06 11:10:23", # tz from Brevo account's preferences
"ts": 1520331023, # 2018-03-06 10:10:23 -- what time zone is this? "ts": 1520331023, # 2018-03-06 10:10:23 -- what time zone is this?
"ts_event": 1520331023, # unclear if this ever differs from "ts" "ts_event": 1520331023, # unclear if this ever differs from "ts"
"ts_epoch": 1520363423000, # 2018-03-06 19:10:23.000+00:00 -- UTC (msec) "ts_epoch": 1520363423000, # 2018-03-06 19:10:23.000+00:00 -- UTC (msec)
@@ -55,16 +54,16 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
"sending_ip": "333.33.33.33", "sending_ip": "333.33.33.33",
} }
response = self.client.post( response = self.client.post(
"/anymail/sendinblue/tracking/", "/anymail/brevo/tracking/",
content_type="application/json", content_type="application/json",
data=json.dumps(raw_event), data=json.dumps(raw_event),
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with( kwargs = self.assert_handler_called_once_with(
self.tracking_handler, self.tracking_handler,
sender=SendinBlueTrackingWebhookView, sender=BrevoTrackingWebhookView,
event=ANY, event=ANY,
esp_name="SendinBlue", esp_name="Brevo",
) )
event = kwargs["event"] event = kwargs["event"]
self.assertIsInstance(event, AnymailTrackingEvent) self.assertIsInstance(event, AnymailTrackingEvent)
@@ -77,7 +76,7 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
self.assertEqual( self.assertEqual(
event.message_id, "<201803062010.27287306012@smtp-relay.mailin.fr>" event.message_id, "<201803062010.27287306012@smtp-relay.mailin.fr>"
) )
# SendinBlue does not provide a unique event id: # Brevo does not provide a unique event id:
self.assertIsNone(event.event_id) self.assertIsNone(event.event_id)
self.assertEqual(event.recipient, "recipient@example.com") self.assertEqual(event.recipient, "recipient@example.com")
self.assertEqual(event.metadata, {"meta": "data"}) self.assertEqual(event.metadata, {"meta": "data"})
@@ -93,16 +92,16 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
"message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>", "message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>",
} }
response = self.client.post( response = self.client.post(
"/anymail/sendinblue/tracking/", "/anymail/brevo/tracking/",
content_type="application/json", content_type="application/json",
data=json.dumps(raw_event), data=json.dumps(raw_event),
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with( kwargs = self.assert_handler_called_once_with(
self.tracking_handler, self.tracking_handler,
sender=SendinBlueTrackingWebhookView, sender=BrevoTrackingWebhookView,
event=ANY, event=ANY,
esp_name="SendinBlue", esp_name="Brevo",
) )
event = kwargs["event"] event = kwargs["event"]
self.assertIsInstance(event, AnymailTrackingEvent) self.assertIsInstance(event, AnymailTrackingEvent)
@@ -128,16 +127,16 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
"tag": "header-tag", "tag": "header-tag",
} }
response = self.client.post( response = self.client.post(
"/anymail/sendinblue/tracking/", "/anymail/brevo/tracking/",
content_type="application/json", content_type="application/json",
data=json.dumps(raw_event), data=json.dumps(raw_event),
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with( kwargs = self.assert_handler_called_once_with(
self.tracking_handler, self.tracking_handler,
sender=SendinBlueTrackingWebhookView, sender=BrevoTrackingWebhookView,
event=ANY, event=ANY,
esp_name="SendinBlue", esp_name="Brevo",
) )
event = kwargs["event"] event = kwargs["event"]
self.assertEqual(event.event_type, "bounced") self.assertEqual(event.event_type, "bounced")
@@ -158,16 +157,16 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
"reason": "undefined Unable to find MX of domain no-mx.example.com", "reason": "undefined Unable to find MX of domain no-mx.example.com",
} }
response = self.client.post( response = self.client.post(
"/anymail/sendinblue/tracking/", "/anymail/brevo/tracking/",
content_type="application/json", content_type="application/json",
data=json.dumps(raw_event), data=json.dumps(raw_event),
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with( kwargs = self.assert_handler_called_once_with(
self.tracking_handler, self.tracking_handler,
sender=SendinBlueTrackingWebhookView, sender=BrevoTrackingWebhookView,
event=ANY, event=ANY,
esp_name="SendinBlue", esp_name="Brevo",
) )
event = kwargs["event"] event = kwargs["event"]
self.assertEqual(event.event_type, "bounced") self.assertEqual(event.event_type, "bounced")
@@ -188,16 +187,16 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
"reason": "blocked : due to blacklist user", "reason": "blocked : due to blacklist user",
} }
response = self.client.post( response = self.client.post(
"/anymail/sendinblue/tracking/", "/anymail/brevo/tracking/",
content_type="application/json", content_type="application/json",
data=json.dumps(raw_event), data=json.dumps(raw_event),
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with( kwargs = self.assert_handler_called_once_with(
self.tracking_handler, self.tracking_handler,
sender=SendinBlueTrackingWebhookView, sender=BrevoTrackingWebhookView,
event=ANY, event=ANY,
esp_name="SendinBlue", esp_name="Brevo",
) )
event = kwargs["event"] event = kwargs["event"]
self.assertEqual(event.event_type, "rejected") self.assertEqual(event.event_type, "rejected")
@@ -214,16 +213,16 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
"message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>", "message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>",
} }
response = self.client.post( response = self.client.post(
"/anymail/sendinblue/tracking/", "/anymail/brevo/tracking/",
content_type="application/json", content_type="application/json",
data=json.dumps(raw_event), data=json.dumps(raw_event),
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with( kwargs = self.assert_handler_called_once_with(
self.tracking_handler, self.tracking_handler,
sender=SendinBlueTrackingWebhookView, sender=BrevoTrackingWebhookView,
event=ANY, event=ANY,
esp_name="SendinBlue", esp_name="Brevo",
) )
event = kwargs["event"] event = kwargs["event"]
self.assertEqual(event.event_type, "complained") self.assertEqual(event.event_type, "complained")
@@ -231,7 +230,7 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
def test_invalid_email(self): def test_invalid_email(self):
# "If a ISP again indicated us that the email is not valid or if we discovered # "If a ISP again indicated us that the email is not valid or if we discovered
# that the email is not valid." (unclear whether this error originates with the # that the email is not valid." (unclear whether this error originates with the
# receiving MTA or with SendinBlue pre-send) (haven't observed "invalid_email" # receiving MTA or with Brevo pre-send) (haven't observed "invalid_email"
# event in actual testing; payload below is a guess) # event in actual testing; payload below is a guess)
raw_event = { raw_event = {
"event": "invalid_email", "event": "invalid_email",
@@ -241,16 +240,16 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
"reason": "(guessing invalid_email includes a reason)", "reason": "(guessing invalid_email includes a reason)",
} }
response = self.client.post( response = self.client.post(
"/anymail/sendinblue/tracking/", "/anymail/brevo/tracking/",
content_type="application/json", content_type="application/json",
data=json.dumps(raw_event), data=json.dumps(raw_event),
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with( kwargs = self.assert_handler_called_once_with(
self.tracking_handler, self.tracking_handler,
sender=SendinBlueTrackingWebhookView, sender=BrevoTrackingWebhookView,
event=ANY, event=ANY,
esp_name="SendinBlue", esp_name="Brevo",
) )
event = kwargs["event"] event = kwargs["event"]
self.assertEqual(event.event_type, "bounced") self.assertEqual(event.event_type, "bounced")
@@ -262,7 +261,7 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
def test_deferred_event(self): def test_deferred_event(self):
# Note: the example below is an actual event capture (with 'example.com' # Note: the example below is an actual event capture (with 'example.com'
# substituted for the real receiving domain). It's pretty clearly a bounce, not # substituted for the real receiving domain). It's pretty clearly a bounce, not
# a deferral. It looks like SendinBlue mis-categorizes this SMTP response code. # a deferral. It looks like Brevo mis-categorizes this SMTP response code.
raw_event = { raw_event = {
"event": "deferred", "event": "deferred",
"email": "notauser@example.com", "email": "notauser@example.com",
@@ -272,16 +271,16 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
" address rejected: User unknown in virtual alias table", " address rejected: User unknown in virtual alias table",
} }
response = self.client.post( response = self.client.post(
"/anymail/sendinblue/tracking/", "/anymail/brevo/tracking/",
content_type="application/json", content_type="application/json",
data=json.dumps(raw_event), data=json.dumps(raw_event),
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with( kwargs = self.assert_handler_called_once_with(
self.tracking_handler, self.tracking_handler,
sender=SendinBlueTrackingWebhookView, sender=BrevoTrackingWebhookView,
event=ANY, event=ANY,
esp_name="SendinBlue", esp_name="Brevo",
) )
event = kwargs["event"] event = kwargs["event"]
self.assertEqual(event.event_type, "deferred") self.assertEqual(event.event_type, "deferred")
@@ -294,7 +293,7 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
) )
def test_opened_event(self): def test_opened_event(self):
# SendinBlue delivers 'unique_opened' only on the first open, and 'opened' # Brevo delivers 'unique_opened' only on the first open, and 'opened'
# only on the second or later tracking pixel views. (But they used to deliver # only on the second or later tracking pixel views. (But they used to deliver
# both on the first open.) # both on the first open.)
raw_event = { raw_event = {
@@ -304,20 +303,20 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
"message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>", "message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>",
} }
response = self.client.post( response = self.client.post(
"/anymail/sendinblue/tracking/", "/anymail/brevo/tracking/",
content_type="application/json", content_type="application/json",
data=json.dumps(raw_event), data=json.dumps(raw_event),
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with( kwargs = self.assert_handler_called_once_with(
self.tracking_handler, self.tracking_handler,
sender=SendinBlueTrackingWebhookView, sender=BrevoTrackingWebhookView,
event=ANY, event=ANY,
esp_name="SendinBlue", esp_name="Brevo",
) )
event = kwargs["event"] event = kwargs["event"]
self.assertEqual(event.event_type, "opened") self.assertEqual(event.event_type, "opened")
self.assertIsNone(event.user_agent) # SendinBlue doesn't report user agent self.assertIsNone(event.user_agent) # Brevo doesn't report user agent
def test_unique_opened_event(self): def test_unique_opened_event(self):
# See note in test_opened_event above # See note in test_opened_event above
@@ -328,16 +327,16 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
"message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>", "message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>",
} }
response = self.client.post( response = self.client.post(
"/anymail/sendinblue/tracking/", "/anymail/brevo/tracking/",
content_type="application/json", content_type="application/json",
data=json.dumps(raw_event), data=json.dumps(raw_event),
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with( kwargs = self.assert_handler_called_once_with(
self.tracking_handler, self.tracking_handler,
sender=SendinBlueTrackingWebhookView, sender=BrevoTrackingWebhookView,
event=ANY, event=ANY,
esp_name="SendinBlue", esp_name="Brevo",
) )
event = kwargs["event"] event = kwargs["event"]
self.assertEqual(event.event_type, "opened") self.assertEqual(event.event_type, "opened")
@@ -351,21 +350,21 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
"link": "https://example.com/click/me", "link": "https://example.com/click/me",
} }
response = self.client.post( response = self.client.post(
"/anymail/sendinblue/tracking/", "/anymail/brevo/tracking/",
content_type="application/json", content_type="application/json",
data=json.dumps(raw_event), data=json.dumps(raw_event),
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with( kwargs = self.assert_handler_called_once_with(
self.tracking_handler, self.tracking_handler,
sender=SendinBlueTrackingWebhookView, sender=BrevoTrackingWebhookView,
event=ANY, event=ANY,
esp_name="SendinBlue", esp_name="Brevo",
) )
event = kwargs["event"] event = kwargs["event"]
self.assertEqual(event.event_type, "clicked") self.assertEqual(event.event_type, "clicked")
self.assertEqual(event.click_url, "https://example.com/click/me") self.assertEqual(event.click_url, "https://example.com/click/me")
self.assertIsNone(event.user_agent) # SendinBlue doesn't report user agent self.assertIsNone(event.user_agent) # Brevo doesn't report user agent
def test_unsubscribe(self): def test_unsubscribe(self):
# "When a person unsubscribes from the email received." # "When a person unsubscribes from the email received."
@@ -378,28 +377,28 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
"message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>", "message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>",
} }
response = self.client.post( response = self.client.post(
"/anymail/sendinblue/tracking/", "/anymail/brevo/tracking/",
content_type="application/json", content_type="application/json",
data=json.dumps(raw_event), data=json.dumps(raw_event),
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with( kwargs = self.assert_handler_called_once_with(
self.tracking_handler, self.tracking_handler,
sender=SendinBlueTrackingWebhookView, sender=BrevoTrackingWebhookView,
event=ANY, event=ANY,
esp_name="SendinBlue", esp_name="Brevo",
) )
event = kwargs["event"] event = kwargs["event"]
self.assertEqual(event.event_type, "unsubscribed") self.assertEqual(event.event_type, "unsubscribed")
def test_misconfigured_inbound(self): def test_misconfigured_inbound(self):
errmsg = ( errmsg = (
"You seem to have set SendinBlue's *inbound* webhook URL" "You seem to have set Brevo's *inbound* webhook URL"
" to Anymail's SendinBlue *tracking* webhook URL." " to Anymail's Brevo *tracking* webhook URL."
) )
with self.assertRaisesMessage(AnymailConfigurationError, errmsg): with self.assertRaisesMessage(AnymailConfigurationError, errmsg):
self.client.post( self.client.post(
"/anymail/sendinblue/tracking/", "/anymail/brevo/tracking/",
content_type="application/json", content_type="application/json",
data={"items": []}, data={"items": []},
) )

View File

@@ -59,6 +59,7 @@ setenv =
# (resend should work with or without its extras, so it isn't in `none`) # (resend should work with or without its extras, so it isn't in `none`)
none: ANYMAIL_SKIP_TESTS=amazon_ses,postal none: ANYMAIL_SKIP_TESTS=amazon_ses,postal
amazon_ses: ANYMAIL_ONLY_TEST=amazon_ses amazon_ses: ANYMAIL_ONLY_TEST=amazon_ses
brevo: ANYMAIL_ONLY_TEST=brevo
mailersend: ANYMAIL_ONLY_TEST=mailersend mailersend: ANYMAIL_ONLY_TEST=mailersend
mailgun: ANYMAIL_ONLY_TEST=mailgun mailgun: ANYMAIL_ONLY_TEST=mailgun
mailjet: ANYMAIL_ONLY_TEST=mailjet mailjet: ANYMAIL_ONLY_TEST=mailjet
@@ -68,7 +69,6 @@ setenv =
resend: ANYMAIL_ONLY_TEST=resend resend: ANYMAIL_ONLY_TEST=resend
sendgrid: ANYMAIL_ONLY_TEST=sendgrid sendgrid: ANYMAIL_ONLY_TEST=sendgrid
unisender_go: ANYMAIL_ONLY_TEST=unisender_go unisender_go: ANYMAIL_ONLY_TEST=unisender_go
sendinblue: ANYMAIL_ONLY_TEST=sendinblue
sparkpost: ANYMAIL_ONLY_TEST=sparkpost sparkpost: ANYMAIL_ONLY_TEST=sparkpost
ignore_outcome = ignore_outcome =
# CI that wants to handle errors itself can set TOX_OVERRIDE_IGNORE_OUTCOME=false # CI that wants to handle errors itself can set TOX_OVERRIDE_IGNORE_OUTCOME=false