mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 11:51:05 -05:00
- 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.
221 lines
8.4 KiB
Python
221 lines
8.4 KiB
Python
import json
|
|
from datetime import datetime, timezone
|
|
from email.utils import unquote
|
|
from urllib.parse import quote, urljoin
|
|
|
|
import requests
|
|
|
|
from ..exceptions import AnymailConfigurationError
|
|
from ..inbound import AnymailInboundMessage
|
|
from ..signals import (
|
|
AnymailInboundEvent,
|
|
AnymailTrackingEvent,
|
|
EventType,
|
|
RejectReason,
|
|
inbound,
|
|
tracking,
|
|
)
|
|
from ..utils import get_anymail_setting
|
|
from .base import AnymailBaseWebhookView
|
|
|
|
|
|
class BrevoBaseWebhookView(AnymailBaseWebhookView):
|
|
esp_name = "Brevo"
|
|
|
|
|
|
class BrevoTrackingWebhookView(BrevoBaseWebhookView):
|
|
"""Handler for Brevo delivery and engagement tracking webhooks"""
|
|
|
|
# https://developers.brevo.com/docs/transactional-webhooks
|
|
|
|
signal = tracking
|
|
|
|
def parse_events(self, request):
|
|
esp_event = json.loads(request.body.decode("utf-8"))
|
|
if "items" in esp_event:
|
|
# This is an inbound webhook post
|
|
raise AnymailConfigurationError(
|
|
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)]
|
|
|
|
event_types = {
|
|
# 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),
|
|
"hard_bounce": (EventType.BOUNCED, RejectReason.BOUNCED),
|
|
"soft_bounce": (EventType.BOUNCED, RejectReason.BOUNCED),
|
|
"blocked": (EventType.REJECTED, RejectReason.BLOCKED),
|
|
"spam": (EventType.COMPLAINED, RejectReason.SPAM),
|
|
"invalid_email": (EventType.BOUNCED, RejectReason.INVALID),
|
|
"deferred": (EventType.DEFERRED, None),
|
|
"opened": (EventType.OPENED, None), # see also unique_opened below
|
|
"click": (EventType.CLICKED, None),
|
|
"unsubscribe": (EventType.UNSUBSCRIBED, None),
|
|
# 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):
|
|
esp_type = esp_event.get("event")
|
|
event_type, reject_reason = self.event_types.get(
|
|
esp_type, (EventType.UNKNOWN, None)
|
|
)
|
|
recipient = esp_event.get("email")
|
|
|
|
try:
|
|
# 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
|
|
timestamp = datetime.fromtimestamp(
|
|
esp_event["ts_epoch"] / 1000.0, tz=timezone.utc
|
|
)
|
|
except (KeyError, ValueError):
|
|
timestamp = None
|
|
|
|
tags = []
|
|
try:
|
|
# If `tags` param set on send, webhook payload includes 'tags' array field.
|
|
tags = esp_event["tags"]
|
|
except KeyError:
|
|
try:
|
|
# If `X-Mailin-Tag` header set on send, webhook payload includes single
|
|
# 'tag' string. (If header not set, webhook 'tag' will be the template
|
|
# name for template sends.)
|
|
tags = [esp_event["tag"]]
|
|
except KeyError:
|
|
pass
|
|
|
|
try:
|
|
metadata = json.loads(esp_event["X-Mailin-custom"])
|
|
except (KeyError, TypeError):
|
|
metadata = {}
|
|
|
|
return AnymailTrackingEvent(
|
|
description=None,
|
|
esp_event=esp_event,
|
|
# Brevo doesn't provide a unique event id:
|
|
event_id=None,
|
|
event_type=event_type,
|
|
message_id=esp_event.get("message-id"),
|
|
metadata=metadata,
|
|
mta_response=esp_event.get("reason"),
|
|
recipient=recipient,
|
|
reject_reason=reject_reason,
|
|
tags=tags,
|
|
timestamp=timestamp,
|
|
user_agent=None,
|
|
click_url=esp_event.get("link"),
|
|
)
|
|
|
|
|
|
class BrevoInboundWebhookView(BrevoBaseWebhookView):
|
|
"""Handler for Brevo inbound email webhooks"""
|
|
|
|
# https://developers.brevo.com/docs/inbound-parse-webhooks#parsed-email-payload
|
|
|
|
signal = inbound
|
|
|
|
def __init__(self, **kwargs):
|
|
super().__init__(**kwargs)
|
|
# API is required to fetch inbound attachment content:
|
|
self.api_key = get_anymail_setting(
|
|
"api_key",
|
|
esp_name=self.esp_name,
|
|
kwargs=kwargs,
|
|
allow_bare=True,
|
|
)
|
|
self.api_url = get_anymail_setting(
|
|
"api_url",
|
|
esp_name=self.esp_name,
|
|
kwargs=kwargs,
|
|
default="https://api.brevo.com/v3/",
|
|
)
|
|
if not self.api_url.endswith("/"):
|
|
self.api_url += "/"
|
|
|
|
def parse_events(self, request):
|
|
payload = json.loads(request.body.decode("utf-8"))
|
|
try:
|
|
esp_events = payload["items"]
|
|
except KeyError:
|
|
# This is not an inbound webhook post
|
|
raise AnymailConfigurationError(
|
|
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]
|
|
|
|
def esp_to_anymail_event(self, esp_event):
|
|
# Inbound event's "Uuid" is documented as
|
|
# "A list of recipients UUID (can be used with the Public API)".
|
|
# In practice, it seems to be a single-item list (even when sending
|
|
# to multiple inbound recipients at once) that uniquely identifies this
|
|
# inbound event. (And works as a param for the /inbound/events/{uuid} API
|
|
# that will "Fetch all events history for one particular received email.")
|
|
try:
|
|
event_id = esp_event["Uuid"][0]
|
|
except (KeyError, IndexError):
|
|
event_id = None
|
|
|
|
attachments = [
|
|
self._fetch_attachment(attachment)
|
|
for attachment in esp_event.get("Attachments", [])
|
|
]
|
|
headers = [
|
|
(name, value)
|
|
for name, values in esp_event.get("Headers", {}).items()
|
|
# values is string if single header instance, list of string if multiple
|
|
for value in ([values] if isinstance(values, str) else values)
|
|
]
|
|
|
|
# (esp_event From, To, Cc, ReplyTo, Subject, Date, etc. are also in Headers)
|
|
message = AnymailInboundMessage.construct(
|
|
headers=headers,
|
|
text=esp_event.get("RawTextBody", ""),
|
|
html=esp_event.get("RawHtmlBody", ""),
|
|
attachments=attachments,
|
|
)
|
|
|
|
if message["Return-Path"]:
|
|
message.envelope_sender = unquote(message["Return-Path"])
|
|
if message["Delivered-To"]:
|
|
message.envelope_recipient = unquote(message["Delivered-To"])
|
|
message.stripped_text = esp_event.get("ExtractedMarkdownMessage")
|
|
|
|
# Documented as "Spam.Score" object, but both example payload
|
|
# and actual received payload use single "SpamScore" field:
|
|
message.spam_score = esp_event.get("SpamScore")
|
|
|
|
return AnymailInboundEvent(
|
|
event_type=EventType.INBOUND,
|
|
timestamp=None, # Brevo doesn't provide inbound event timestamp
|
|
event_id=event_id,
|
|
esp_event=esp_event,
|
|
message=message,
|
|
)
|
|
|
|
def _fetch_attachment(self, attachment):
|
|
# 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='')}")
|
|
response = requests.get(url, headers={"api-key": self.api_key})
|
|
response.raise_for_status() # or maybe just log and continue?
|
|
|
|
content = response.content
|
|
# Prefer response Content-Type header to attachment ContentType field,
|
|
# as the header will include charset but the ContentType field won't.
|
|
content_type = response.headers.get("Content-Type") or attachment["ContentType"]
|
|
return AnymailInboundMessage.construct_attachment(
|
|
content_type=content_type,
|
|
content=content,
|
|
filename=attachment.get("Name"),
|
|
content_id=attachment.get("ContentID"),
|
|
)
|