Brevo: add inbound support

(Also adds "responses" to test requirements,
for mocking fetches of Brevo inbound
attachments.)

Closes #322
This commit is contained in:
Mike Edmunds
2023-07-27 18:13:10 -07:00
parent 0ac248254e
commit c8a5e13c89
8 changed files with 428 additions and 7 deletions

View File

@@ -1,18 +1,41 @@
import json
from datetime import datetime, timezone
from email.utils import unquote
from urllib.parse import quote, urljoin
from ..signals import AnymailTrackingEvent, EventType, RejectReason, tracking
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 SendinBlueTrackingWebhookView(AnymailBaseWebhookView):
class SendinBlueBaseWebhookView(AnymailBaseWebhookView):
esp_name = "SendinBlue"
class SendinBlueTrackingWebhookView(SendinBlueBaseWebhookView):
"""Handler for SendinBlue delivery and engagement tracking webhooks"""
esp_name = "SendinBlue"
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(
"You seem to have set SendinBlue's *inbound* webhook URL "
"to Anymail's SendinBlue *tracking* webhook URL."
)
return [self.esp_to_anymail_event(esp_event)]
# SendinBlue's webhook payload data doesn't seem to be documented anywhere.
@@ -88,3 +111,108 @@ class SendinBlueTrackingWebhookView(AnymailBaseWebhookView):
user_agent=None,
click_url=esp_event.get("link"),
)
class SendinBlueInboundWebhookView(SendinBlueBaseWebhookView):
"""Handler for SendinBlue inbound email webhooks"""
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 n inbound webhook post
raise AnymailConfigurationError(
"You seem to have set SendinBlue's *tracking* webhook URL "
"to Anymail's SendinBlue *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 SendinBlue 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"),
)