Skip to content
EtherCorps
Blog What building an email platform taught me about where Cloudflare's edge pushes back 21 min read
Product engineering · Webdev July 2026

What building an email platform taught me about where Cloudflare's edge pushes back

I've spent the last few months building Doota — a self-hosted email application that runs entirely on Cloudflare.

Written by Shivam Meena · EtherCorps

A log: Workers, D1, R2, Queues and SvelteKit — and the eight places the platform said no.

I've spent the last few weeks building Doota — a self-hosted email application that runs entirely on Cloudflare. Workers for compute, D1 for relational state, R2 for message storage, Queues for both mail directions, Email Routing at the edge. SvelteKit on the front.

The project is simple: email threads are conversations pretending to be documents. Every reply drags the entire history along, so finding the one new sentence means scrolling past four levels of On Tue, someone wrote:. Doota renders threads the way a chat app does — bubbles, quoted history stripped, the message and nothing else.

This post isn't really about the product. It's about the eight places the platform pushed back, because that's where every interesting decision came from.

Cloudflare was not the easy choice for stateful email. Email is heavy, unbounded and stubbornly stateful; the edge is optimised for the opposite. Almost every decision below exists because a platform limit made the obvious approach wrong — and in most cases the limit turned out to be load-bearing rather than arbitrary.

The stack

LayerChoice
FrameworkSvelteKit, Svelte 5 runes
ComputeCloudflare Workers (app, mail-in, mail-out)
RelationalD1 + Drizzle
BlobsR2 — raw RFC 5322, attachments, derived render cache
AsyncCloudflare Queues, both directions
Mail edgeCloudflare Email Routing + Email Service
AuthBetter Auth
UIshadcn-svelte + Tailwind

Core tenancy model: one organization = one domain. Every mail table carries org_id. That single decision matters more than it looks like it should — it comes back twice below.


Part 1 — What we learned

1. An ingest path that cannot drop mail

Cloudflare Email Routing can invoke a Worker's email() handler for incoming mail. The tempting shape is to do the work right there: parse the MIME, resolve the thread, strip the quoted history, write the rows, update the search index.

It reads well and it's completely wrong.

Email has no retry-later UI. If your handler throws because a downstream step is slow, or because you shipped a bad deploy four minutes ago, the message is gone — and the sender's mail server may or may not tell them. Unlike an HTTP request, there's no user sitting there to hit refresh.

So the handler does almost nothing:

  1. Resolve the recipient against a cached lookup.
  2. Stream the raw RFC 5322 blob into R2.
  3. Enqueue a pointer.
  4. Accept.

Everything expensive — parsing, threading, quote-stripping, encryption, indexing — happens in a Queue consumer.

The property this buys is worth stating precisely: a backlog or an outage now affects processing latency, never receipt. If the consumer is broken for an hour, mail still arrives and still lands in R2. It just becomes visible in the mailbox later.

The queue is a shock absorber, and the durable write happens before the ack.

The trade is eventual consistency between "received" and "visible" — plus an idempotency tax on every consumer step, which turned out to be the next problem.

2. Idempotency when the database has no transactions

D1 has no multi-statement transactions. You can't wrap a handful of writes in BEGIN/COMMIT and know they land together or not at all.

That matters more here than it would elsewhere, because inbound email is legitimately duplicated. Email Routing invokes the handler once per recipient — a message addressed to three of your addresses arrives as three separate invocations of the same message. Add at-least-once queue redelivery on top, and the consumer has to assume it will see the same work repeatedly, sometimes concurrently.

Without transactions, you stop trying to make writes atomic and start making them converge:

  • The shared message row is keyed by a unique index on (org_id, message_id_header). First writer creates it; every later recipient upserts into the same row. Content stored once, deduped.
  • Each recipient contributes its own delivery row, unique on (message_id, mailbox_id, role). Three recipients, three rows, zero coordination needed.
  • Per-mailbox thread state is unique on (thread_id, mailbox_id). Same idea.

A redelivered job re-runs every step and lands in exactly the same state. Nothing duplicates, and no lock is ever required.

The cost is that this can't be bolted on. You can't retrofit idempotency onto a schema with no natural unique keys — you'd have to invent them, and by then you have data.

On a store without transactions, your unique constraints are your concurrency control. Which makes schema design and correctness the same activity.

3. Deciding what is truth and what is derived

Email is lossy to re-encode. The moment you parse a message, strip its quoted history and decide how to render it, you've made a pile of judgment calls you can't cleanly reverse. And you will want to revisit them — quote-stripping in particular is never finished. Every mail client quotes differently, and Outlook quotes differently from itself depending on version.

So we drew a hard line. The raw MIME blob in R2 is canonical truth. Everything in D1 — the stripped body, the threading links, the content-kind classification, the search tokens — is a derived, regenerable index. No derived field is ever the only copy of anything.

This is JMAP's model, and Dovecot and Cyrus's before it. The payoff is concrete: when we improved the quote-stripper last week, we re-derived from the originals and every historical message upgraded. No migration, no loss, no "messages before March render badly forever."

Underneath it sits a split we got wrong on the first pass. The obvious schema is one row per email. The correct one separates three different scopes:

  • messages — shared, immutable, deduped by Message-ID. The content.
  • deliveries — per mailbox, per message. Receipt: role (to/cc/bcc), via-alias, subaddress tag.
  • thread_states — per mailbox, per thread. Triage: folder placement, starred, assignee.

Our first schema put folder placement on the delivery row. That's wrong, and the wrongness is subtle: archiving is something you do to a conversation, not to one message inside it. Once placement moved to thread-level state, un-archive-on-reply and per-mailbox assignment both became trivial instead of awkward.

There's a fourth table that arrived later, and shared mailboxes force it: thread_read, a per-user read cursor. In a shared support@, one teammate opening a thread must not clear the unread dot for everyone else. Read state is per-user; triage is per-mailbox. Two different things that look like one until there's a second person in the inbox.

4. The 2 MB row that isn't a scaling limit

D1 caps any single row, string or BLOB at 2 MB.

That reads like a scaling concern. It's a correctness bug.

A marketing email with base64 images inlined into the HTML clears 2 MB comfortably. The D1 write fails, the raw message sits happily in R2, and the message never materialises into anyone's mailbox. It fires on ordinary real-world mail — roughly one message in several hundred — and it fails silently unless you're specifically looking for it.

The fix follows from raw-is-truth: bodies live in R2, D1 holds a short preview for the thread list, and the full body is derived on open. We shipped that this week, along with render flags (html_kind, has_remote_images) computed once at ingest so the list view never touches R2 at all.

That fix also moved the storage ceiling by more than an order of magnitude. With bodies in D1 you're looking at roughly 30–40 KB per message and a wall somewhere around a couple of hundred thousand messages. With bodies in R2 it's more like 1–3 KB per message — millions of messages, which for a self-hosted team instance is years of headroom.

5. Rows read per execution is the metric

D1's dashboard shows total rows read per query. That number is nearly useless on its own. The number that tells you whether a query's shape is right is rows read divided by executions.

Here's what our analytics looked like a few days ago:

QueryExecutionsRows readPer executionRows returned
Recipient autocomplete14178,21055550
Draft GC sweep1,54543,060280
Unread count3821,4405641

The absolute numbers are trivial — we were using roughly 0.003% of the 25 billion monthly rows included on Workers Paid. But three of the six hottest queries were O(table size) rather than O(rows returned), which is fine at a hundred messages and visibly broken at fifty thousand.

Two of them were an index away. The draft sweep had no index on (status, updated_at), so a garbage-collection query that returns nothing was scanning the table 1,545 times a day. Adding the index and gating the cron to daily took it from 288 runs a day to one.

The third couldn't be fixed with an index at all. Recipient autocomplete derived "who have I corresponded with" by grouping over the entire delivery history, every time someone opened the composer. Filter on one table, sort by an aggregate of another — no index serves both.

That one needed a table, not an index. We materialised a correspondent table keyed (mailbox_id, address) with last_seen_at, upserted in the inbound consumer and on send. Autocomplete became an indexed prefix scan over a few hundred rows instead of a group-by over all mail ever received.

The lesson generalises: if a query recomputes an aggregate on every call, indexing it is treating the symptom. And because D1 is single-threaded, one slow query blocks everything else — query shape matters more here than it would on Postgres.

6. Searching content you can't read

We wanted message content encrypted at rest, and we wanted full-text search. Those two goals fight, because a full-text index over plaintext is a plaintext copy of everything you just encrypted. Encrypting the body and then building a normal FTS index over it is security theatre.

D1 supports SQLite's FTS5, which gave us an opening. Instead of indexing words, we HMAC each word with a dedicated search key and index the resulting opaque tokens. The index stores hashes. A query gets hashed the same way and matched against them. The database never holds a searchable word.

Two honest caveats, because both are real.

This is exact-word matching only. No prefix search, no fuzzy matching, no ranking by term frequency — hashes destroy the structure all of those rely on. For an email client that's a genuine downgrade from what people expect. We made the trade deliberately, but a reader deserves to see it named rather than glossed over.

Blind tokens are not a silver bullet. They hide word content, not word distribution. An attacker holding the database can still see that one token appears in four hundred messages and another appears once, and English word frequencies are extremely well characterised. Blind tokens raise the cost of casual database access substantially; they do not make the index information-free.

One implementation detail that cost more than it should have: a full hex HMAC is 64 characters. Storing a 64-byte token in place of a word that averaged five bytes inflates the index by more than 10× — the search index can end up larger than the content it indexes. Truncating to 16–24 hex characters keeps collisions negligible for exact matching and cuts index size by well over half.

7. Bootstrapping trust before any mail exists

I didn't see this one coming, and it's my favourite problem in the project.

The first administrator has to be created before any domain is onboarded — which means before the system can send or receive a single email. The reflexive design is "create the account, email a verification link." That's a circular lock: the verification email has to travel through a mail system that doesn't exist yet, to prove control of an address on a domain that isn't wired up.

I went around in circles looking for a clever sequencing fix before noticing I was solving the wrong problem.

Email verification exists to prove control of an email address. But the first admin doesn't need to prove that to anyone — they need to prove they're the operator, and they've already proven it more strongly than email ever could, by having deploy access and holding the instance secrets. Anyone who can deploy the Worker and read its secrets is, definitionally, more authenticated than anyone who could click a link in a mailbox.

So genesis trusts deploy access. First-run setup creates the initial superadmin with a password and TOTP, records an external email as an unverified attribute, and sends nothing. Verification is deferred until a domain is live and a sending path actually exists. A CLI path does the same job with no web layer and no mail at all, which doubles as the permanent recovery hatch.

The generalisable version: pick a trust root that already exists at the moment you need it, rather than inventing one that depends on the thing you're bootstrapping. Every chicken-and-egg auth problem I've hit since has had the same shape and the same answer.

8. The ceiling you design for before you hit it

A D1 database caps at 10 GB.

The interesting question is why, because understanding that changed how I felt about it. A D1 database is SQLite running inside a Durable Object. That's what gives you edge-local reads, Time Travel point-in-time recovery, and the ability to relocate a database between colos — and every one of those properties degrades as the file grows. It's single-threaded, so throughput is tied directly to query duration, and query duration grows with index size.

The cap isn't Cloudflare rationing storage. It's the boundary inside which D1's guarantees hold. Raising it would hand you a worse database. That's why the answer is sharding rather than a support ticket, and why the docs describe horizontal scale-out across many smaller databases as the intended pattern.

Which our data model was already ready for, almost by accident. Because organization = one domain and every mail table carries org_id, there are essentially no cross-organization queries. Sharding per organization is a natural move rather than a rewrite. We added a getDbForOrg() accessor that today just returns the single database and ignores its argument — an afternoon of work that turns a future migration into a change in one function instead of a hunt across every query site in the codebase.

One wrinkle forced a distinction we hadn't planned: the auth tables can't shard the same way. Sessions are inherently cross-organization — one user can hold sessions across several domains simultaneously — so those live in a single small control-plane database while mail data shards as the data plane. The split emerged from the constraint, but it's the right architecture regardless.

Know where your storage breaks, and put the seam in before you need it. Not after.


Part 2 — The SvelteKit side

Cloudflare gets most of the attention in posts like this, but a few SvelteKit decisions did just as much work.

Remote functions instead of a REST layer

SvelteKit's remote functions (.remote.ts) let you call a server function from a component with full end-to-end type safety and no hand-written API layer in between. For an app with a lot of small mutations — mark read, archive, assign, star, save draft — that removed an entire category of code.

There's no /api/threads/:id/archive route, no request/response schema to keep in sync, no client fetch wrapper. There's a function, it runs on the server, and the types flow.

The discipline that makes it safe: every remote function is an authorisation boundary. It's tempting to treat them like local calls because they look like local calls. Each one starts by resolving the caller and running a permission check, exactly as an HTTP handler would.

Runes for mail state

Svelte 5's runes suit a mail client unusually well. A thread list is derived state over a mailbox and a placement filter; a thread view is derived over a thread ID; unread counts are derived over both. $derived expresses that directly instead of via a store-subscription graph you maintain by hand.

The pattern that paid off most: keep the server DTO and the view model separate. Optimistic mutations update the view model immediately and the server response reconciles it. When a live event arrives over the WebSocket, it updates that same view model — so there's one place where reality lands, regardless of whether the change came from this tab, another tab, or another user entirely.

One permission chokepoint, and a lint rule that enforces it

Every permission check in the app routes through a single pure function, can(). It's auditable, it logs denials, and there's exactly one place to reason about authorisation.

What makes it stick is architectural rather than cultural. Better Auth exposes an internal adapter that's genuinely convenient and genuinely dangerous — it bypasses the databaseHooks/plugin pipeline, so side effects attached to "user created" or "member added" silently don't fire, and it bypasses can() entirely.

That usage had sprawled across the app. We refactored it behind a single auth boundary module with three sanctioned paths — mutations through auth.api.*, cross-cutting side effects through hooks, and reads joining auth tables with app tables confined to a repository layer — then added an ESLint no-restricted-imports rule that fails the build if anything imports Better Auth internals from outside that module.

The lint rule is the point. A convention that isn't enforced degrades the moment someone's in a hurry, and this particular failure is invisible: nothing errors, a hook just doesn't run.


Part 3 — Rendering hostile HTML

This is the part nobody writes about, and it's where a mail client is most likely to hurt its users.

Email HTML is untrusted input, authored by someone who knows you'll render it. It arrives full of conditional comments, XML namespaces, tracking pixels, and CSS that will happily leak into your application if you let it.

Everything renders inside a sandboxed iframe with a strict Content-Security-Policy — default-src 'none' plus a narrow allowlist. That's the only reliable way to stop email CSS escaping into the app.

A few things we got wrong or nearly missed:

allow-scripts plus allow-same-origin is a sandbox escape. Together they let the framed document reach up and remove its own sandbox attribute. It's one careless commit away at all times, so there's now a comment at the iframe explaining exactly why those two flags must never coexist.

CSP does not stop <meta http-equiv="refresh">. A sandboxed frame can navigate itself, so left in place, an email can silently replace the rendered body with an attacker-controlled page inside your own UI. The sanitizer has to strip it.

Proxy every remote reference, not just <img>. Our first pass covered images. Real newsletters also pull remote content through CSS backgrounds, srcset, @font-face and url() inside stylesheets — every one of them a tracking vector. We now rewrite all of them through a proxy Worker and strip @import entirely.

The image proxy is itself an SSRF vector. A Worker fetching user-supplied URLs must block private and link-local ranges — and re-check after redirects, not just on the initial URL.

Cap the node count. A deeply nested table bomb will pin the parser. We cap and fall back to the plain-text alternative. We initially set that cap at 15k nodes and had to raise it to 60k, because real newsletters are genuinely that baroque.

And the quote-stripping itself — the actual product feature under all this plumbing — now handles forwards as well as replies, across Gmail, Outlook, Apple Mail, Thunderbird and Yahoo. Each client marks quoted history differently, and Outlook disagrees with itself across versions. We store both the stripped body and the full body so nothing is ever lost, and re-quote on outbound so replies leaving Doota still thread correctly in Gmail.


Part 4 — What we've achieved

The full loop works end to end.

Receive — Email Routing → mail-in → R2 → Queue → idempotent consumer. Threading from In-Reply-To/References, quote-stripping, encryption, blind-token indexing.

Read — threads render as chat timelines; rich mail renders as cards. Attachments are served by re-extraction from the canonical raw blob rather than a second stored copy.

Sendmail-out with a JMAP EmailSubmission-shaped submission object carrying per- recipient send state, 50-recipient chunking, retries with backoff, a double-send guard, undo and scheduled send, bounce and complaint handling with a suppression list, and per-mailbox rate limiting. Internal mail short-circuits into the store with no SMTP round trip.

Collaborate — shared mailboxes with per-user read state, internal notes that are structurally incapable of reaching the outbound path, thread assignment, and @mentions.

Operate — domain onboarding through the Cloudflare API (create or link a zone, poll to active, enable routing, write DNS, onboard the sending domain), routing subdomains, subaddressing, and a health view that reads live DNS state instead of caching a stale copy.

Secure — content encrypted at rest with AES-256-GCM. As of this week that includes the R2 side: raw messages, attachments, outbound copies and the derived render cache are all gzipped and encrypted. Routing and threading metadata stays cleartext so the hot path never decrypts.

That last one has a consequence worth stating plainly for anyone self-hosting: there is no plaintext path left. Lose the encryption key and every message is permanently unrecoverable. Key backup isn't a checklist item — it's the single highest-stakes thing an operator can get wrong.


Part 5 — What we're working on

Notifications. A persistent notification log landed this week: server-owned cross-device read state, @mention notifications, and a live push over the WebSocket hub. Web Push — service worker, VAPID, subscription store — is next, so notifications fire with the app closed. (Worth knowing: iOS only delivers Web Push to installed PWAs, not Safari tabs.)

Generic outbound webhooks. Rather than building a Slack integration, we're building one signed, retried, per-mailbox webhook — and Slack becomes a docs page, alongside Discord, n8n and anyone's own endpoint. One feature, many integrations, no vendor APIs to track.

A local-first cache. The read path is D1 → R2 → decrypt → sanitize → render, which is several hops before a thread appears. An encrypted local cache keyed by a device-generated key would make it instant. The interesting constraint: a device key genuinely protects against disk access but not against XSS, and browsers evict storage — so local has to stay a cache, never the source of truth.

Further out — a CalDAV server on Workers, where standard clients provide the UI so there's no calendar app to build; and an MCP server so agents can work a mailbox. The second comes with a real warning: a mail MCP with both read and send is a textbook prompt-injection amplifier, because email content is attacker-controlled by definition. Read-only and draft-only by default.


The throughline

None of this is "Cloudflare is great." It's that building stateful systems on the edge is a real design discipline with real edges — and the edges are mostly informative.

The 2 MB row limit taught us our body storage was in the wrong place. The absence of transactions taught us that schema design and correctness are the same activity. The 10 GB cap taught us where our tenancy boundary should be. The bootstrap paradox taught us something about trust roots I'll carry into every system I build after this one.

The platform pushed back in eight places, and in seven of them it was right.


Doota is open source under Apache-2.0 — github.com/etherCorps/doota

On how this was built: I directed the architecture, and an AI agent wrote a large share of the code under that direction. Every decision above — the ingest shape, the message/state split, the search trade-off, the trust root, the sharding seam — came out of reasoning I did and defended, often by rejecting the first thing the agent proposed. Worth stating plainly rather than leaving to inference. The judgment is the scarce part, and increasingly the job.

webdevsveltesveltekitupdate
All posts

Keep reading

All posts
2026 · 03

Building ContainerKit: GUI for Apple's Container CLI with Tauri and Svelte 5

Introduction I’ve been working on ContainerKit, a desktop application designed to provide...

Product engineering · Opensource
3 min
2025 · 11

Announcing SvelteKit OG v4: An alternative to @vercel/og for sveltekit

Introduction We're thrilled to announce the official release of...

Product engineering · Webdev
6 min
EtherCorps mascot

Get in touch

I take on a small number of projects at a time.

Tell me what you're building and where it hurts. I'll say honestly whether I'm the right person for it.

Get in touch