Skip to content

tutorials

Cloudflare Email Routing tutorial: receive inbound mail in a Worker

July 15, 2026

#cloudflare#email-routing#workers#d1#inbound-email
Cloudflare Email Routing tutorial: receive inbound mail in a Worker
Visual: Boomerang Betty on okslop

Email Routing receives your mail; a Worker decides what happens next. Here is how to turn inbound mail into rows you own.

The outbound half: why Email Routing can't send covered sending from the edge. This is the other half: receiving. Cloudflare Email Routing accepts mail at your domain and, instead of just forwarding it to Gmail, can hand each message to a Worker you control. That Worker is where inbound mail stops being a forwarded copy and becomes structured data you can query, search, and feed to an agent.

This tutorial wires the full inbound path by hand: enable routing, export an email() handler, parse the MIME, and store the result in D1 and R2. Then it shows where Tinbox already does this so you do not have to maintain the parser.

What does Cloudflare Email Routing do (and not do)?

Cloudflare Email Routing is an inbound-only service. It provisions the MX records for your domain, accepts mail at addresses you configure, and runs an action: forward to an external address, drop, or invoke a Worker. It does three things and stops there: it receives, it forwards, and it can call your code.

It does not send. Email Routing has no concept of originating a message, and the Workers runtime cannot open outbound port 25 for direct-to-MX SMTP. Anything that needs to send (replies, receipts, campaigns) uses the separate Cloudflare Email Service or another HTTPS-based sender, which is the subject of the outbound Email Service article. Keep the two products clearly separated: Email Routing is inbound; Email Service is outbound.

The piece that matters for this tutorial is the third action: "Send to a Worker." When a routing rule targets a Worker, the runtime invokes that Worker's email() handler once per delivered message.

How do I process inbound email in a Cloudflare Worker?

Export an async email() method from your Worker's default export and bind it as a routing destination. The runtime calls it with a ForwardableEmailMessage, which gives you the envelope (message.from, message.to), the parsed message.headers, and the full raw MIME as a ReadableStream on message.raw.

Here is the minimal handler. It does nothing but log, which is the right first step to confirm mail is reaching your code at all:

// src/index.ts
export interface Env {
  DB: D1Database;
  BLOBS: R2Bucket;
}

export default {
  async email(message: ForwardableEmailMessage, env: Env, ctx: ExecutionContext) {
    console.log(`inbound to=${message.to} from=${message.from}`);
    console.log(`subject=${message.headers.get("subject") ?? "(none)"}`);
    // message.raw is a ReadableStream of the full MIME message.
    // We have not read it yet.
  },
} satisfies ExportedHandler<Env>;

ForwardableEmailMessage ships in @cloudflare/workers-types, so the signature is typed for free. Note the handler returns void: by the time it runs, Cloudflare's MX has already accepted the message. Your job is the durable side effect (store it), not the SMTP transaction.

The wrangler.jsonc config needs the D1 and R2 bindings plus the Node compatibility flag (postal-mime, which we use below, needs it):

{
  "name": "inbound-mail",
  "main": "src/index.ts",
  "compatibility_date": "2026-01-01",
  "compatibility_flags": ["nodejs_compat"],
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "inbox",
      "database_id": "<your-d1-id>",
    },
  ],
  "r2_buckets": [
    {
      "binding": "BLOBS",
      "bucket_name": "inbox-blobs",
    },
  ],
}

There is no email block in wrangler.jsonc. The binding between an address and this Worker is made in the Email Routing dashboard, not in config, which is the single most common source of "my handler never runs" (see the failure section below).

To connect an address to the Worker after deploying:

Cloudflare dashboard -> your domain -> Email -> Email Routing
  -> Routing rules
  -> Catch-all address (or a custom address)
  -> Action: "Send to a Worker"
  -> Destination: inbound-mail
  -> Save

A catch-all rule routes every address at the domain to the Worker. A custom-address rule routes one address. For an inbox product you usually want catch-all, then decide per-recipient inside the handler.

How do I parse inbound MIME in the handler?

Buffer message.raw into an ArrayBuffer, then hand it to a real MIME parser. Do not try to parse MIME with string splitting: multipart nesting, transfer encodings (base64, quoted-printable), and inline images will all bite you. Use postal-mime, which runs on the Workers runtime with nodejs_compat.

First, buffer the stream:

async function streamToArrayBuffer(stream: ReadableStream<Uint8Array>): Promise<ArrayBuffer> {
  const reader = stream.getReader();
  const chunks: Uint8Array[] = [];
  let total = 0;
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    chunks.push(value);
    total += value.length;
  }
  const out = new Uint8Array(total);
  let offset = 0;
  for (const chunk of chunks) {
    out.set(chunk, offset);
    offset += chunk.length;
  }
  return out.buffer;
}

Then parse. Tinbox wraps postal-mime in a typed parseEmail that normalizes addresses (lowercased), the Message-ID (angle-bracketed), the References chain, and attachments into one ParsedEmail shape:

import { parseEmail } from "@tinbox/engine/mime";

const raw = await streamToArrayBuffer(message.raw);
const parsed = await parseEmail(raw);

// parsed: { messageId, inReplyTo, references[], from, to[], cc[],
//           subject, date, text, html, attachments[], headers }

The shape that comes back is the one you actually want to store. The address fields are flattened and lowercased, references is an oldest-to-newest array of normalized IDs (which is what you thread on), and attachments carries each part's bytes, MIME type, and disposition so you can route inline images and real attachments differently. If you would rather not depend on Tinbox, postal-mime's own PostalMime.parse(raw) returns a similar object; you will just re-derive the normalization yourself.

Where should I store the parsed mail?

Split it: structured fields into D1 for querying, raw bytes into R2 for fidelity. D1 (SQLite at the edge) holds the columns you filter and search on (from, to, subject, date, message_id, a body snapshot, a thread id). R2 holds the original .eml and each attachment, because you never want to reconstruct an exact message from parsed-and-reserialized fields, and attachments would bloat the database.

The minimal store, by hand:

const messageId = parsed.messageId ?? crypto.randomUUID();
const r2Key = `eml/${messageId}.eml`;

// Raw MIME -> R2 (fidelity + attachments live here).
await env.BLOBS.put(r2Key, raw);

// Structured fields -> D1 (queryable + searchable).
await env.DB.prepare(
  `INSERT INTO messages (message_id, recipient, sender, subject, body_text, received_at, raw_key)
   VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
   ON CONFLICT(message_id) DO NOTHING`,
)
  .bind(
    messageId,
    message.to,
    parsed.from?.address ?? message.from,
    parsed.subject,
    parsed.text ?? "",
    parsed.date ?? Date.now(),
    r2Key,
  )
  .run();

For a schema worth copying rather than inventing, see the D1 schema for threads, folders, and messages. The important property here is that once a message is a row, it is addressable: the inbox UI, full-text search, and an AI agent all read the same table. That is the whole reason to store inbound mail in a database instead of forwarding it away.

Tinbox condenses the whole parse-and-store step into ingestParsedEmail, which resolves the thread, writes the message and attachment-metadata rows, and streams the .eml and each attachment part to R2 in one call. The hand-written version above is what that function does, minus the threading.

How do I avoid storing the same email twice?

Key your insert on the Message-ID. Email Routing can deliver the same message more than once (retries, transient failures on your side), so a naive INSERT produces duplicate rows. The ON CONFLICT(message_id) DO NOTHING clause above, paired with a unique index on message_id, makes the write idempotent:

CREATE UNIQUE INDEX IF NOT EXISTS messages_message_id_idx ON messages(message_id);

Normalize the Message-ID before you compare (strip whitespace, ensure angle brackets) so <abc@host> and abc@host collapse to the same key. Tinbox's parseEmail already returns a normalized messageId, which is why dedup is reliable on top of it. If a message genuinely has no Message-ID (rare, but malformed senders exist), fall back to a content hash so two distinct messages do not collide on a shared placeholder.

Can I classify inbound mail as it arrives?

Yes, and ingest is the right moment to do it. Once the message is parsed, you have the subject, body, and headers in memory, which is everything a classifier needs. Tinbox runs interpreters at ingest: algorithmic and LLM rules that tag a message as an OTP code, a complaint, a receipt, and so on. See how interpreters classify inbound mail on arrival for the rule model and eval suites, the ways an LLM can label your incoming mail for the patterns worth applying, and interpreter recipes for smarter inbound handling for ready-made rules to copy.

The one thing to get right is where the verdict lives. Tinbox stores interpreter output in the message's extra JSON column, not in invented top-level columns. The schema stays stable; tenant- and interpreter-specific data rides along in extra:

// after ingest, attach a classification verdict to the row
await env.DB.prepare(
  `UPDATE messages SET extra = json_set(coalesce(extra, '{}'), '$.verdict', json(?1))
   WHERE message_id = ?2`,
)
  .bind(JSON.stringify({ kind: "otp", code: "493021", confidence: 0.98 }), messageId)
  .run();

Keeping classification at ingest means the inbox, search, and agent all see the verdict immediately, without a second pass over the archive.

How do I verify inbound actually works?

Send a real message to the routed address, then query D1 for the row. End to end:

# 1. Deploy the worker
npx wrangler deploy

# 2. (In the dashboard) point a catch-all rule at the worker, as shown above.

# 3. Send a test message to any address at the domain
#    e.g. from your normal mail client, to: hello@yourdomain.com

# 4. Read it back out of D1
npx wrangler d1 execute inbox --remote \
  --command "SELECT message_id, sender, subject FROM messages ORDER BY received_at DESC LIMIT 5"

Expected output once the message lands:

┌──────────────────────────┬────────────────────┬──────────────────┐
│ message_id               │ sender             │ subject          │
├──────────────────────────┼────────────────────┼──────────────────┤
│ <CA+xyz@mail.gmail.com>  │ you@gmail.com      │ test inbound     │
└──────────────────────────┴────────────────────┴──────────────────┘

Tail the logs while you send to watch the handler run in real time:

npx wrangler tail

You should see your inbound to=... from=... log line within a second or two of sending.

Why is my email() handler not being invoked?

Mail is accepted but your code never runs: this is almost always the routing rule or the export, in that order. Here are the real symptoms and fixes.

Symptom: test mail does not bounce, but nothing appears in D1 and wrangler tail shows no email event. The routing rule is not pointing at your Worker. Check the dashboard: the address (or catch-all) must have action "Send to a Worker" with your Worker as the destination. A rule set to "Send to an email" forwards silently and never calls your code.

Symptom: deploy succeeds but the dashboard will not let you select the Worker as a destination. The Worker has no email() export. The runtime only offers Workers that export an email handler. Confirm it is the email method on the default export, not fetch:

// WRONG: this Worker will not appear as an Email Routing destination
export default {
  async fetch(req: Request) {
    return new Response("ok");
  },
};

// RIGHT: exports email(), so Email Routing can target it
export default {
  async email(message: ForwardableEmailMessage, env: Env) {
    // ...store the message
  },
};

Symptom: small messages store fine, large ones fail or truncate. You are likely buffering the whole stream into memory for very large attachments. Stream attachment parts straight to R2 instead of holding multi-megabyte buffers, and be aware of the Worker memory limit. For most inbound mail this is a non-issue; it shows up with large file attachments.

Symptom: parse throws on a weird message. Some senders produce malformed MIME. postal-mime is lenient, but wrap the parse and, on failure, still persist the raw .eml to R2 with a parse_error flag so you never lose the message. The raw bytes are the source of truth; a failed parse should degrade, not drop.

Where does inbound fit in the bigger stack?

Inbound is the foundation. Once mail is rows in D1 with raw bytes in R2, everything else reads from that substrate: full-text search over the inbound mail you stored, the inbox UI this inbound pipeline feeds, and an AI agent answering questions over the archive. The email() handler is the narrow entry point; the value is in everything that queries what it stored.

You can wire all of this yourself with the code above. The parser, the thread resolution, the idempotent store, the R2 streaming, and the interpreters are each a few days of careful work, and MIME has a long tail of edge cases. Or skip it: Tinbox ships the inbound pipeline (the email() handler, parseEmail, ingestParsedEmail, interpreters at ingest, and the parsing and storage helpers from tinbox/cf) as part of one deploy, and runs it on your Cloudflare account, not ours. If you are routing mail per client, see per-tenant inbound routing on Cloudflare for the current one-deploy-per-domain model.

One caveat to keep you honest: dynamic, runtime-managed routing (creating a new inbox address that receives mail with no redeploy) is partly built and partly roadmap. The shipped, reliable path is the one in this tutorial: a routing rule pointing at an email() handler that parses and stores. Stick to that and inbound just works.

Deploy this yourself: npx tinbox init <domain>. The inbound handler, parser, and D1/R2 wiring are scaffolded for you; the docs walk through the Email Routing rule. The rest of the stack the inbound side feeds is in the full self-contained email stack.

Frequently asked questions

Can Cloudflare Email Routing send email?

No. Cloudflare Email Routing is inbound only: it receives mail at your domain and can forward it or invoke a Worker. Outbound mail uses the separate Cloudflare Email Service binding/REST API; external clients may use its authenticated SMTP submission endpoint.

How do I process inbound email in a Cloudflare Worker?

Export an async email(message, env, ctx) handler from your Worker and bind it as the destination of a Cloudflare Email Routing rule. The runtime calls that handler with a ForwardableEmailMessage. Read message.raw (a ReadableStream), buffer it, parse the MIME, and write the result wherever you want.

Where does the inbound mail get stored?

Wherever your handler writes it. A practical pattern on Cloudflare is structured fields and headers into D1 (SQLite) for querying and search, and the raw .eml plus attachments into R2 object storage. Tinbox uses exactly this split so every message becomes a searchable row backed by its original bytes.

How do I avoid storing the same email twice?

Deduplicate on the Message-ID header. Normalize it (angle-bracketed), and before inserting check whether a row with that message_id already exists for the inbox. Email Routing can retry delivery, so an idempotent upsert keyed on Message-ID prevents duplicate rows.

Why is my email() handler not being invoked?

The two usual causes are a missing routing rule (the address is not routed to your Worker in the Email Routing dashboard) or a handler that is not actually exported (it must be the email method on your default export, not fetch). Mail that is accepted but never reaches your code almost always means the routing rule points elsewhere.