Resend: new ESP (#341)

Add support for Resend.com backend and webhooks.

Closes #341
This commit is contained in:
Mike Edmunds
2023-10-25 12:23:57 -07:00
committed by GitHub
parent 823a161927
commit b5ef492466
13 changed files with 1932 additions and 24 deletions

195
anymail/webhooks/resend.py Normal file
View File

@@ -0,0 +1,195 @@
import json
from datetime import datetime
from ..exceptions import (
AnymailImproperlyInstalled,
AnymailInvalidAddress,
AnymailWebhookValidationFailure,
_LazyError,
)
from ..signals import AnymailTrackingEvent, EventType, RejectReason, tracking
from ..utils import get_anymail_setting, parse_single_address
from .base import AnymailBaseWebhookView, AnymailCoreWebhookView
try:
# Valid webhook signatures with svix library if available
from svix.webhooks import Webhook as SvixWebhook, WebhookVerificationError
except ImportError:
# Otherwise, validating with basic auth is sufficient
# (unless settings specify signature validation, which will then raise this error)
SvixWebhook = _LazyError(
AnymailImproperlyInstalled(missing_package="svix", install_extra="resend")
)
WebhookVerificationError = object()
class SvixWebhookValidationMixin(AnymailCoreWebhookView):
"""Mixin to validate Svix webhook signatures"""
# Consuming classes can override (e.g., to use different secrets
# for inbound and tracking webhooks).
_secret_setting_name = "signing_secret"
@classmethod
def as_view(cls, **initkwargs):
if not hasattr(cls, cls._secret_setting_name):
# The attribute must exist on the class before View.as_view
# will allow overrides via kwarg
setattr(cls, cls._secret_setting_name, None)
return super().as_view(**initkwargs)
def __init__(self, **kwargs):
self.signing_secret = get_anymail_setting(
self._secret_setting_name,
esp_name=self.esp_name,
default=None,
kwargs=kwargs,
)
if self.signing_secret is None:
self._svix_webhook = None
self.warn_if_no_basic_auth = True
else:
# This will raise an import error if svix isn't installed
self._svix_webhook = SvixWebhook(self.signing_secret)
# Basic auth is not required if validating signature
self.warn_if_no_basic_auth = False
super().__init__(**kwargs)
def validate_request(self, request):
if self._svix_webhook:
# https://docs.svix.com/receiving/verifying-payloads/how
try:
# Note: if signature is valid, Svix also tries to parse
# the json body, so this could raise other errors...
self._svix_webhook.verify(request.body, request.headers)
except WebhookVerificationError as error:
setting_name = f"{self.esp_name}_{self._secret_setting_name}".upper()
raise AnymailWebhookValidationFailure(
f"{self.esp_name} webhook called with incorrect signature"
f" (check Anymail {setting_name} setting)"
) from error
class ResendTrackingWebhookView(SvixWebhookValidationMixin, AnymailBaseWebhookView):
"""Handler for Resend.com status tracking webhooks"""
esp_name = "Resend"
signal = tracking
def parse_events(self, request):
esp_event = json.loads(request.body.decode("utf-8"))
return [self.esp_to_anymail_event(esp_event, request)]
# https://resend.com/docs/dashboard/webhooks/event-types
event_types = {
# Map Resend type: Anymail normalized type
"email.sent": EventType.SENT,
"email.delivered": EventType.DELIVERED,
"email.delivery_delayed": EventType.DEFERRED,
"email.complained": EventType.COMPLAINED,
"email.bounced": EventType.BOUNCED,
"email.opened": EventType.OPENED,
"email.clicked": EventType.CLICKED,
}
def esp_to_anymail_event(self, esp_event, request):
event_type = self.event_types.get(esp_event["type"], EventType.UNKNOWN)
# event_id: HTTP header `svix-id` is unique for a particular event
# (including across reposts due to errors)
try:
event_id = request.headers["svix-id"]
except KeyError:
event_id = None
# timestamp: Payload created_at is unique for a particular event.
# (Payload data.created_at is when the message was created, not the event.
# HTTP header `svix-timestamp` changes for each repost of the same event.)
try:
timestamp = datetime.fromisoformat(
# Must convert "Z" to timezone offset for Python 3.10 and earlier.
esp_event["created_at"].replace("Z", "+00:00")
)
except (KeyError, ValueError):
timestamp = None
try:
message_id = esp_event["data"]["email_id"]
except (KeyError, TypeError):
message_id = None
# Resend doesn't provide bounce reasons or SMTP responses,
# but it's possible to distinguish some cases by examining
# the human-readable message text:
try:
bounce_message = esp_event["data"]["bounce"]["message"]
except (KeyError, ValueError):
bounce_message = None
reject_reason = None
else:
if "suppressed sending" in bounce_message:
# "Resend has suppressed sending to this address ..."
reject_reason = RejectReason.BLOCKED
elif "bounce message" in bounce_message:
# "The recipient's email provider sent a hard bounce message, ..."
# "The recipient's email provider sent a general bounce message. ..."
# "The recipient's email provider sent a bounce message because
# the recipient's inbox was full. ..."
reject_reason = RejectReason.BOUNCED
else:
reject_reason = RejectReason.OTHER # unknown
# Recover tags and metadata from custom headers
metadata = {}
tags = []
try:
headers = esp_event["data"]["headers"]
except KeyError:
pass
else:
for header in headers:
name = header["name"].lower()
if name == "x-tags":
try:
tags = json.loads(header["value"])
except (ValueError, TypeError):
pass
elif name == "x-metadata":
try:
metadata = json.loads(header["value"])
except (ValueError, TypeError):
pass
# For multi-recipient emails (including cc and bcc), Resend generates events
# for each recipient, but no indication of which recipient an event applies to.
# Just report the first `to` recipient.
try:
first_to = esp_event["data"]["to"][0]
recipient = parse_single_address(first_to).addr_spec
except (KeyError, IndexError, TypeError, AnymailInvalidAddress):
recipient = None
try:
click_data = esp_event["data"]["click"]
except (KeyError, TypeError):
click_url = None
user_agent = None
else:
click_url = click_data.get("link")
user_agent = click_data.get("userAgent")
return AnymailTrackingEvent(
event_type=event_type,
timestamp=timestamp,
message_id=message_id,
event_id=event_id,
recipient=recipient,
reject_reason=reject_reason,
description=bounce_message,
mta_response=None,
tags=tags,
metadata=metadata,
click_url=click_url,
user_agent=user_agent,
esp_event=esp_event,
)