MailKite
Get started
All posts
Gabe 5 min read

Cloudflare Email Routing can't reply — here's how to send from your domain

Cloudflare Email Routing forwards inbound mail beautifully and Email Workers hand you the raw message — but there's no first-class way to reply or send from your own domain. Here's exactly where the gap is, why the 'just add Email Service' answer is more assembly than it sounds, and how to receive parsed JSON and reply from one API instead.

Cloudflare Email Routing is an inbound forwarder: it takes mail sent to an address on your domain and either forwards it to another mailbox or triggers an Email Worker that hands your code the raw MIME message. What it does not do is reply — there’s no first-class “send from your domain” story in Routing itself, so answering an email means stitching Routing to an Email Worker to Cloudflare’s separate Email Service (beta), and parsing the raw message yourself inside a CPU-limited Worker. MailKite closes that loop in one API: inbound arrives as parsed JSON, and mk.send() replies from the same domain.

Let me be fair up front, because this post is a comparison and comparisons that trash the competitor are useless. Cloudflare Email Routing is genuinely good at what it’s for. It’s free, it runs at Cloudflare’s edge, setup is a couple of DNS records, and as a router — pointing support@yourdomain at your real inbox, or catching everything to a Worker — it’s excellent. If all you need is forwarding, stop reading and go use it.

This post is about the moment you need the other direction.

Where the wall is

You wire up Email Routing. Mail forwards fine. Then a feature needs to answer: a support address that replies, a reply-by-email flow that reopens a ticket, an agent that reads its inbox and responds. You go looking for the “reply” button in Routing and it isn’t there — because Routing was built to forward, not to originate mail from your domain.

The Email Worker gives you a message event with the raw MIME and a couple of convenience helpers, and it does expose a reply() for a narrow case — replying to the specific inbound message you’re currently handling, within its constraints. But “send a new mail from your domain,” “reply later from a queue,” or “email a third party” is a different job, and for that Routing points you at Cloudflare’s Email Service, which shipped in beta in April 2026 and can send. So the honest framing isn’t “Cloudflare can’t send.” It’s: Routing forwards, replying is constrained, and general sending is a separate product you assemble yourself.

Why “just add Email Service” is more than it sounds

On paper the answer is tidy: Routing receives, a Worker parses, Email Service sends. In practice you’re now the systems integrator for three moving parts, and two of them hand you work:

  • You parse the raw MIME yourself. The Email Worker gives you the raw message, not a parsed one. So inside the Worker you walk the multipart tree to find the body, decode quoted-printable and base64, resolve charsets (this is where £ becomes £), and pull attachments out by hand — all inside a CPU budget that a big base64 attachment will blow through, against a 25 MB message cap. Parsing arbitrary inbound MIME is the hard part of inbound, and this architecture puts it squarely on you.
  • Email Service is beta, and it’s glue. Sending means wiring the Worker to the Email Service binding, keeping the send path and the receive path in sync, and accepting beta-grade surface area for a production feature. It works — but “reply from my domain” ends up as several hundred lines of Worker you own and maintain, not one call.
  • DNS lives on Cloudflare. This stack assumes your domain is on Cloudflare. Often fine; sometimes a constraint you didn’t choose.

None of this is a knock on Cloudflare’s engineering. It’s that Routing + Workers + Email Service is a toolkit for building an email app, and what a lot of people actually want is the app: parsed message in, reply out, from the same domain, without becoming a MIME expert.

The closed loop: parsed in, reply out

MailKite treats receive-and-reply as one product. Inbound mail to any address on your domain is parsed at the edge and POSTed to you as JSON — the whole message, already decoded:

{
  "id": "msg_2Hk9…",
  "type": "email.received",
  "from": { "address": "ada@example.com" },
  "to": [{ "address": "support@myapp.ai" }],
  "subject": "Re: invoice #1042",
  "text": "Looks good — approved!",
  "html": "<p>Looks good — approved!</p>",
  "threadId": "<a1b2c3@mail.example.com>",
  "auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass", "spam": "ham" },
  "attachments": [
    { "id": "msg_2Hk9…:0", "filename": "po.pdf", "contentType": "application/pdf",
      "size": 18213, "url": "https://api.mailkite.dev/att/2Hk9…/0?exp=…&sig=…" }
  ]
}

No raw MIME, no CPU budget to watch, no charset guessing — text and html are decoded, attachments are signed URLs, and auth tells you whether the sender is real before you act. Replying from the same domain is one call:

// Express — receive parsed JSON, reply from your own domain
import express from "express";
import { MailKite } from "mailkite";

const mk = new MailKite(process.env.MAILKITE_API_KEY);
const SECRET = process.env.MAILKITE_WEBHOOK_SECRET;

app.use("/hooks/mailkite", express.raw({ type: "application/json" }));

app.post("/hooks/mailkite", async (req, res) => {
  const sig = req.headers["x-mailkite-signature"];
  if (!MailKite.verifyWebhook(sig, req.body, SECRET)) return res.sendStatus(401);

  const event = JSON.parse(req.body);
  res.sendStatus(200); // ack fast; reply out of band

  if (event.type === "email.received" && event.auth.dmarc === "pass") {
    await mk.send({
      from: "support@myapp.ai",             // your domain, both directions
      to: event.from.address,
      subject: "Re: " + event.subject,
      html: "<p>Thanks — we've reopened your ticket.</p>",
    }); // returns { id, status }
  }
});

app.listen(3000);

That’s the loop Routing can’t close on its own: mail arrives parsed, and the reply goes out from the same address, from the same API, on any DNS. Verify against the raw bytes, ack fast, then send. The same handler exists for Python, Ruby, Go, PHP, and Java — see the sending and receiving docs.

FAQ

Can Cloudflare Email Routing send email? Routing itself forwards inbound mail; it doesn’t originate mail from your domain. An Email Worker can reply() to the specific message it’s handling within limits, and Cloudflare’s separate Email Service (beta, April 2026) can send — but general “reply/send from my domain” means assembling Routing + a Worker + Email Service yourself.

How do I reply to a forwarded email from my domain? On Cloudflare you’d catch the message in an Email Worker, parse the raw MIME, and hand off to Email Service to send the reply. With MailKite the inbound arrives already parsed as JSON and you call mk.send({ from, to, subject, html }) from the same domain — one call, thread-aware, on any DNS provider.

Do I have to keep my DNS on Cloudflare? For the Routing + Workers + Email Service stack, effectively yes. MailKite works on any DNS host — you point MX for inbound and add SPF/DKIM for outbound wherever your domain lives.

Is Cloudflare Email Routing bad, then? No — it’s a very good free forwarder and edge trigger, and this post says so. It’s just not a “parsed inbound message plus reply-from-your-domain” API, which is a different product and the gap MailKite fills.

What about attachments and the CPU limit? In an Email Worker, decoding a large base64 attachment can exhaust your CPU budget and you’re capped at 25 MB. MailKite parses at the edge and hands attachments back as signed URLs you fetch on demand, so nothing rides through a constrained runtime.


Cloudflare Email Routing is a fine router — it just doesn’t reply. If you need the whole loop, point a domain at MailKite and receive parsed JSON while sending from the same address.

Related: Receiving email is the part nobody warns you about covers the receive half in depth, and the honest SendGrid Inbound Parse alternative is the same comparison for SendGrid.

Discuss this post: Hacker News Share on X

Related posts