How it’s built
Two providers, chosen for what each is actually good at. Everything runs on Cloudflare except the part that must not.
The request path
client
│ POST /v1/emails
▼
Cloudflare Worker
├─ authenticate (KV-cached key lookup)
├─ rate limit (Durable Object token bucket)
├─ idempotency (Durable Object ledger)
├─ ownership · suppression · daily+monthly limits
└─ enqueue
▼ 202 Accepted
Cloudflare Queue
▼
Consumer Worker ──SigV4──▶ Amazon SES
│
▼
SNS → Worker → your webhookAccepting rather than sending is what makes per-tenant pacing, retry on upstream throttling, and scheduled delivery possible without holding your connection open for the duration.
Why the edge is Cloudflare
- Workers run authentication and validation within milliseconds of the caller, so an invalid request never crosses an ocean to be rejected.
- Durable Objects give a single serialisation point per organization. Idempotency needs that: two concurrent retries against an eventually-consistent store would both read “absent” and both send.
- Queues, D1, R2 and KV remove an entire tier of infrastructure — no VPC, no NAT gateways, no connection pools.
Why the MTA is Amazon SES
Sending mail is not the hard part. Reputation is, and reputation is what an established MTA provides: warmed IP pools, feedback loops with the major mailbox providers, and — critically here — tenant isolation.
Each organization maps to an isolated sending tenant with its own suppression list and its own bounce and complaint metrics. Without that, every customer shares one reputation, and a single tenant mailing a stale list degrades delivery for everyone with no way to attribute it.
An honest limitation
This means one dependency we do not control. If SES has an incident, sends queue rather than fail — messages are durable in the queue and drain when it recovers — but they do not go out. We would rather state that than imply an independence we do not have.
Choices worth explaining
Attachments are capped at 10 MB
A request isolate has 128 MB. An attachment arrives base64-encoded in JSON (+33%), becomes a UTF-16 string when parsed (×2), and is copied again during assembly. Ten megabytes is already sixty to eighty of peak memory. The cap is a real constraint, not a pricing lever; raising it needs a streaming upload path.
Templates have no logic
Variable substitution only — no loops, no conditionals, no expressions. Templates are rendered in a shared runtime alongside other tenants’ work, and anything Turing-complete there is both a sandbox-escape surface and a denial-of-service vector.
Quota counts recipients
One message to fifty people is fifty emails against reputation and against cost, so it is fifty against quota. Counting messages would make the limit trivially avoidable.
Terminal states are never downgraded
Per-recipient events arrive out of order — a delivery for one address can land after a bounce for another on the same message. Once a message is bounced or failed, a later delivery event does not overwrite it.
The unauthenticated edge
Delivery events arrive over a single public endpoint, since AWS cannot present an API key. Anything that could post to it unchecked would be able to mark messages bounced, forge your webhooks and poison suppression lists.
So the RSA signature is verified against an AWS-signed certificate, the certificate URL is checked to be AWS-owned before it is fetched, and the topic is matched against an allow-list — a valid signature from someone else’s topic is still hostile.
What is not built
Dedicated IP pools, open and click tracking on a custom domain, and team invites. Receiving is a separate application and is not part of this service.