Skip to content

tutorials

Turn inbound email into a webhook with Cloudflare Workers

July 22, 2026

#cloudflare-workers#inbound-email#webhook#mime#tinbox
Turn inbound email into a webhook with Cloudflare Workers
Visual: Block & Brayer on okslop

You want a reply to support@yourdomain.com to fire your app logic, and you do not want to pay a SaaS inbound-parse product to do it. Here is the by-hand version, the MIME pitfalls that will bite you, and the shortcut.

Inbound email as an event source is genuinely useful: ticket creation from support@, magic-link replies, +tag routing, parsing supplier order confirmations, an ai@ address that hands a message to an agent. The classic answer is to point an MX record at a provider that parses the mail and POSTs you JSON. That works, but now a third party reads every message before you do, and you pay per inbound.

Cloudflare gives you the pieces to do it yourself on your own account. The plumbing is easy. The MIME parsing is where people underestimate the work, so we will build the whole thing and be honest about the sharp edges.

How do I send incoming email to a webhook on Cloudflare?

Enable Cloudflare Email Routing on your domain, route an address to a Worker, and implement the email() handler in that Worker. The handler receives the raw inbound message; you parse it into JSON and fetch() your webhook URL. There is no SMTP server to run and no port to open, because Cloudflare terminates SMTP at its edge and hands the parsed stream to your code.

The flow has three moving parts:

sender → Cloudflare MX (Email Routing) → your Worker email() handler → fetch() → your webhook

Cloudflare Email Routing is inbound only (it receives and forwards, it does not send outbound mail to the world), which is exactly what we want here. For the higher-level walkthrough of getting inbound working at all, see the steps to receive inbound email on your own Cloudflare account; sending is a separate problem covered in sending email from a Worker.

Step 1: Enable Email Routing and route to a Worker

In the Cloudflare dashboard, open your domain, go to Email, and enable Email Routing. Cloudflare adds the MX records and an SPF TXT record for you. Then create a route that sends an address (or a catch-all) to a Worker instead of forwarding to a mailbox.

You can also declare the binding in wrangler.jsonc so the Worker is configured in Cloudflare's JSON-first format:

{
  "name": "inbound-mail",
  "main": "src/index.ts",
  "compatibility_date": "2026-06-01",

  // Email Routing invokes the email() handler for matched addresses.
  "send_email": [],
}

The route-to-Worker mapping itself is configured in the Email Routing UI (address support@yourdomain.com → Worker inbound-mail). Once that is set, every message to that address calls your handler.

Step 2: Receive the message in the email() handler

A Worker can export three entry points: fetch(), queue(), and email(). Email Routing calls the last one with a ForwardableEmailMessage. The important field is raw: a ReadableStream of the full RFC 822 message.

export default {
  async email(message: ForwardableEmailMessage, env: Env): Promise<void> {
    // Envelope metadata is on the message object directly.
    const from = message.from;       // SMTP MAIL FROM
    const to = message.to;           // RCPT TO (the matched address)
    const size = message.rawSize;    // bytes

    // The full MIME message is a stream you must read.
    const raw = await streamToString(message.raw);

    console.log(`inbound from ${from} to ${to}, ${size} bytes`);
    // ...parse + POST below
  },
};

async function streamToString(stream: ReadableStream): Promise<string> {
  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 merged = new Uint8Array(total);
  let offset = 0;
  for (const c of chunks) {
    merged.set(c, offset);
    offset += c.length;
  }
  return new TextDecoder("utf-8").decode(merged);
}

So far so good. You have the raw message as a string. Now you have to make sense of it, and this is where the project quietly triples in scope.

Step 3: Parse the MIME (this is the hard part)

The naive approach is to split on the blank line between headers and body, grab the body, and call it the email text. It works on exactly one kind of message: a single-part, 7-bit, plain-text email that nobody sends anymore. Real mail looks like this:

From: Alice <alice@example.com>
To: support@yourdomain.com
Subject: =?UTF-8?Q?Re=3A_order_=23A1042_caf=C3=A9?=
Content-Type: multipart/mixed; boundary="OUTER"

--OUTER
Content-Type: multipart/alternative; boundary="INNER"

--INNER
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: quoted-printable

Hi, the caf=C3=A9 receipt didn=E2=80=99t arrive=2E
--INNER
Content-Type: text/html; charset="utf-8"
Content-Transfer-Encoding: quoted-printable

<p>Hi, the caf=C3=A9 receipt didn=E2=80=99t arrive=2E</p>
--INNER--
--OUTER
Content-Type: application/pdf; name="receipt.pdf"
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="receipt.pdf"

JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9UeXBl...
--OUTER--

Count the things a correct parser has to handle in just that one message:

  1. A nested multipart tree. multipart/mixed contains a multipart/alternative (text + html) and a separate attachment. Boundaries nest, and the inner boundary is different from the outer. A regex that splits on --OUTER does not see the inner parts.
  2. Encoded-word headers. The Subject is RFC 2047 encoded (=?UTF-8?Q?...?=). You have to decode it to get Re: order #A1042 café.
  3. Quoted-printable bodies. =C3=A9 is a UTF-8 byte pair for é; =E2=80=99 is a curly apostrophe; a trailing = means a soft line break. You decode QP to bytes, then decode the bytes as the declared charset.
  4. Base64 attachments. The PDF is base64 across many lines. Decode to bytes and keep the declared filename.
  5. Picking the right body. For multipart/alternative you generally prefer text/html, fall back to text/plain.

Here is a compact, real parser that covers those cases. It is not exhaustive (charsets beyond UTF-8 and deeply pathological messages need more), but it is honest about the structure instead of pretending email is flat.

interface ParsedEmail {
  headers: Record<string, string>;
  subject: string;
  text?: string;
  html?: string;
  attachments: { filename: string; contentType: string; bytes: Uint8Array }[];
}

function splitHeadersAndBody(raw: string): [string, string] {
  const idx = raw.search(/\r?\n\r?\n/);
  if (idx === -1) return [raw, ""];
  const sep = raw.slice(idx).match(/^\r?\n\r?\n/)![0];
  return [raw.slice(0, idx), raw.slice(idx + sep.length)];
}

function parseHeaders(block: string): Record<string, string> {
  // Unfold continuation lines (a header wrapped onto the next indented line).
  const unfolded = block.replace(/\r?\n[ \t]+/g, " ");
  const headers: Record<string, string> = {};
  for (const line of unfolded.split(/\r?\n/)) {
    const m = line.match(/^([!-9;-~]+):\s*(.*)$/);
    if (m) headers[m[1].toLowerCase()] = m[2];
  }
  return headers;
}

function decodeEncodedWords(input: string): string {
  return input.replace(
    /=\?([^?]+)\?([bBqQ])\?([^?]*)\?=/g,
    (_all, _charset, enc, data) => {
      if (enc.toUpperCase() === "B") {
        return new TextDecoder("utf-8").decode(base64ToBytes(data));
      }
      // Q-encoding: _ is space, =XX is a byte.
      const bytes = qpToBytes(data.replace(/_/g, " "));
      return new TextDecoder("utf-8").decode(bytes);
    },
  );
}

function qpToBytes(input: string): Uint8Array {
  const cleaned = input.replace(/=\r?\n/g, ""); // soft line breaks
  const out: number[] = [];
  for (let i = 0; i < cleaned.length; i++) {
    if (cleaned[i] === "=" && i + 2 < cleaned.length) {
      out.push(parseInt(cleaned.slice(i + 1, i + 3), 16));
      i += 2;
    } else {
      out.push(cleaned.charCodeAt(i));
    }
  }
  return new Uint8Array(out);
}

function base64ToBytes(input: string): Uint8Array {
  const bin = atob(input.replace(/\s+/g, ""));
  const bytes = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
  return bytes;
}

function getBoundary(contentType: string): string | null {
  const m = contentType.match(/boundary="?([^";]+)"?/i);
  return m ? m[1] : null;
}

function parsePart(raw: string, into: ParsedEmail): void {
  const [headerBlock, body] = splitHeadersAndBody(raw);
  const headers = parseHeaders(headerBlock);
  const contentType = headers["content-type"] || "text/plain";
  const encoding = (headers["content-transfer-encoding"] || "7bit").toLowerCase();
  const disposition = headers["content-disposition"] || "";

  if (contentType.startsWith("multipart/")) {
    const boundary = getBoundary(contentType);
    if (!boundary) return;
    // Split on the boundary; first chunk is the preamble, last is the closing.
    const parts = body
      .split(new RegExp(`\r?\n--${escapeRe(boundary)}(?:--)?\r?\n?`))
      .slice(1, -1);
    for (const part of parts) parsePart(part, into);
    return;
  }

  // Leaf part: decode the body bytes.
  let bytes: Uint8Array;
  if (encoding === "base64") bytes = base64ToBytes(body);
  else if (encoding === "quoted-printable") bytes = qpToBytes(body);
  else bytes = new TextEncoder().encode(body);

  const filenameMatch = (disposition + contentType).match(/filename="?([^";]+)"?/i);
  if (disposition.startsWith("attachment") || filenameMatch) {
    into.attachments.push({
      filename: filenameMatch ? filenameMatch[1] : "attachment.bin",
      contentType: contentType.split(";")[0].trim(),
      bytes,
    });
  } else if (contentType.startsWith("text/html")) {
    into.html = new TextDecoder("utf-8").decode(bytes);
  } else if (contentType.startsWith("text/plain")) {
    into.text = new TextDecoder("utf-8").decode(bytes);
  }
}

function escapeRe(s: string): string {
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

export function parseEmail(raw: string): ParsedEmail {
  const [headerBlock] = splitHeadersAndBody(raw);
  const headers = parseHeaders(headerBlock);
  const result: ParsedEmail = {
    headers,
    subject: decodeEncodedWords(headers["subject"] || ""),
    attachments: [],
  };
  parsePart(raw, result);
  return result;
}

That is a meaningful amount of code, and it still has gaps. Charsets other than UTF-8 (ISO-8859-1, Shift_JIS, GBK) need a decoding table or TextDecoder with the right label. Inline images referenced by Content-ID need rewriting if you render the HTML. Malformed boundaries, missing closing delimiters, and 8-bit bodies show up in real traffic. Every one of those is a bug report waiting to happen, and you will be triaging them instead of building your product. Keep that cost in mind.

Step 4: POST a clean payload to your webhook

With a parsed object, the webhook call is the easy part. Do not inline attachment bytes as base64 in the JSON: it bloats the payload and can blow past your webhook's body limit. Pass small metadata and store the bytes elsewhere (R2 is the natural choice on Cloudflare; see the Cloudflare-native sending guide for the same R2/Queues pattern).

export default {
  async email(message: ForwardableEmailMessage, env: Env): Promise<void> {
    const raw = await streamToString(message.raw);
    const parsed = parseEmail(raw);

    const payload = {
      from: message.from,
      to: message.to,
      subject: parsed.subject,
      text: parsed.text ?? null,
      html: parsed.html ?? null,
      attachments: parsed.attachments.map((a) => ({
        filename: a.filename,
        contentType: a.contentType,
        size: a.bytes.length,
      })),
      receivedAt: new Date().toISOString(),
    };

    const res = await fetch(env.WEBHOOK_URL, {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "x-webhook-secret": env.WEBHOOK_SECRET, // verify this on the receiving side
      },
      body: JSON.stringify(payload),
    });

    if (!res.ok) {
      // Reject so the sender gets a bounce instead of silent data loss.
      message.setReject(`webhook returned ${res.status}`);
    }
  },
};

Two things worth calling out:

  • Authenticate the webhook. Anyone who learns your URL can POST fake email events. A shared secret header (verified on the receiving end) is the minimum; an HMAC over the body is better.
  • Decide what failure means. If your webhook is down, message.setReject(...) tells the sender it failed, which is usually safer than swallowing the message. For non-critical flows you might prefer to enqueue and retry instead, so a transient outage does not bounce real mail.

Common failure modes

TypeError: Cannot read properties of undefined (reading 'getReader')

You tried to read message.raw twice. The stream is consumable once. Buffer it into a string up front, then parse from the string.

Inbound mail never reaches the Worker

The Email Routing route is missing or still points at a forwarding address. Confirm the address (or catch-all) is routed to the Worker in the Email Routing UI, and that the MX records Cloudflare added are live.

Body shows =C3=A9 and =20 literally

You skipped quoted-printable decoding (or decoded as ASCII instead of the declared charset). Decode QP to bytes first, then decode the bytes as UTF-8.

Attachment arrives but the file is corrupt

Base64 was decoded with embedded newlines left in, or you decoded an attachment as text. Strip whitespace before atob, and keep attachment parts as raw bytes.

Or skip the parser: the Tinbox interpreter pipeline

Everything above is real work you can own, and for a single simple address it is fine. The trouble is that the MIME parser is the part that breaks at the worst time, on a message from your most important customer, in production, on a Saturday. If you would rather not maintain it, this is where Tinbox fits.

Tinbox runs the same Email Routing email() entry point on your own Cloudflare account, but it ships a tested MIME parser, stores attachments in R2 automatically, and writes the parsed message into your D1 inbox. On top of that it runs an interpreter pipeline: algorithmic and LLM rules that classify each inbound message (is this an OTP, a complaint, a reply to an existing thread, or a custom category you define) before any of your code runs. Each interpreter ships with its own eval suite, so a prompt change cannot silently regress.

npx tinbox init yourdomain.com

This scaffolds the inbound handler, provisions D1, R2, and Queues, and prints the DNS records. From there you write a rule, not a parser:

// app/interpreters/support-ticket.ts
import { defineInterpreter } from "tinbox/cf";

export default defineInterpreter({
  name: "support-ticket",
  match: (msg) => msg.to.startsWith("support@"),
  // msg is already parsed: subject, text, html, attachments are clean.
  run: async (msg, ctx) => {
    await ctx.webhook("https://app.yourdomain.com/tickets", {
      from: msg.from,
      subject: msg.subject,
      body: msg.text ?? msg.html,
      attachments: msg.attachments, // already in R2, passed as references
    });
  },
});

The webhook still fires. You just never touched quoted-printable, never debugged a nested boundary, and never lost a message to a charset you had not seen before. For ideas on what those classification rules can do once the parsing is handled, the email interpreter recipes collect patterns for OTP detection, complaint routing, and custom intents. If you want to go further and let an assistant act on inbound mail, the same parsed data feeds the MCP email server so Claude or another agent can read and triage it.

For the broader picture of receiving, sending, and storing mail on one Cloudflare-native stack, see the Tinbox overview.

Deploy this yourself: npx tinbox init yourdomain.com. Full docs for the interpreter pipeline and the inbound handler are in the Tinbox documentation.

Frequently asked questions

How do I send incoming email to a webhook on Cloudflare?

Enable Cloudflare Email Routing on your domain, set a route to a Worker, and implement the email() handler in that Worker. The handler receives a ForwardableEmailMessage with the raw RFC 822 stream; read it, parse the MIME into a JSON object, and POST that to your webhook URL with fetch().

Can a Cloudflare Worker receive email directly?

Yes. Email Routing delivers inbound mail for your domain to a Worker via the email() handler (an entry point alongside fetch() and queue()). The Worker does not open any SMTP socket; Cloudflare terminates SMTP and hands you the parsed message stream.

Why is parsing the raw email the hard part?

MIME is deceptively complex: nested multipart bodies, quoted-printable and base64 encodings, charset conversion, inline images with Content-ID, and malformed real-world messages. A regex-based parser breaks on the first nested multipart/alternative inside multipart/mixed. You need a real streaming MIME parser.

Does Tinbox replace the webhook approach?

Tinbox runs the same Email Routing entry point but adds a tested MIME parser, attachment storage in R2, and an interpreter pipeline that classifies inbound mail (OTP, complaint, reply, and custom rules) before your code runs. You can still emit a webhook; you just do not write the parser.

What is the size limit for inbound email on Cloudflare?

Cloudflare Email Routing rejects messages over its inbound size cap (low tens of MB at time of writing), and very large attachments are the common failure. Store attachment bytes in R2 and pass references in your webhook payload rather than inlining base64 blobs, which also keeps the JSON small.