mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 03:41:05 -05:00
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:
6
.github/workflows/integration-test.yml
vendored
6
.github/workflows/integration-test.yml
vendored
@@ -40,6 +40,7 @@ jobs:
|
||||
# combination, to avoid rapidly consuming the testing accounts' entire send allotments.
|
||||
config:
|
||||
- { 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-mailgun, 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-resend, 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-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_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_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_DOMAIN: ${{ secrets.ANYMAIL_TEST_MAILERSEND_DOMAIN }}
|
||||
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_DOMAIN: ${{ secrets.ANYMAIL_TEST_SENDGRID_DOMAIN }}
|
||||
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_DOMAIN: ${{ secrets.ANYMAIL_TEST_SPARKPOST_DOMAIN }}
|
||||
ANYMAIL_TEST_UNISENDER_GO_API_KEY: ${{ secrets.ANYMAIL_TEST_UNISENDER_GO_API_KEY }}
|
||||
|
||||
@@ -30,6 +30,16 @@ vNext
|
||||
|
||||
*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
|
||||
~~~~~~~~
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@ from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
|
||||
class EmailBackend(AnymailRequestsBackend):
|
||||
"""
|
||||
SendinBlue v3 API Email Backend
|
||||
Brevo v3 API Email Backend
|
||||
"""
|
||||
|
||||
esp_name = "SendinBlue"
|
||||
esp_name = "Brevo"
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Init options from Django settings"""
|
||||
@@ -33,11 +33,11 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
super().__init__(api_url, **kwargs)
|
||||
|
||||
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):
|
||||
# SendinBlue doesn't give any detail on a success
|
||||
# https://developers.sendinblue.com/docs/responses
|
||||
# Brevo doesn't give any detail on a success, other than messageId
|
||||
# https://developers.brevo.com/reference/sendtransacemail
|
||||
message_id = None
|
||||
message_ids = []
|
||||
|
||||
@@ -51,7 +51,7 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
message_ids = parsed_response["messageIds"]
|
||||
except (KeyError, TypeError) as err:
|
||||
raise AnymailRequestsAPIError(
|
||||
"Invalid SendinBlue API response format",
|
||||
"Invalid Brevo API response format",
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
@@ -70,7 +70,7 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
return recipient_status
|
||||
|
||||
|
||||
class SendinBluePayload(RequestsPayload):
|
||||
class BrevoPayload(RequestsPayload):
|
||||
def __init__(self, message, defaults, backend, *args, **kwargs):
|
||||
self.all_recipients = [] # used for backend.parse_recipient_status
|
||||
self.to_recipients = [] # used for backend.parse_recipient_status
|
||||
@@ -124,7 +124,7 @@ class SendinBluePayload(RequestsPayload):
|
||||
|
||||
@staticmethod
|
||||
def email_object(email):
|
||||
"""Converts EmailAddress to SendinBlue API array"""
|
||||
"""Converts EmailAddress to Brevo API array"""
|
||||
email_object = dict()
|
||||
email_object["email"] = email.addr_spec
|
||||
if email.display_name:
|
||||
@@ -147,14 +147,14 @@ class SendinBluePayload(RequestsPayload):
|
||||
self.data["subject"] = subject
|
||||
|
||||
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:
|
||||
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):
|
||||
# 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.
|
||||
self.data["headers"].update(
|
||||
{
|
||||
@@ -182,7 +182,7 @@ class SendinBluePayload(RequestsPayload):
|
||||
self.data["htmlContent"] = body
|
||||
|
||||
def add_attachment(self, attachment):
|
||||
"""Converts attachments to SendinBlue API {name, base64} array"""
|
||||
"""Converts attachments to Brevo API {name, base64} array"""
|
||||
att = {
|
||||
"name": attachment.name or "",
|
||||
"content": attachment.b64content,
|
||||
@@ -204,7 +204,7 @@ class SendinBluePayload(RequestsPayload):
|
||||
self.data["params"] = merge_global_data
|
||||
|
||||
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.metadata = metadata # needed in serialize_data for batch send
|
||||
|
||||
@@ -4,6 +4,7 @@ from .webhooks.amazon_ses import (
|
||||
AmazonSESInboundWebhookView,
|
||||
AmazonSESTrackingWebhookView,
|
||||
)
|
||||
from .webhooks.brevo import BrevoInboundWebhookView, BrevoTrackingWebhookView
|
||||
from .webhooks.mailersend import (
|
||||
MailerSendInboundWebhookView,
|
||||
MailerSendTrackingWebhookView,
|
||||
@@ -15,10 +16,6 @@ from .webhooks.postal import PostalInboundWebhookView, PostalTrackingWebhookView
|
||||
from .webhooks.postmark import PostmarkInboundWebhookView, PostmarkTrackingWebhookView
|
||||
from .webhooks.resend import ResendTrackingWebhookView
|
||||
from .webhooks.sendgrid import SendGridInboundWebhookView, SendGridTrackingWebhookView
|
||||
from .webhooks.sendinblue import (
|
||||
SendinBlueInboundWebhookView,
|
||||
SendinBlueTrackingWebhookView,
|
||||
)
|
||||
from .webhooks.sparkpost import (
|
||||
SparkPostInboundWebhookView,
|
||||
SparkPostTrackingWebhookView,
|
||||
@@ -32,6 +29,11 @@ urlpatterns = [
|
||||
AmazonSESInboundWebhookView.as_view(),
|
||||
name="amazon_ses_inbound_webhook",
|
||||
),
|
||||
path(
|
||||
"brevo/inbound/",
|
||||
BrevoInboundWebhookView.as_view(),
|
||||
name="brevo_inbound_webhook",
|
||||
),
|
||||
path(
|
||||
"mailersend/inbound/",
|
||||
MailerSendInboundWebhookView.as_view(),
|
||||
@@ -66,11 +68,6 @@ urlpatterns = [
|
||||
SendGridInboundWebhookView.as_view(),
|
||||
name="sendgrid_inbound_webhook",
|
||||
),
|
||||
path(
|
||||
"sendinblue/inbound/",
|
||||
SendinBlueInboundWebhookView.as_view(),
|
||||
name="sendinblue_inbound_webhook",
|
||||
),
|
||||
path(
|
||||
"sparkpost/inbound/",
|
||||
SparkPostInboundWebhookView.as_view(),
|
||||
@@ -81,6 +78,11 @@ urlpatterns = [
|
||||
AmazonSESTrackingWebhookView.as_view(),
|
||||
name="amazon_ses_tracking_webhook",
|
||||
),
|
||||
path(
|
||||
"brevo/tracking/",
|
||||
BrevoTrackingWebhookView.as_view(),
|
||||
name="brevo_tracking_webhook",
|
||||
),
|
||||
path(
|
||||
"mailersend/tracking/",
|
||||
MailerSendTrackingWebhookView.as_view(),
|
||||
@@ -116,11 +118,6 @@ urlpatterns = [
|
||||
SendGridTrackingWebhookView.as_view(),
|
||||
name="sendgrid_tracking_webhook",
|
||||
),
|
||||
path(
|
||||
"sendinblue/tracking/",
|
||||
SendinBlueTrackingWebhookView.as_view(),
|
||||
name="sendinblue_tracking_webhook",
|
||||
),
|
||||
path(
|
||||
"sparkpost/tracking/",
|
||||
SparkPostTrackingWebhookView.as_view(),
|
||||
|
||||
@@ -19,12 +19,14 @@ from ..utils import get_anymail_setting
|
||||
from .base import AnymailBaseWebhookView
|
||||
|
||||
|
||||
class SendinBlueBaseWebhookView(AnymailBaseWebhookView):
|
||||
esp_name = "SendinBlue"
|
||||
class BrevoBaseWebhookView(AnymailBaseWebhookView):
|
||||
esp_name = "Brevo"
|
||||
|
||||
|
||||
class SendinBlueTrackingWebhookView(SendinBlueBaseWebhookView):
|
||||
"""Handler for SendinBlue delivery and engagement tracking webhooks"""
|
||||
class BrevoTrackingWebhookView(BrevoBaseWebhookView):
|
||||
"""Handler for Brevo delivery and engagement tracking webhooks"""
|
||||
|
||||
# https://developers.brevo.com/docs/transactional-webhooks
|
||||
|
||||
signal = tracking
|
||||
|
||||
@@ -33,15 +35,13 @@ class SendinBlueTrackingWebhookView(SendinBlueBaseWebhookView):
|
||||
if "items" in esp_event:
|
||||
# This is an inbound webhook post
|
||||
raise AnymailConfigurationError(
|
||||
"You seem to have set SendinBlue's *inbound* webhook URL "
|
||||
"to Anymail's SendinBlue *tracking* webhook URL."
|
||||
f"You seem to have set Brevo's *inbound* webhook URL "
|
||||
f"to Anymail's {self.esp_name} *tracking* webhook URL."
|
||||
)
|
||||
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 = {
|
||||
# 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"):
|
||||
"request": (EventType.QUEUED, None),
|
||||
"delivered": (EventType.DELIVERED, None),
|
||||
@@ -67,7 +67,7 @@ class SendinBlueTrackingWebhookView(SendinBlueBaseWebhookView):
|
||||
recipient = esp_event.get("email")
|
||||
|
||||
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
|
||||
# inconsistent DST adjustment). "ts_epoch" is the only field that seems to
|
||||
# be consistently UTC; it's in milliseconds
|
||||
@@ -98,7 +98,7 @@ class SendinBlueTrackingWebhookView(SendinBlueBaseWebhookView):
|
||||
return AnymailTrackingEvent(
|
||||
description=None,
|
||||
esp_event=esp_event,
|
||||
# SendinBlue doesn't provide a unique event id:
|
||||
# Brevo doesn't provide a unique event id:
|
||||
event_id=None,
|
||||
event_type=event_type,
|
||||
message_id=esp_event.get("message-id"),
|
||||
@@ -113,8 +113,10 @@ class SendinBlueTrackingWebhookView(SendinBlueBaseWebhookView):
|
||||
)
|
||||
|
||||
|
||||
class SendinBlueInboundWebhookView(SendinBlueBaseWebhookView):
|
||||
"""Handler for SendinBlue inbound email webhooks"""
|
||||
class BrevoInboundWebhookView(BrevoBaseWebhookView):
|
||||
"""Handler for Brevo inbound email webhooks"""
|
||||
|
||||
# https://developers.brevo.com/docs/inbound-parse-webhooks#parsed-email-payload
|
||||
|
||||
signal = inbound
|
||||
|
||||
@@ -141,10 +143,10 @@ class SendinBlueInboundWebhookView(SendinBlueBaseWebhookView):
|
||||
try:
|
||||
esp_events = payload["items"]
|
||||
except KeyError:
|
||||
# This is not n inbound webhook post
|
||||
# This is not an inbound webhook post
|
||||
raise AnymailConfigurationError(
|
||||
"You seem to have set SendinBlue's *tracking* webhook URL "
|
||||
"to Anymail's SendinBlue *inbound* webhook URL."
|
||||
f"You seem to have set Brevo's *tracking* webhook URL "
|
||||
f"to Anymail's {self.esp_name} *inbound* webhook URL."
|
||||
)
|
||||
else:
|
||||
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):
|
||||
# Download attachment content from SendinBlue API.
|
||||
# Download attachment content from Brevo API.
|
||||
# FUTURE: somehow defer download until attachment is accessed?
|
||||
token = attachment["DownloadToken"]
|
||||
url = urljoin(self.api_url, f"inbound/attachments/{quote(token, safe='')}")
|
||||
@@ -4,14 +4,24 @@
|
||||
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`_.
|
||||
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,
|
||||
Anymail still uses the old name in code (settings, backend, webhook urls, etc.).
|
||||
SendinBlue rebranded as Brevo in May, 2023. Anymail 10.3 uses the new
|
||||
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::
|
||||
|
||||
@@ -36,14 +46,14 @@ To use Anymail's Brevo backend, set:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
EMAIL_BACKEND = "anymail.backends.sendinblue.EmailBackend"
|
||||
EMAIL_BACKEND = "anymail.backends.brevo.EmailBackend"
|
||||
|
||||
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
|
||||
"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 = {
|
||||
...
|
||||
"SENDINBLUE_API_KEY": "<your v3 API key>",
|
||||
"BREVO_API_KEY": "<your v3 API key>",
|
||||
}
|
||||
|
||||
Anymail will also look for ``SENDINBLUE_API_KEY`` at the
|
||||
root of the settings file if neither ``ANYMAIL["SENDINBLUE_API_KEY"]``
|
||||
nor ``ANYMAIL_SENDINBLUE_API_KEY`` is set.
|
||||
Anymail will also look for ``BREVO_API_KEY`` at the
|
||||
root of the settings file if neither ``ANYMAIL["BREVO_API_KEY"]``
|
||||
nor ``ANYMAIL_BREVO_API_KEY`` is set.
|
||||
|
||||
.. _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 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.)
|
||||
|
||||
.. 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/``.
|
||||
|
||||
|
||||
.. _brevo-esp-extra:
|
||||
.. _sendinblue-esp-extra:
|
||||
|
||||
esp_extra support
|
||||
@@ -106,6 +117,7 @@ to apply it to all messages.)
|
||||
.. _smtp/email API: https://developers.brevo.com/reference/sendtransacemail
|
||||
|
||||
|
||||
.. _brevo-limitations:
|
||||
.. _sendinblue-limitations:
|
||||
|
||||
Limitations and quirks
|
||||
@@ -192,6 +204,7 @@ Brevo can handle.
|
||||
on individual messages.
|
||||
|
||||
|
||||
.. _brevo-templates:
|
||||
.. _sendinblue-templates:
|
||||
|
||||
Batch sending/merge and ESP templates
|
||||
@@ -267,9 +280,9 @@ message's headers: ``message.extra_headers = {"idempotencyKey": "...uuid..."}``.
|
||||
|
||||
.. 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 }}``
|
||||
substitutions), and an "old" template language that used percent-delimited
|
||||
``%NAME%`` substitutions.
|
||||
@@ -299,6 +312,7 @@ message's headers: ``message.extra_headers = {"idempotencyKey": "...uuid..."}``.
|
||||
https://help.brevo.com/hc/en-us/articles/360000991960
|
||||
|
||||
|
||||
.. _brevo-webhooks:
|
||||
.. _sendinblue-webhooks:
|
||||
|
||||
Status tracking webhooks
|
||||
@@ -309,7 +323,7 @@ the url at Brevo's site under `Transactional > Email > Settings > Webhook`_.
|
||||
|
||||
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
|
||||
* *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
|
||||
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
|
||||
|
||||
|
||||
.. _brevo-inbound:
|
||||
.. _sendinblue-inbound:
|
||||
|
||||
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:
|
||||
|
||||
: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
|
||||
* *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
|
||||
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:
|
||||
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
|
||||
.. _inbound events list API:
|
||||
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
|
||||
|
||||
@@ -66,6 +66,7 @@ dependencies = [
|
||||
# (For simplicity, requests is included in the base dependencies.)
|
||||
# (Do not use underscores in extra names: they get normalized to hyphens.)
|
||||
amazon-ses = ["boto3"]
|
||||
brevo = []
|
||||
mailersend = []
|
||||
mailgun = []
|
||||
mailjet = []
|
||||
|
||||
@@ -32,19 +32,16 @@ from .utils import (
|
||||
)
|
||||
|
||||
|
||||
@tag("sendinblue")
|
||||
@tag("brevo")
|
||||
@override_settings(
|
||||
EMAIL_BACKEND="anymail.backends.sendinblue.EmailBackend",
|
||||
ANYMAIL={"SENDINBLUE_API_KEY": "test_api_key"},
|
||||
EMAIL_BACKEND="anymail.backends.brevo.EmailBackend",
|
||||
ANYMAIL={"BREVO_API_KEY": "test_api_key"},
|
||||
)
|
||||
class SendinBlueBackendMockAPITestCase(RequestsBackendMockAPITestCase):
|
||||
# SendinBlue v3 success responses are empty
|
||||
class BrevoBackendMockAPITestCase(RequestsBackendMockAPITestCase):
|
||||
DEFAULT_RAW_RESPONSE = (
|
||||
b'{"messageId":"<201801020304.1234567890@smtp-relay.mailin.fr>"}'
|
||||
)
|
||||
DEFAULT_STATUS_CODE = (
|
||||
201 # SendinBlue v3 uses '201 Created' for success (in most cases)
|
||||
)
|
||||
DEFAULT_STATUS_CODE = 201 # Brevo v3 uses '201 Created' for success (in most cases)
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
@@ -54,8 +51,8 @@ class SendinBlueBackendMockAPITestCase(RequestsBackendMockAPITestCase):
|
||||
)
|
||||
|
||||
|
||||
@tag("sendinblue")
|
||||
class SendinBlueBackendStandardEmailTests(SendinBlueBackendMockAPITestCase):
|
||||
@tag("brevo")
|
||||
class BrevoBackendStandardEmailTests(BrevoBackendMockAPITestCase):
|
||||
"""Test backend support for Django standard email features"""
|
||||
|
||||
def test_send_mail(self):
|
||||
@@ -204,7 +201,7 @@ class SendinBlueBackendStandardEmailTests(SendinBlueBackendMockAPITestCase):
|
||||
)
|
||||
|
||||
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 = [
|
||||
'"Reply recipient" <reply@example.com',
|
||||
"reply2@example.com",
|
||||
@@ -274,7 +271,7 @@ class SendinBlueBackendStandardEmailTests(SendinBlueBackendMockAPITestCase):
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
image_filename = SAMPLE_IMAGE_FILENAME
|
||||
@@ -339,7 +336,7 @@ class SendinBlueBackendStandardEmailTests(SendinBlueBackendMockAPITestCase):
|
||||
|
||||
def test_api_failure(self):
|
||||
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"])
|
||||
|
||||
# Make sure fail_silently is respected
|
||||
@@ -373,12 +370,12 @@ class SendinBlueBackendStandardEmailTests(SendinBlueBackendMockAPITestCase):
|
||||
self.message.send()
|
||||
|
||||
|
||||
@tag("sendinblue")
|
||||
class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase):
|
||||
@tag("brevo")
|
||||
class BrevoBackendAnymailFeatureTests(BrevoBackendMockAPITestCase):
|
||||
"""Test backend support for Anymail added features"""
|
||||
|
||||
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"
|
||||
with self.assertRaisesMessage(AnymailUnsupportedFeature, "envelope_sender"):
|
||||
self.message.send()
|
||||
@@ -459,7 +456,7 @@ class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase):
|
||||
self.message.send()
|
||||
|
||||
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(
|
||||
subject="My Subject",
|
||||
body=None,
|
||||
@@ -470,7 +467,7 @@ class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase):
|
||||
bcc=["Recipient <bcc@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.send()
|
||||
data = self.get_api_call_json()
|
||||
@@ -603,7 +600,7 @@ class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase):
|
||||
# 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
|
||||
# the DEFAULT_RAW_RESPONSE above is the *only* success response Brevo
|
||||
# returns, so no need to override it here
|
||||
msg = mail.EmailMessage(
|
||||
"Subject",
|
||||
@@ -652,39 +649,37 @@ class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase):
|
||||
err = cm.exception
|
||||
self.assertIsInstance(err, TypeError) # compatibility with json.dumps
|
||||
# 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
|
||||
self.assertRegex(str(err), r"Decimal.*is not JSON serializable")
|
||||
|
||||
|
||||
@tag("sendinblue")
|
||||
class SendinBlueBackendRecipientsRefusedTests(SendinBlueBackendMockAPITestCase):
|
||||
@tag("brevo")
|
||||
class BrevoBackendRecipientsRefusedTests(BrevoBackendMockAPITestCase):
|
||||
"""
|
||||
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"
|
||||
# and "failed" events to detect refused recipients.
|
||||
pass # not applicable to this backend
|
||||
|
||||
|
||||
@tag("sendinblue")
|
||||
class SendinBlueBackendSessionSharingTestCase(
|
||||
SessionSharingTestCases, SendinBlueBackendMockAPITestCase
|
||||
@tag("brevo")
|
||||
class BrevoBackendSessionSharingTestCase(
|
||||
SessionSharingTestCases, BrevoBackendMockAPITestCase
|
||||
):
|
||||
"""Requests session sharing tests"""
|
||||
|
||||
pass # tests are defined in SessionSharingTestCases
|
||||
|
||||
|
||||
@tag("sendinblue")
|
||||
@override_settings(EMAIL_BACKEND="anymail.backends.sendinblue.EmailBackend")
|
||||
class SendinBlueBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase):
|
||||
@tag("brevo")
|
||||
@override_settings(EMAIL_BACKEND="anymail.backends.brevo.EmailBackend")
|
||||
class BrevoBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase):
|
||||
"""Test ESP backend without required settings in place"""
|
||||
|
||||
def test_missing_auth(self):
|
||||
with self.assertRaisesRegex(
|
||||
AnymailConfigurationError, r"\bSENDINBLUE_API_KEY\b"
|
||||
):
|
||||
with self.assertRaisesRegex(AnymailConfigurationError, r"\bBREVO_API_KEY\b"):
|
||||
mail.send_mail("Subject", "Message", "from@example.com", ["to@example.com"])
|
||||
@@ -7,15 +7,15 @@ from responses.matchers import header_matcher
|
||||
from anymail.exceptions import AnymailConfigurationError
|
||||
from anymail.inbound import AnymailInboundMessage
|
||||
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 .webhook_cases import WebhookTestCase
|
||||
|
||||
|
||||
@tag("sendinblue")
|
||||
@override_settings(ANYMAIL_SENDINBLUE_API_KEY="test-api-key")
|
||||
class SendinBlueInboundTestCase(WebhookTestCase):
|
||||
@tag("brevo")
|
||||
@override_settings(ANYMAIL_BREVO_API_KEY="test-api-key")
|
||||
class BrevoInboundTestCase(WebhookTestCase):
|
||||
def test_inbound_basics(self):
|
||||
# Actual (sanitized) Brevo inbound message payload 7/2023
|
||||
raw_event = {
|
||||
@@ -54,16 +54,16 @@ class SendinBlueInboundTestCase(WebhookTestCase):
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
"/anymail/sendinblue/inbound/",
|
||||
"/anymail/brevo/inbound/",
|
||||
content_type="application/json",
|
||||
data={"items": [raw_event]},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
kwargs = self.assert_handler_called_once_with(
|
||||
self.inbound_handler,
|
||||
sender=SendinBlueInboundWebhookView,
|
||||
sender=BrevoInboundWebhookView,
|
||||
event=ANY,
|
||||
esp_name="SendinBlue",
|
||||
esp_name="Brevo",
|
||||
)
|
||||
# AnymailInboundEvent
|
||||
event = kwargs["event"]
|
||||
@@ -123,15 +123,15 @@ class SendinBlueInboundTestCase(WebhookTestCase):
|
||||
}
|
||||
}
|
||||
self.client.post(
|
||||
"/anymail/sendinblue/inbound/",
|
||||
"/anymail/brevo/inbound/",
|
||||
content_type="application/json",
|
||||
data={"items": [raw_event]},
|
||||
)
|
||||
kwargs = self.assert_handler_called_once_with(
|
||||
self.inbound_handler,
|
||||
sender=SendinBlueInboundWebhookView,
|
||||
sender=BrevoInboundWebhookView,
|
||||
event=ANY,
|
||||
esp_name="SendinBlue",
|
||||
esp_name="Brevo",
|
||||
)
|
||||
event = kwargs["event"]
|
||||
message = event.message
|
||||
@@ -203,16 +203,16 @@ class SendinBlueInboundTestCase(WebhookTestCase):
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
"/anymail/sendinblue/inbound/",
|
||||
"/anymail/brevo/inbound/",
|
||||
content_type="application/json",
|
||||
data={"items": [raw_event]},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
kwargs = self.assert_handler_called_once_with(
|
||||
self.inbound_handler,
|
||||
sender=SendinBlueInboundWebhookView,
|
||||
sender=BrevoInboundWebhookView,
|
||||
event=ANY,
|
||||
esp_name="SendinBlue",
|
||||
esp_name="Brevo",
|
||||
)
|
||||
event = kwargs["event"]
|
||||
message = event.message
|
||||
@@ -235,12 +235,12 @@ class SendinBlueInboundTestCase(WebhookTestCase):
|
||||
|
||||
def test_misconfigured_tracking(self):
|
||||
errmsg = (
|
||||
"You seem to have set SendinBlue's *tracking* webhook URL"
|
||||
" to Anymail's SendinBlue *inbound* webhook URL."
|
||||
"You seem to have set Brevo's *tracking* webhook URL"
|
||||
" to Anymail's Brevo *inbound* webhook URL."
|
||||
)
|
||||
with self.assertRaisesMessage(AnymailConfigurationError, errmsg):
|
||||
self.client.post(
|
||||
"/anymail/sendinblue/inbound/",
|
||||
"/anymail/brevo/inbound/",
|
||||
content_type="application/json",
|
||||
data={"event": "delivered"},
|
||||
)
|
||||
@@ -10,39 +10,39 @@ from anymail.message import AnymailMessage
|
||||
|
||||
from .utils import AnymailTestMixin
|
||||
|
||||
ANYMAIL_TEST_SENDINBLUE_API_KEY = os.getenv("ANYMAIL_TEST_SENDINBLUE_API_KEY")
|
||||
ANYMAIL_TEST_SENDINBLUE_DOMAIN = os.getenv("ANYMAIL_TEST_SENDINBLUE_DOMAIN")
|
||||
ANYMAIL_TEST_BREVO_API_KEY = os.getenv("ANYMAIL_TEST_BREVO_API_KEY")
|
||||
ANYMAIL_TEST_BREVO_DOMAIN = os.getenv("ANYMAIL_TEST_BREVO_DOMAIN")
|
||||
|
||||
|
||||
@tag("sendinblue", "live")
|
||||
@tag("brevo", "live")
|
||||
@unittest.skipUnless(
|
||||
ANYMAIL_TEST_SENDINBLUE_API_KEY and ANYMAIL_TEST_SENDINBLUE_DOMAIN,
|
||||
"Set ANYMAIL_TEST_SENDINBLUE_API_KEY and ANYMAIL_TEST_SENDINBLUE_DOMAIN "
|
||||
"environment variables to run SendinBlue integration tests",
|
||||
ANYMAIL_TEST_BREVO_API_KEY and ANYMAIL_TEST_BREVO_DOMAIN,
|
||||
"Set ANYMAIL_TEST_BREVO_API_KEY and ANYMAIL_TEST_BREVO_DOMAIN "
|
||||
"environment variables to run Brevo integration tests",
|
||||
)
|
||||
@override_settings(
|
||||
ANYMAIL_SENDINBLUE_API_KEY=ANYMAIL_TEST_SENDINBLUE_API_KEY,
|
||||
ANYMAIL_SENDINBLUE_SEND_DEFAULTS=dict(),
|
||||
EMAIL_BACKEND="anymail.backends.sendinblue.EmailBackend",
|
||||
ANYMAIL_BREVO_API_KEY=ANYMAIL_TEST_BREVO_API_KEY,
|
||||
ANYMAIL_BREVO_SEND_DEFAULTS=dict(),
|
||||
EMAIL_BACKEND="anymail.backends.brevo.EmailBackend",
|
||||
)
|
||||
class SendinBlueBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
||||
"""SendinBlue v3 API integration tests
|
||||
class BrevoBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
||||
"""Brevo v3 API integration tests
|
||||
|
||||
SendinBlue doesn't have sandbox so these tests run
|
||||
against the **live** SendinBlue API, using the
|
||||
environment variable `ANYMAIL_TEST_SENDINBLUE_API_KEY` as the API key,
|
||||
and `ANYMAIL_TEST_SENDINBLUE_DOMAIN` to construct sender addresses.
|
||||
Brevo doesn't have sandbox so these tests run
|
||||
against the **live** Brevo API, using the
|
||||
environment variable `ANYMAIL_TEST_BREVO_API_KEY` as the API key,
|
||||
and `ANYMAIL_TEST_BREVO_DOMAIN` to construct sender addresses.
|
||||
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):
|
||||
super().setUp()
|
||||
self.from_email = "from@%s" % ANYMAIL_TEST_SENDINBLUE_DOMAIN
|
||||
self.from_email = "from@%s" % ANYMAIL_TEST_BREVO_DOMAIN
|
||||
self.message = AnymailMessage(
|
||||
"Anymail SendinBlue integration test",
|
||||
"Anymail Brevo integration test",
|
||||
"Text content",
|
||||
self.from_email,
|
||||
["test+to1@anymail.dev"],
|
||||
@@ -50,7 +50,7 @@ class SendinBlueBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
||||
self.message.attach_alternative("<p>HTML content</p>", "text/html")
|
||||
|
||||
def test_simple_send(self):
|
||||
# Example of getting the SendinBlue send status and message id from the message
|
||||
# Example of getting the Brevo send status and message id from the message
|
||||
sent_count = self.message.send()
|
||||
self.assertEqual(sent_count, 1)
|
||||
|
||||
@@ -58,7 +58,7 @@ class SendinBlueBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
||||
sent_status = anymail_status.recipients["test+to1@anymail.dev"].status
|
||||
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:
|
||||
self.assertRegex(message_id, r"\<.+@.+\>")
|
||||
# set of all recipient statuses:
|
||||
@@ -68,27 +68,27 @@ class SendinBlueBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
||||
def test_all_options(self):
|
||||
send_at = datetime.now() + timedelta(minutes=2)
|
||||
message = AnymailMessage(
|
||||
subject="Anymail SendinBlue all-options integration test",
|
||||
subject="Anymail Brevo all-options integration test",
|
||||
body="This is the text body",
|
||||
from_email=formataddr(("Test From, with comma", self.from_email)),
|
||||
to=["test+to1@anymail.dev", '"Recipient 2, OK?" <test+to2@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>"],
|
||||
# SendinBlue API v3 only supports single reply-to
|
||||
# Brevo API v3 only supports single reply-to
|
||||
reply_to=['"Reply, with comma" <reply@example.com>'],
|
||||
headers={"X-Anymail-Test": "value", "X-Anymail-Count": 3},
|
||||
metadata={"meta1": "simple string", "meta2": 2},
|
||||
send_at=send_at,
|
||||
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("attachment1.txt", "Here is some\ntext for you", "text/plain")
|
||||
message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv")
|
||||
|
||||
message.send()
|
||||
# SendinBlue always queues:
|
||||
# Brevo always queues:
|
||||
self.assertEqual(message.anymail_status.status, {"queued"})
|
||||
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.send()
|
||||
# SendinBlue always queues:
|
||||
# Brevo always queues:
|
||||
self.assertEqual(message.anymail_status.status, {"queued"})
|
||||
recipient_status = message.anymail_status.recipients
|
||||
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,
|
||||
)
|
||||
|
||||
@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):
|
||||
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:
|
||||
# Make sure the exception message includes Brevo's response:
|
||||
self.assertIn("Key not found", str(err))
|
||||
@@ -6,16 +6,16 @@ from django.test import tag
|
||||
|
||||
from anymail.exceptions import AnymailConfigurationError
|
||||
from anymail.signals import AnymailTrackingEvent
|
||||
from anymail.webhooks.sendinblue import SendinBlueTrackingWebhookView
|
||||
from anymail.webhooks.brevo import BrevoTrackingWebhookView
|
||||
|
||||
from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase
|
||||
|
||||
|
||||
@tag("sendinblue")
|
||||
class SendinBlueWebhookSecurityTestCase(WebhookBasicAuthTestCase):
|
||||
@tag("brevo")
|
||||
class BrevoWebhookSecurityTestCase(WebhookBasicAuthTestCase):
|
||||
def call_webhook(self):
|
||||
return self.client.post(
|
||||
"/anymail/sendinblue/tracking/",
|
||||
"/anymail/brevo/tracking/",
|
||||
content_type="application/json",
|
||||
data=json.dumps({}),
|
||||
)
|
||||
@@ -23,23 +23,22 @@ class SendinBlueWebhookSecurityTestCase(WebhookBasicAuthTestCase):
|
||||
# Actual tests are in WebhookBasicAuthTestCase
|
||||
|
||||
|
||||
@tag("sendinblue")
|
||||
class SendinBlueDeliveryTestCase(WebhookTestCase):
|
||||
# SendinBlue's webhook payload data is partially documented at
|
||||
# https://help.sendinblue.com/hc/en-us/articles/360007666479,
|
||||
# but it's not completely up to date.
|
||||
@tag("brevo")
|
||||
class BrevoDeliveryTestCase(WebhookTestCase):
|
||||
# Brevo's webhook payload data is documented at
|
||||
# https://developers.brevo.com/docs/transactional-webhooks.
|
||||
# The payloads below were obtained through live testing.
|
||||
|
||||
def test_sent_event(self):
|
||||
raw_event = {
|
||||
"event": "request",
|
||||
"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>",
|
||||
"subject": "Test subject",
|
||||
# From a message sent at 2018-03-06 11:10:23-08: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_event": 1520331023, # unclear if this ever differs from "ts"
|
||||
"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",
|
||||
}
|
||||
response = self.client.post(
|
||||
"/anymail/sendinblue/tracking/",
|
||||
"/anymail/brevo/tracking/",
|
||||
content_type="application/json",
|
||||
data=json.dumps(raw_event),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
kwargs = self.assert_handler_called_once_with(
|
||||
self.tracking_handler,
|
||||
sender=SendinBlueTrackingWebhookView,
|
||||
sender=BrevoTrackingWebhookView,
|
||||
event=ANY,
|
||||
esp_name="SendinBlue",
|
||||
esp_name="Brevo",
|
||||
)
|
||||
event = kwargs["event"]
|
||||
self.assertIsInstance(event, AnymailTrackingEvent)
|
||||
@@ -77,7 +76,7 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
|
||||
self.assertEqual(
|
||||
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.assertEqual(event.recipient, "recipient@example.com")
|
||||
self.assertEqual(event.metadata, {"meta": "data"})
|
||||
@@ -93,16 +92,16 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
|
||||
"message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>",
|
||||
}
|
||||
response = self.client.post(
|
||||
"/anymail/sendinblue/tracking/",
|
||||
"/anymail/brevo/tracking/",
|
||||
content_type="application/json",
|
||||
data=json.dumps(raw_event),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
kwargs = self.assert_handler_called_once_with(
|
||||
self.tracking_handler,
|
||||
sender=SendinBlueTrackingWebhookView,
|
||||
sender=BrevoTrackingWebhookView,
|
||||
event=ANY,
|
||||
esp_name="SendinBlue",
|
||||
esp_name="Brevo",
|
||||
)
|
||||
event = kwargs["event"]
|
||||
self.assertIsInstance(event, AnymailTrackingEvent)
|
||||
@@ -128,16 +127,16 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
|
||||
"tag": "header-tag",
|
||||
}
|
||||
response = self.client.post(
|
||||
"/anymail/sendinblue/tracking/",
|
||||
"/anymail/brevo/tracking/",
|
||||
content_type="application/json",
|
||||
data=json.dumps(raw_event),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
kwargs = self.assert_handler_called_once_with(
|
||||
self.tracking_handler,
|
||||
sender=SendinBlueTrackingWebhookView,
|
||||
sender=BrevoTrackingWebhookView,
|
||||
event=ANY,
|
||||
esp_name="SendinBlue",
|
||||
esp_name="Brevo",
|
||||
)
|
||||
event = kwargs["event"]
|
||||
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",
|
||||
}
|
||||
response = self.client.post(
|
||||
"/anymail/sendinblue/tracking/",
|
||||
"/anymail/brevo/tracking/",
|
||||
content_type="application/json",
|
||||
data=json.dumps(raw_event),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
kwargs = self.assert_handler_called_once_with(
|
||||
self.tracking_handler,
|
||||
sender=SendinBlueTrackingWebhookView,
|
||||
sender=BrevoTrackingWebhookView,
|
||||
event=ANY,
|
||||
esp_name="SendinBlue",
|
||||
esp_name="Brevo",
|
||||
)
|
||||
event = kwargs["event"]
|
||||
self.assertEqual(event.event_type, "bounced")
|
||||
@@ -188,16 +187,16 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
|
||||
"reason": "blocked : due to blacklist user",
|
||||
}
|
||||
response = self.client.post(
|
||||
"/anymail/sendinblue/tracking/",
|
||||
"/anymail/brevo/tracking/",
|
||||
content_type="application/json",
|
||||
data=json.dumps(raw_event),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
kwargs = self.assert_handler_called_once_with(
|
||||
self.tracking_handler,
|
||||
sender=SendinBlueTrackingWebhookView,
|
||||
sender=BrevoTrackingWebhookView,
|
||||
event=ANY,
|
||||
esp_name="SendinBlue",
|
||||
esp_name="Brevo",
|
||||
)
|
||||
event = kwargs["event"]
|
||||
self.assertEqual(event.event_type, "rejected")
|
||||
@@ -214,16 +213,16 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
|
||||
"message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>",
|
||||
}
|
||||
response = self.client.post(
|
||||
"/anymail/sendinblue/tracking/",
|
||||
"/anymail/brevo/tracking/",
|
||||
content_type="application/json",
|
||||
data=json.dumps(raw_event),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
kwargs = self.assert_handler_called_once_with(
|
||||
self.tracking_handler,
|
||||
sender=SendinBlueTrackingWebhookView,
|
||||
sender=BrevoTrackingWebhookView,
|
||||
event=ANY,
|
||||
esp_name="SendinBlue",
|
||||
esp_name="Brevo",
|
||||
)
|
||||
event = kwargs["event"]
|
||||
self.assertEqual(event.event_type, "complained")
|
||||
@@ -231,7 +230,7 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
|
||||
def test_invalid_email(self):
|
||||
# "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
|
||||
# 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)
|
||||
raw_event = {
|
||||
"event": "invalid_email",
|
||||
@@ -241,16 +240,16 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
|
||||
"reason": "(guessing invalid_email includes a reason)",
|
||||
}
|
||||
response = self.client.post(
|
||||
"/anymail/sendinblue/tracking/",
|
||||
"/anymail/brevo/tracking/",
|
||||
content_type="application/json",
|
||||
data=json.dumps(raw_event),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
kwargs = self.assert_handler_called_once_with(
|
||||
self.tracking_handler,
|
||||
sender=SendinBlueTrackingWebhookView,
|
||||
sender=BrevoTrackingWebhookView,
|
||||
event=ANY,
|
||||
esp_name="SendinBlue",
|
||||
esp_name="Brevo",
|
||||
)
|
||||
event = kwargs["event"]
|
||||
self.assertEqual(event.event_type, "bounced")
|
||||
@@ -262,7 +261,7 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
|
||||
def test_deferred_event(self):
|
||||
# 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
|
||||
# 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 = {
|
||||
"event": "deferred",
|
||||
"email": "notauser@example.com",
|
||||
@@ -272,16 +271,16 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
|
||||
" address rejected: User unknown in virtual alias table",
|
||||
}
|
||||
response = self.client.post(
|
||||
"/anymail/sendinblue/tracking/",
|
||||
"/anymail/brevo/tracking/",
|
||||
content_type="application/json",
|
||||
data=json.dumps(raw_event),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
kwargs = self.assert_handler_called_once_with(
|
||||
self.tracking_handler,
|
||||
sender=SendinBlueTrackingWebhookView,
|
||||
sender=BrevoTrackingWebhookView,
|
||||
event=ANY,
|
||||
esp_name="SendinBlue",
|
||||
esp_name="Brevo",
|
||||
)
|
||||
event = kwargs["event"]
|
||||
self.assertEqual(event.event_type, "deferred")
|
||||
@@ -294,7 +293,7 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
|
||||
)
|
||||
|
||||
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
|
||||
# both on the first open.)
|
||||
raw_event = {
|
||||
@@ -304,20 +303,20 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
|
||||
"message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>",
|
||||
}
|
||||
response = self.client.post(
|
||||
"/anymail/sendinblue/tracking/",
|
||||
"/anymail/brevo/tracking/",
|
||||
content_type="application/json",
|
||||
data=json.dumps(raw_event),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
kwargs = self.assert_handler_called_once_with(
|
||||
self.tracking_handler,
|
||||
sender=SendinBlueTrackingWebhookView,
|
||||
sender=BrevoTrackingWebhookView,
|
||||
event=ANY,
|
||||
esp_name="SendinBlue",
|
||||
esp_name="Brevo",
|
||||
)
|
||||
event = kwargs["event"]
|
||||
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):
|
||||
# See note in test_opened_event above
|
||||
@@ -328,16 +327,16 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
|
||||
"message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>",
|
||||
}
|
||||
response = self.client.post(
|
||||
"/anymail/sendinblue/tracking/",
|
||||
"/anymail/brevo/tracking/",
|
||||
content_type="application/json",
|
||||
data=json.dumps(raw_event),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
kwargs = self.assert_handler_called_once_with(
|
||||
self.tracking_handler,
|
||||
sender=SendinBlueTrackingWebhookView,
|
||||
sender=BrevoTrackingWebhookView,
|
||||
event=ANY,
|
||||
esp_name="SendinBlue",
|
||||
esp_name="Brevo",
|
||||
)
|
||||
event = kwargs["event"]
|
||||
self.assertEqual(event.event_type, "opened")
|
||||
@@ -351,21 +350,21 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
|
||||
"link": "https://example.com/click/me",
|
||||
}
|
||||
response = self.client.post(
|
||||
"/anymail/sendinblue/tracking/",
|
||||
"/anymail/brevo/tracking/",
|
||||
content_type="application/json",
|
||||
data=json.dumps(raw_event),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
kwargs = self.assert_handler_called_once_with(
|
||||
self.tracking_handler,
|
||||
sender=SendinBlueTrackingWebhookView,
|
||||
sender=BrevoTrackingWebhookView,
|
||||
event=ANY,
|
||||
esp_name="SendinBlue",
|
||||
esp_name="Brevo",
|
||||
)
|
||||
event = kwargs["event"]
|
||||
self.assertEqual(event.event_type, "clicked")
|
||||
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):
|
||||
# "When a person unsubscribes from the email received."
|
||||
@@ -378,28 +377,28 @@ class SendinBlueDeliveryTestCase(WebhookTestCase):
|
||||
"message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>",
|
||||
}
|
||||
response = self.client.post(
|
||||
"/anymail/sendinblue/tracking/",
|
||||
"/anymail/brevo/tracking/",
|
||||
content_type="application/json",
|
||||
data=json.dumps(raw_event),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
kwargs = self.assert_handler_called_once_with(
|
||||
self.tracking_handler,
|
||||
sender=SendinBlueTrackingWebhookView,
|
||||
sender=BrevoTrackingWebhookView,
|
||||
event=ANY,
|
||||
esp_name="SendinBlue",
|
||||
esp_name="Brevo",
|
||||
)
|
||||
event = kwargs["event"]
|
||||
self.assertEqual(event.event_type, "unsubscribed")
|
||||
|
||||
def test_misconfigured_inbound(self):
|
||||
errmsg = (
|
||||
"You seem to have set SendinBlue's *inbound* webhook URL"
|
||||
" to Anymail's SendinBlue *tracking* webhook URL."
|
||||
"You seem to have set Brevo's *inbound* webhook URL"
|
||||
" to Anymail's Brevo *tracking* webhook URL."
|
||||
)
|
||||
with self.assertRaisesMessage(AnymailConfigurationError, errmsg):
|
||||
self.client.post(
|
||||
"/anymail/sendinblue/tracking/",
|
||||
"/anymail/brevo/tracking/",
|
||||
content_type="application/json",
|
||||
data={"items": []},
|
||||
)
|
||||
2
tox.ini
2
tox.ini
@@ -59,6 +59,7 @@ setenv =
|
||||
# (resend should work with or without its extras, so it isn't in `none`)
|
||||
none: ANYMAIL_SKIP_TESTS=amazon_ses,postal
|
||||
amazon_ses: ANYMAIL_ONLY_TEST=amazon_ses
|
||||
brevo: ANYMAIL_ONLY_TEST=brevo
|
||||
mailersend: ANYMAIL_ONLY_TEST=mailersend
|
||||
mailgun: ANYMAIL_ONLY_TEST=mailgun
|
||||
mailjet: ANYMAIL_ONLY_TEST=mailjet
|
||||
@@ -68,7 +69,6 @@ setenv =
|
||||
resend: ANYMAIL_ONLY_TEST=resend
|
||||
sendgrid: ANYMAIL_ONLY_TEST=sendgrid
|
||||
unisender_go: ANYMAIL_ONLY_TEST=unisender_go
|
||||
sendinblue: ANYMAIL_ONLY_TEST=sendinblue
|
||||
sparkpost: ANYMAIL_ONLY_TEST=sparkpost
|
||||
ignore_outcome =
|
||||
# CI that wants to handle errors itself can set TOX_OVERRIDE_IGNORE_OUTCOME=false
|
||||
|
||||
Reference in New Issue
Block a user