Skip to content

Commit

Permalink
Resend: tracking webhook
Browse files Browse the repository at this point in the history
  • Loading branch information
medmunds committed Oct 4, 2023
1 parent 994f106 commit eb574a6
Show file tree
Hide file tree
Showing 5 changed files with 625 additions and 2 deletions.
6 changes: 6 additions & 0 deletions anymail/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from .webhooks.mandrill import MandrillCombinedWebhookView
from .webhooks.postal import PostalInboundWebhookView, PostalTrackingWebhookView
from .webhooks.postmark import PostmarkInboundWebhookView, PostmarkTrackingWebhookView
from .webhooks.resend import ResendTrackingWebhookView
from .webhooks.sendgrid import SendGridInboundWebhookView, SendGridTrackingWebhookView
from .webhooks.sendinblue import (
SendinBlueInboundWebhookView,
Expand Down Expand Up @@ -104,6 +105,11 @@
PostmarkTrackingWebhookView.as_view(),
name="postmark_tracking_webhook",
),
path(
"resend/tracking/",
ResendTrackingWebhookView.as_view(),
name="resend_tracking_webhook",
),
path(
"sendgrid/tracking/",
SendGridTrackingWebhookView.as_view(),
Expand Down
192 changes: 192 additions & 0 deletions anymail/webhooks/resend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
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 = "webhook_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-tag":
tags.append(header["value"])
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,
)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ mailgun = []
mailjet = []
mandrill = []
postmark = []
resend = []
resend = ["svix"]
sendgrid = []
sendinblue = []
sparkpost = []
Expand Down
Loading

0 comments on commit eb574a6

Please sign in to comment.