Tracking & Webhooks¶
amsdal_mail can receive delivery and engagement events from ESPs via webhooks. Currently, AWS SES is supported (via SNS notifications).
How It Works¶
Your App → SES (sends email)
↓
SES fires event (delivered, bounced, opened, clicked, ...)
↓
SNS topic receives event
↓
SNS sends HTTP POST to your webhook endpoint
↓
amsdal_mail parses event → emits EmailTrackingEvent on EventBus
↓
Your listener handles the event
Setup¶
1. Enable Webhooks¶
export AMSDAL_MAIL_WEBHOOK_ENABLED=true
Optional settings:
| Variable | Default | Description |
|---|---|---|
AMSDAL_MAIL_WEBHOOK_ENABLED |
false |
Enable webhook endpoint |
AMSDAL_MAIL_WEBHOOK_BASE_PATH |
/webhooks/mail |
Base path for webhook routes |
AMSDAL_MAIL_WEBHOOK_SES_SECRET |
— | Optional secret for request verification |
2. Configure SES → SNS → Webhook¶
- Create an SNS topic in AWS Console
- Add an HTTP/HTTPS subscription pointing to
https://your-domain/webhooks/mail/ses - Create a Configuration Set in SES (if you don't have one)
- Add an Event Destination in the Configuration Set:
- Destination: the SNS topic
- Event types: Send, Delivery, Bounce, Complaint, Reject, Open, Click, DeliveryDelay
3. Confirm SNS Subscription¶
When you add the HTTP subscription, SNS sends a SubscriptionConfirmation request to your endpoint. The webhook handler logs the SubscribeURL — visit it to confirm the subscription.
Webhook Endpoint¶
When enabled, the plugin registers:
POST /webhooks/mail/{esp_name}
Currently the only supported esp_name is ses.
The handler:
- Verifies the request (cross-validates SNS headers against the JSON body)
- Parses the SES event into
EmailTrackingContextobjects - Emits an
EmailTrackingEventfor each via the AMSDAL EventBus
Tracking Event Types¶
| Event Type | Description |
|---|---|
queued |
Message queued for delivery |
sent |
Message sent to recipient's mail server |
delivered |
Message delivered to recipient |
deferred |
Delivery delayed (will retry) |
bounced |
Message bounced (hard or soft) |
rejected |
Message rejected by ESP |
failed |
Send failed |
opened |
Recipient opened the email |
clicked |
Recipient clicked a link |
complained |
Recipient marked as spam |
unsubscribed |
Recipient unsubscribed |
unknown |
Event type not recognized |
EmailTrackingContext¶
Each webhook event is parsed into an EmailTrackingContext:
| Field | Type | Description |
|---|---|---|
event_type |
TrackingEventType |
Event type (see table above) |
message_id |
str |
SES MessageId |
recipient |
str |
Recipient email address |
timestamp |
datetime |
When the event occurred |
esp_name |
str |
Always 'ses' for SES events |
tags |
list[str] |
Tags from the original message |
metadata |
dict[str, Any] |
Metadata from X-Metadata-* headers |
reject_reason |
str \| None |
Reason for bounce/reject |
description |
str \| None |
Diagnostic code (bounces) or delay type |
click_url |
str \| None |
Clicked URL (click events only) |
user_agent |
str \| None |
Recipient's user agent (open/click events) |
raw_event |
dict |
Full SES event JSON |
Listening for Events¶
Subscribe to EmailTrackingEvent via the AMSDAL EventBus:
from amsdal_utils.events import EventListener, NextFn, AsyncNextFn, EventBus
from amsdal_mail import EmailTrackingEvent, EmailTrackingContext
class TrackingListener(EventListener[EmailTrackingContext]):
def handle(
self,
context: EmailTrackingContext,
next_fn: NextFn[EmailTrackingContext],
) -> EmailTrackingContext:
if context.event_type == 'bounced':
print(f'Bounce: {context.recipient} — {context.reject_reason}')
elif context.event_type == 'opened':
print(f'Opened: {context.recipient}')
elif context.event_type == 'clicked':
print(f'Clicked: {context.recipient} → {context.click_url}')
return next_fn(context)
async def ahandle(
self,
context: EmailTrackingContext,
next_fn: AsyncNextFn[EmailTrackingContext],
) -> EmailTrackingContext:
return self.handle(context, next_fn) # type: ignore[arg-type]
# Register the listener
EventBus.subscribe(EmailTrackingEvent, TrackingListener)
Multi-Recipient Events¶
SES events like bounces and complaints can include multiple recipients. The webhook parser expands these into individual EmailTrackingContext instances — one per recipient — so your listener always receives a single recipient per event.
Verification¶
The SES webhook parser verifies incoming requests by cross-validating:
x-amz-sns-message-typeheader matches theTypefield in the JSON bodyx-amz-sns-message-idheader matches theMessageIdfield in the JSON body
Requests that fail verification are rejected with HTTP 403.