Skip to content

Commit

Permalink
Brevo: support proxy open, complained, error events
Browse files Browse the repository at this point in the history
Add support for Brevo's new "Complained," "Error" and 
"Loaded by proxy" events in Brevo tracking webhook.

Closes #385.

---------

Co-authored-by: Mike Edmunds <medmunds@gmail.com>
  • Loading branch information
originell and medmunds authored Jul 25, 2024
1 parent 5e689cd commit d05f448
Show file tree
Hide file tree
Showing 4 changed files with 186 additions and 13 deletions.
13 changes: 13 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ Release history
.. This extra heading level keeps the ToC from becoming unmanageably long
vNext
-----

*unreleased changes*

Features
~~~~~~~~

* **Brevo:** Support Brevo's new "Complaint," "Error" and "Loaded by proxy"
tracking events. (Thanks to `@originell`_ for the update.)


v11.0.1
-------

Expand Down Expand Up @@ -1695,6 +1707,7 @@ Features
.. _@mounirmesselmeni: https://github.com/mounirmesselmeni
.. _@mwheels: https://github.com/mwheels
.. _@nuschk: https://github.com/nuschk
.. _@originell: https://github.com/originell
.. _@puru02: https://github.com/puru02
.. _@RignonNoel: https://github.com/RignonNoel
.. _@sblondon: https://github.com/sblondon
Expand Down
21 changes: 16 additions & 5 deletions anymail/webhooks/brevo.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,23 +40,34 @@ def parse_events(self, request):
)
return [self.esp_to_anymail_event(esp_event)]

# Map Brevo event type -> Anymail normalized (event type, reject reason).
event_types = {
# Map Brevo event type: Anymail normalized (event type, reject reason)
# received even if message won't be sent (e.g., before "blocked"):
# Treat "request" as QUEUED rather than SENT, because it may be received
# even if message won't actually be sent (e.g., before "blocked").
"request": (EventType.QUEUED, None),
"delivered": (EventType.DELIVERED, None),
"hard_bounce": (EventType.BOUNCED, RejectReason.BOUNCED),
"soft_bounce": (EventType.BOUNCED, RejectReason.BOUNCED),
"blocked": (EventType.REJECTED, RejectReason.BLOCKED),
"spam": (EventType.COMPLAINED, RejectReason.SPAM),
"complaint": (EventType.COMPLAINED, RejectReason.SPAM),
"invalid_email": (EventType.BOUNCED, RejectReason.INVALID),
"deferred": (EventType.DEFERRED, None),
"opened": (EventType.OPENED, None), # see also unique_opened below
# Brevo has four types of opened events:
# - "unique_opened": first time opened
# - "opened": subsequent opens
# - "unique_proxy_opened": first time opened via proxy (e.g., Apple Mail)
# - "proxy_open": subsequent opens via proxy
# Treat all of these as OPENED.
"unique_opened": (EventType.OPENED, None),
"opened": (EventType.OPENED, None),
"unique_proxy_open": (EventType.OPENED, None),
"proxy_open": (EventType.OPENED, None),
"click": (EventType.CLICKED, None),
"unsubscribe": (EventType.UNSUBSCRIBED, None),
# shouldn't occur for transactional messages:
"error": (EventType.FAILED, None),
# ("list_addition" shouldn't occur for transactional messages.)
"list_addition": (EventType.SUBSCRIBED, None),
"unique_opened": (EventType.OPENED, None), # first open; see also opened above
}

def esp_to_anymail_event(self, esp_event):
Expand Down
38 changes: 30 additions & 8 deletions docs/esps/brevo.rst
Original file line number Diff line number Diff line change
Expand Up @@ -332,20 +332,36 @@ Be sure to select the checkboxes for all the event types you want to receive. (A
sure you are in the "Transactional" section of their site; Brevo has a separate set
of "Campaign" webhooks, which don't apply to messages sent through Anymail.)

If you are interested in tracking opens, note that Brevo has both "First opening"
and an "Known open" event types. The latter seems to be generated only for the second
and subsequent opens. Anymail normalizes both types to "opened." To track unique opens
enable only "First opening," or to track all message opens enable both. (Brevo used to
deliver both events for the first open, so be sure to check their current behavior
if duplicate first open events might cause problems for you. You might be able to use
the event timestamp to de-dupe.)
If you are interested in tracking opens, note that Brevo has four different
open event types:

* "First opening": the first time a message is opened by a particular recipient.
(Brevo event type "opened")
* "Known open": the second and subsequent opens. (Brevo event type "unique_opened")
* "Loaded by proxy": a message's tracking pixel is loaded by a proxy service
intended to protect users' IP addresses. See Brevo's article on
`Apple's Mail Privacy Protection`_ for more details. As of July, 2024, Brevo
seems to deliver this event only for the second and subsequent loads by the
proxy service. (Brevo event type "proxy_open")
* "First open but loaded by proxy": the first time a message's tracking pixel
is loaded by a proxy service for a particular recipient. As of July, 2024,
this event has not yet been exposed in Brevo's webhook control panel, and
you must contact Brevo support to enable it. (Brevo event type "unique_proxy_opened")

Anymail normalizes all of these to "opened." If you need to distinguish the
specific Brevo event types, examine the raw
:attr:`~anymail.signals.AnymailTrackingEvent.esp_event`, e.g.:
``if event.esp_event["event"] == "unique_opened": …``.

Brevo will report these Anymail :attr:`~anymail.signals.AnymailTrackingEvent.event_type`\s:
queued, rejected, bounced, deferred, delivered, opened (see note above), clicked, complained,
unsubscribed, subscribed (though this should never occur for transactional email).
failed, unsubscribed, subscribed (though subscribed should never occur for transactional email).

For events that occur in rapid succession, Brevo frequently delivers them out of order.
For example, it's not uncommon to receive a "delivered" event before the corresponding "queued."
Also, note that "queued" may be received even if Brevo will not actually send the message.
(E.g., if a recipient is on your blocked list due to a previous bounce, you may receive
"queued" followed by "rejected.")

The event's :attr:`~anymail.signals.AnymailTrackingEvent.esp_event` field will be
a `dict` of raw webhook data received from Brevo.
Expand All @@ -356,8 +372,14 @@ a `dict` of raw webhook data received from Brevo.
than "brevo". The old URL will still work, but is deprecated. See :ref:`brevo-rename`
below.

.. versionchanged:: 11.1

Added support for Brevo's "Complaint," "Error" and "Loaded by proxy" events.


.. _Transactional > Email > Settings > Webhook: https://app-smtp.brevo.com/webhook
.. _Apple's Mail Privacy Protection:
https://help.brevo.com/hc/en-us/articles/4406537065618-How-to-handle-changes-in-Apple-s-Mail-Privacy-Protection


.. _brevo-inbound:
Expand Down
127 changes: 127 additions & 0 deletions tests/test_brevo_webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,36 @@ def test_spam(self):
)
event = kwargs["event"]
self.assertEqual(event.event_type, "complained")
self.assertEqual(event.reject_reason, "spam")

def test_complaint(self):
# Sadly, this is not well documented in the official Brevo API documentation.
raw_event = {
"event": "complaint",
"email": "example@domain.com",
"id": "xxxxx",
"date": "2020-10-09 00:00:00",
"ts": 1604933619,
"message-id": "201798300811.5787683@relay.domain.com",
"ts_event": 1604933654,
"X-Mailin-custom": '{"meta": "data"}',
"tags": ["transac_messages"],
}
response = self.client.post(
"/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=BrevoTrackingWebhookView,
event=ANY,
esp_name="Brevo",
)
event = kwargs["event"]
self.assertEqual(event.event_type, "complained")
self.assertEqual(event.reject_reason, "spam")

def test_invalid_email(self):
# "If a ISP again indicated us that the email is not valid or if we discovered
Expand Down Expand Up @@ -258,6 +288,38 @@ def test_invalid_email(self):
event.mta_response, "(guessing invalid_email includes a reason)"
)

def test_error_email(self):
# Sadly, this is not well documented in the official Brevo API documentation.
raw_event = {
"event": "error",
"email": "example@domain.com",
"id": "xxxxx",
"date": "2020-10-09 00:00:00",
"ts": 1604933619,
"message-id": "201798300811.5787683@relay.domain.com",
"ts_event": 1604933654,
"subject": "My first Transactional",
"X-Mailin-custom": '{"meta": "data"}',
"template_id": 22,
"tags": ["transac_messages"],
"ts_epoch": 1604933623,
}
response = self.client.post(
"/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=BrevoTrackingWebhookView,
event=ANY,
esp_name="Brevo",
)
event = kwargs["event"]
self.assertEqual(event.event_type, "failed")
self.assertEqual(event.reject_reason, None)

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
Expand Down Expand Up @@ -341,6 +403,71 @@ def test_unique_opened_event(self):
event = kwargs["event"]
self.assertEqual(event.event_type, "opened")

def test_proxy_open_event(self):
# Equivalent to "Loaded via Proxy" in the Brevo UI.
# This is sent when a tracking pixel is loaded via a 'privacy proxy server'.
# This technique is used by Apple Mail, for example, to protect user's IP
# addresses.
raw_event = {
"event": "proxy_open",
"email": "example@domain.com",
"id": 1,
"date": "2020-10-09 00:00:00",
"message-id": "201798300811.5787683@relay.domain.com",
"subject": "My first Transactional",
"tag": ["transactionalTag"],
"sending_ip": "xxx.xxx.xxx.xxx",
"s_epoch": 1534486682000,
"template_id": 1,
}
response = self.client.post(
"/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=BrevoTrackingWebhookView,
event=ANY,
esp_name="Brevo",
)
event = kwargs["event"]
self.assertEqual(event.event_type, "opened")

def test_unique_proxy_open_event(self):
# Sadly, undocumented in Brevo.
# Equivalent to "First Open but loaded via Proxy".
# This is sent when a tracking pixel is loaded via a 'privacy proxy server'.
# This technique is used by Apple Mail, for example, to protect user's IP
# addresses.
raw_event = {
"event": "unique_proxy_open",
"email": "example@domain.com",
"id": 1,
"date": "2020-10-09 00:00:00",
"message-id": "201798300811.5787683@relay.domain.com",
"subject": "My first Transactional",
"tag": ["transactionalTag"],
"sending_ip": "xxx.xxx.xxx.xxx",
"s_epoch": 1534486682000,
"template_id": 1,
}
response = self.client.post(
"/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=BrevoTrackingWebhookView,
event=ANY,
esp_name="Brevo",
)
event = kwargs["event"]
self.assertEqual(event.event_type, "opened")

def test_clicked_event(self):
raw_event = {
"event": "click",
Expand Down

0 comments on commit d05f448

Please sign in to comment.