Guushu Studio / Notes

Cloudflare Email Worker inbox: store every message in D1 in 30 lines

A Cloudflare Email Worker inbox in about 30 lines of TypeScript: the email() handler, D1 schema, wrangler config, and 3 self-hosted inboxes built the same way.

· updated

A Cloudflare Email Worker inbox is the answer to "Email Routing forwards mail and forgets it". Point the routing rule at a Worker instead of a mailbox, write the message to D1, forward a copy. About 30 lines of TypeScript, and three self-hosted projects (two open source, one source-available) already ship that shape.

This note is the handler, the schema, the wrangler side, and the three projects to clone instead.

The 3 self-hosted inboxes built on Email Routing

All three take the same route: an Email Worker receives the message, parses it, writes it to D1 (and R2 for attachments), and a small web UI reads it back. Licenses and stacks checked against each repo on 2026-09-16.

ProjectLicenseStackUp-front cost
cloudflare/agentic-inboxApache-2.0Workers + D1; built as an inbox that AI agents can read and act onFree plan is enough to receive; one wrangler deploy
HQBase/hqbaseAGPL-3.0Workers + D1 + R2 + Queues; sends through the Cloudflare send_email binding; AI-native team email workspaceSame deploy; R2 and Queues bindings to create
mirza-rizvi/ResolveHQSource-available (not open source)Email Routing + Resend + D1 + R2 + Queues; shared inbox, threading, AI assistIts README says it runs on Workers Free for small teams; R2 is activated separately, plus Resend DNS records for outbound

Self-hosting any of them means you own the D1 migrations, the R2 bucket, the reply path, and the pager when it breaks. If that is fine and one of them fits, clone it and skip the rest.

Why does Cloudflare Email Routing have no built-in mailbox?

Cloudflare's product is the edge, not storage. Email Routing sits at the MX layer: accept the SMTP session, apply your rules, hand the bytes to a destination. Storing mail means retention, search, quotas and abuse handling. The escape hatch is that a destination can be a Worker, and all three projects above walk through it.

The email() handler

An Email Worker exports an email() method alongside, or instead of, fetch():

import PostalMime from "postal-mime";

export default {
  async email(message: ForwardableEmailMessage, env: Env, ctx: ExecutionContext) {
    const raw = await new Response(message.raw).arrayBuffer();   // full MIME, up to 25 MiB
    const parsed = await PostalMime.parse(raw);

    await env.DB.prepare(
      "INSERT INTO messages (id, from_addr, to_addr, subject, text_body, received_at) VALUES (?,?,?,?,?,?)"
    )
      .bind(
        crypto.randomUUID(),
        message.from,
        message.to,
        parsed.subject ?? "",
        parsed.text ?? "",
        new Date().toISOString()
      )
      .run();

    ctx.waitUntil(message.forward("you@personal.example"));     // must be a verified destination
  },
};

Three things in there worth knowing before the first deploy.

**message.raw is a ReadableStream, not a string.** Wrap it in a Response to read it once. A second read is empty.

**forward() only accepts verified destinations.** Same list as the dashboard. An unverified address throws, and the sender gets a bounce. One rule maps to one destination or one Worker; fan-out to several people is forward() called once per address.

**setReject(reason) exists.** If the Worker decides the message is spam, message.setReject("...") ends the SMTP session with a 5xx. Nothing is stored or forwarded.

The wrangler config and the routing rule

{
  "name": "inbox",
  "main": "src/index.ts",
  "compatibility_date": "2026-09-01",
  "d1_databases": [{ "binding": "DB", "database_name": "inbox", "database_id": "..." }]
}

Then in the dashboard: Compute, Email Service, Email Routing, Routing Rules, your address, action Send to a Worker, pick inbox. There is no wrangler command for the rule itself; it is a dashboard click or the REST API. Renaming the Worker drops the binding, so rename first and re-point the rule after.

The D1 schema

CREATE TABLE messages (
  id TEXT PRIMARY KEY,
  from_addr TEXT NOT NULL,
  to_addr TEXT NOT NULL,
  subject TEXT NOT NULL DEFAULT '',
  text_body TEXT NOT NULL DEFAULT '',
  received_at TEXT NOT NULL
);
CREATE INDEX messages_received ON messages (received_at DESC);

D1's free tier is 5 million rows read and 100,000 rows written a day. A support address that gets 100 messages a day uses 0.1% of the write budget. The index on received_at is what keeps "latest 50" from scanning the table; the D1 bill cases in Why Cloudflare bills spike are all missing indexes.

Attachments are the one thing to push to R2 instead of D1. For text-only support mail, D1 alone is where I would start.

What you have, and what you don't

A Worker that is, functionally, a mailbox: every message persisted, searchable with SQL, still forwarded to a phone. What you do not have is a UI, threading, "who is handling this", or a way for a human to reply from support@ an hour later. For those there are the three projects at the top. The setup steps before any of this are in Cloudflare Email Routing support address.

The hosted version of this handler plus a two-person interface is a waitlist page today, not a product; the form asks which address you route: guard.guushu.com/inbox.