Connect an email sender
Kestrel treats the email provider as transport behind a two-method seam (sendBatch + parseWebhook); the app owns the list, consent, deliveries, and suppressions itself (docs/SPEC.md §9). Two adapters ship: SES (the default) and Resend. Pick one per environment with the PROVIDER var and set that provider’s credentials as Worker secrets.
The webhook is what closes the loop: a hard bounce or a complaint arrives from the provider and suppresses the address on its own. Set it up — a sender without a working bounce/complaint webhook degrades its own deliverability.
The staging rule
Staging must not be able to mail a stranger. Use the provider’s sandbox (SES) or a test domain, and send only to addresses you own and have verified. Real subscribers belong to production only. (Development can’t reach an inbox at all — it runs the fake transport.)
Option A — Amazon SES
SESv2 SendEmail over HTTPS, SigV4-signed (src/providers/ses.ts). Events arrive through SNS.
1. Verify the sending identity
In the SES console (in your AWS_REGION), verify the domain send.example.com as a sending identity — a domain identity, not just a single address, so any …@send.example.com From works and DKIM can be domain-signed. Verifying a domain requires publishing DNS records; do that in Sending-domain DNS next.
2. Leave the sandbox
A new SES account is in the sandbox: it can only send to verified addresses and has a tiny quota. For staging that is fine (it matches the staging rule). For production, request production access from the SES console (Account dashboard → Request production access) and wait for approval before pointing real subscribers at it.
3. Wire the bounce/complaint webhook (config set → SNS → the app)
Create an SES configuration set and set it as
SES_CONFIGURATION_SET(below). The adapter attaches it to every send so events are published.Create an SNS topic for the events.
In the configuration set, add an event destination → SNS → your topic, subscribed to at least Bounce, Complaint, and (optionally) Delivery.
Add an HTTPS subscription on the topic pointing at the app’s SES webhook:
https://newsletter.example.com/webhooks/sesThe route is
/webhooks/ses(seesrc/routes/webhooks.ts) — not/webhooks/email.The subscription-confirmation handshake is automatic. When you add the HTTPS subscription, SNS immediately POSTs a
SubscriptionConfirmationto the endpoint. The app verifies the SNS signature and completes the handshake for you by fetching theSubscribeURL(host-pinned tosns.*.amazonaws.com); there is nothing to click. The subscription flips to Confirmed on its own. Every subsequent event is signature-verified before it touches the database.
4. Credentials and vars
Create an IAM principal allowed to call SESv2 SendEmail, then set:
npx wrangler secret put AWS_ACCESS_KEY_ID --env production
npx wrangler secret put AWS_SECRET_ACCESS_KEY --env production
npx wrangler secret put SES_CONFIGURATION_SET --env production # the config set from step 3
npx wrangler secret put SNS_TOPIC_ARN --env production # the topic ARN from step 3
AWS_REGION and FROM_ADDRESS are public vars in wrangler.jsonc; set PROVIDER to ses for that environment.
Option B — Resend
Resend’s REST API with Svix-signed webhooks (src/providers/resend.ts).
1. Verify the domain
In the Resend dashboard, add and verify send.example.com as a sending domain. Verification publishes SPF/DKIM (and a return-path) records — see Sending-domain DNS.
2. API key
Create a Resend API key and set it:
npx wrangler secret put RESEND_API_KEY --env production
3. Webhook
In Resend, add a webhook pointing at:
https://newsletter.example.com/webhooks/resend
Subscribe it to the delivery, bounce, and complaint events. Resend signs each delivery with Svix; copy the webhook’s signing secret and set it — the adapter verifies every webhook against it before applying events:
npx wrangler secret put RESEND_WEBHOOK_SECRET --env production
Set PROVIDER to resend for that environment.
After either provider
Redeploy so the new PROVIDER and secrets take effect:
npm run deploy -- --env production
Then prove the whole round-trip — a real test send, one-click unsubscribe, and a bounce that suppresses — in Verify it works.