Skip to content

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 webhook

Accepting 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.