Skip to content

Commit d05f448

Browse files
originellmedmunds
andauthored
Brevo: support proxy open, complained, error events
Add support for Brevo's new "Complained," "Error" and "Loaded by proxy" events in Brevo tracking webhook. Closes #385. --------- Co-authored-by: Mike Edmunds <[email protected]>
1 parent 5e689cd commit d05f448

File tree

4 files changed

+186
-13
lines changed

4 files changed

+186
-13
lines changed

CHANGELOG.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,18 @@ Release history
2626
.. This extra heading level keeps the ToC from becoming unmanageably long
2727
2828
29+
vNext
30+
-----
31+
32+
*unreleased changes*
33+
34+
Features
35+
~~~~~~~~
36+
37+
* **Brevo:** Support Brevo's new "Complaint," "Error" and "Loaded by proxy"
38+
tracking events. (Thanks to `@originell`_ for the update.)
39+
40+
2941
v11.0.1
3042
-------
3143

@@ -1695,6 +1707,7 @@ Features
16951707
.. _@mounirmesselmeni: https://github.com/mounirmesselmeni
16961708
.. _@mwheels: https://github.com/mwheels
16971709
.. _@nuschk: https://github.com/nuschk
1710+
.. _@originell: https://github.com/originell
16981711
.. _@puru02: https://github.com/puru02
16991712
.. _@RignonNoel: https://github.com/RignonNoel
17001713
.. _@sblondon: https://github.com/sblondon

anymail/webhooks/brevo.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,23 +40,34 @@ def parse_events(self, request):
4040
)
4141
return [self.esp_to_anymail_event(esp_event)]
4242

43+
# Map Brevo event type -> Anymail normalized (event type, reject reason).
4344
event_types = {
44-
# Map Brevo event type: Anymail normalized (event type, reject reason)
45-
# received even if message won't be sent (e.g., before "blocked"):
45+
# Treat "request" as QUEUED rather than SENT, because it may be received
46+
# even if message won't actually be sent (e.g., before "blocked").
4647
"request": (EventType.QUEUED, None),
4748
"delivered": (EventType.DELIVERED, None),
4849
"hard_bounce": (EventType.BOUNCED, RejectReason.BOUNCED),
4950
"soft_bounce": (EventType.BOUNCED, RejectReason.BOUNCED),
5051
"blocked": (EventType.REJECTED, RejectReason.BLOCKED),
5152
"spam": (EventType.COMPLAINED, RejectReason.SPAM),
53+
"complaint": (EventType.COMPLAINED, RejectReason.SPAM),
5254
"invalid_email": (EventType.BOUNCED, RejectReason.INVALID),
5355
"deferred": (EventType.DEFERRED, None),
54-
"opened": (EventType.OPENED, None), # see also unique_opened below
56+
# Brevo has four types of opened events:
57+
# - "unique_opened": first time opened
58+
# - "opened": subsequent opens
59+
# - "unique_proxy_opened": first time opened via proxy (e.g., Apple Mail)
60+
# - "proxy_open": subsequent opens via proxy
61+
# Treat all of these as OPENED.
62+
"unique_opened": (EventType.OPENED, None),
63+
"opened": (EventType.OPENED, None),
64+
"unique_proxy_open": (EventType.OPENED, None),
65+
"proxy_open": (EventType.OPENED, None),
5566
"click": (EventType.CLICKED, None),
5667
"unsubscribe": (EventType.UNSUBSCRIBED, None),
57-
# shouldn't occur for transactional messages:
68+
"error": (EventType.FAILED, None),
69+
# ("list_addition" shouldn't occur for transactional messages.)
5870
"list_addition": (EventType.SUBSCRIBED, None),
59-
"unique_opened": (EventType.OPENED, None), # first open; see also opened above
6071
}
6172

6273
def esp_to_anymail_event(self, esp_event):

docs/esps/brevo.rst

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -332,20 +332,36 @@ Be sure to select the checkboxes for all the event types you want to receive. (A
332332
sure you are in the "Transactional" section of their site; Brevo has a separate set
333333
of "Campaign" webhooks, which don't apply to messages sent through Anymail.)
334334

335-
If you are interested in tracking opens, note that Brevo has both "First opening"
336-
and an "Known open" event types. The latter seems to be generated only for the second
337-
and subsequent opens. Anymail normalizes both types to "opened." To track unique opens
338-
enable only "First opening," or to track all message opens enable both. (Brevo used to
339-
deliver both events for the first open, so be sure to check their current behavior
340-
if duplicate first open events might cause problems for you. You might be able to use
341-
the event timestamp to de-dupe.)
335+
If you are interested in tracking opens, note that Brevo has four different
336+
open event types:
337+
338+
* "First opening": the first time a message is opened by a particular recipient.
339+
(Brevo event type "opened")
340+
* "Known open": the second and subsequent opens. (Brevo event type "unique_opened")
341+
* "Loaded by proxy": a message's tracking pixel is loaded by a proxy service
342+
intended to protect users' IP addresses. See Brevo's article on
343+
`Apple's Mail Privacy Protection`_ for more details. As of July, 2024, Brevo
344+
seems to deliver this event only for the second and subsequent loads by the
345+
proxy service. (Brevo event type "proxy_open")
346+
* "First open but loaded by proxy": the first time a message's tracking pixel
347+
is loaded by a proxy service for a particular recipient. As of July, 2024,
348+
this event has not yet been exposed in Brevo's webhook control panel, and
349+
you must contact Brevo support to enable it. (Brevo event type "unique_proxy_opened")
350+
351+
Anymail normalizes all of these to "opened." If you need to distinguish the
352+
specific Brevo event types, examine the raw
353+
:attr:`~anymail.signals.AnymailTrackingEvent.esp_event`, e.g.:
354+
``if event.esp_event["event"] == "unique_opened": …``.
342355

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

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

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

375+
.. versionchanged:: 11.1
376+
377+
Added support for Brevo's "Complaint," "Error" and "Loaded by proxy" events.
378+
359379

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

362384

363385
.. _brevo-inbound:

tests/test_brevo_webhooks.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,36 @@ def test_spam(self):
226226
)
227227
event = kwargs["event"]
228228
self.assertEqual(event.event_type, "complained")
229+
self.assertEqual(event.reject_reason, "spam")
230+
231+
def test_complaint(self):
232+
# Sadly, this is not well documented in the official Brevo API documentation.
233+
raw_event = {
234+
"event": "complaint",
235+
"email": "[email protected]",
236+
"id": "xxxxx",
237+
"date": "2020-10-09 00:00:00",
238+
"ts": 1604933619,
239+
"message-id": "[email protected]",
240+
"ts_event": 1604933654,
241+
"X-Mailin-custom": '{"meta": "data"}',
242+
"tags": ["transac_messages"],
243+
}
244+
response = self.client.post(
245+
"/anymail/brevo/tracking/",
246+
content_type="application/json",
247+
data=json.dumps(raw_event),
248+
)
249+
self.assertEqual(response.status_code, 200)
250+
kwargs = self.assert_handler_called_once_with(
251+
self.tracking_handler,
252+
sender=BrevoTrackingWebhookView,
253+
event=ANY,
254+
esp_name="Brevo",
255+
)
256+
event = kwargs["event"]
257+
self.assertEqual(event.event_type, "complained")
258+
self.assertEqual(event.reject_reason, "spam")
229259

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

291+
def test_error_email(self):
292+
# Sadly, this is not well documented in the official Brevo API documentation.
293+
raw_event = {
294+
"event": "error",
295+
"email": "[email protected]",
296+
"id": "xxxxx",
297+
"date": "2020-10-09 00:00:00",
298+
"ts": 1604933619,
299+
"message-id": "[email protected]",
300+
"ts_event": 1604933654,
301+
"subject": "My first Transactional",
302+
"X-Mailin-custom": '{"meta": "data"}',
303+
"template_id": 22,
304+
"tags": ["transac_messages"],
305+
"ts_epoch": 1604933623,
306+
}
307+
response = self.client.post(
308+
"/anymail/brevo/tracking/",
309+
content_type="application/json",
310+
data=json.dumps(raw_event),
311+
)
312+
self.assertEqual(response.status_code, 200)
313+
kwargs = self.assert_handler_called_once_with(
314+
self.tracking_handler,
315+
sender=BrevoTrackingWebhookView,
316+
event=ANY,
317+
esp_name="Brevo",
318+
)
319+
event = kwargs["event"]
320+
self.assertEqual(event.event_type, "failed")
321+
self.assertEqual(event.reject_reason, None)
322+
261323
def test_deferred_event(self):
262324
# Note: the example below is an actual event capture (with 'example.com'
263325
# substituted for the real receiving domain). It's pretty clearly a bounce, not
@@ -341,6 +403,71 @@ def test_unique_opened_event(self):
341403
event = kwargs["event"]
342404
self.assertEqual(event.event_type, "opened")
343405

406+
def test_proxy_open_event(self):
407+
# Equivalent to "Loaded via Proxy" in the Brevo UI.
408+
# This is sent when a tracking pixel is loaded via a 'privacy proxy server'.
409+
# This technique is used by Apple Mail, for example, to protect user's IP
410+
# addresses.
411+
raw_event = {
412+
"event": "proxy_open",
413+
"email": "[email protected]",
414+
"id": 1,
415+
"date": "2020-10-09 00:00:00",
416+
"message-id": "[email protected]",
417+
"subject": "My first Transactional",
418+
"tag": ["transactionalTag"],
419+
"sending_ip": "xxx.xxx.xxx.xxx",
420+
"s_epoch": 1534486682000,
421+
"template_id": 1,
422+
}
423+
response = self.client.post(
424+
"/anymail/brevo/tracking/",
425+
content_type="application/json",
426+
data=json.dumps(raw_event),
427+
)
428+
self.assertEqual(response.status_code, 200)
429+
kwargs = self.assert_handler_called_once_with(
430+
self.tracking_handler,
431+
sender=BrevoTrackingWebhookView,
432+
event=ANY,
433+
esp_name="Brevo",
434+
)
435+
event = kwargs["event"]
436+
self.assertEqual(event.event_type, "opened")
437+
438+
def test_unique_proxy_open_event(self):
439+
# Sadly, undocumented in Brevo.
440+
# Equivalent to "First Open but loaded via Proxy".
441+
# This is sent when a tracking pixel is loaded via a 'privacy proxy server'.
442+
# This technique is used by Apple Mail, for example, to protect user's IP
443+
# addresses.
444+
raw_event = {
445+
"event": "unique_proxy_open",
446+
"email": "[email protected]",
447+
"id": 1,
448+
"date": "2020-10-09 00:00:00",
449+
"message-id": "[email protected]",
450+
"subject": "My first Transactional",
451+
"tag": ["transactionalTag"],
452+
"sending_ip": "xxx.xxx.xxx.xxx",
453+
"s_epoch": 1534486682000,
454+
"template_id": 1,
455+
}
456+
response = self.client.post(
457+
"/anymail/brevo/tracking/",
458+
content_type="application/json",
459+
data=json.dumps(raw_event),
460+
)
461+
self.assertEqual(response.status_code, 200)
462+
kwargs = self.assert_handler_called_once_with(
463+
self.tracking_handler,
464+
sender=BrevoTrackingWebhookView,
465+
event=ANY,
466+
esp_name="Brevo",
467+
)
468+
event = kwargs["event"]
469+
self.assertEqual(event.event_type, "opened")
470+
344471
def test_clicked_event(self):
345472
raw_event = {
346473
"event": "click",

0 commit comments

Comments
 (0)