All posts
Cloudflare Email Routing can't reply: how to send from your domain
Gabe 14 min read

Cloudflare Email Routing can't reply: how to send from your domain

Cloudflare Email Routing forwards inbound mail and Email Workers hand your code the raw MIME, but there's no first-class reply-from-your-domain path: that takes Routing plus a Worker that parses MIME plus the separate Email Service beta. Where the gap is, the DIY assembly on Cloudflare with real code, and how MailKite (which we build) closes the loop: parsed JSON in, one send call out.

Here is that gap in one picture: the same inbound email, and everything you wire up to answer it from your own domain on each side. The rest of this post is the honest version of the diagram, where Email Routing genuinely wins, the Cloudflare-native assembly with real code, and the handful of lines that are the entire MailKite side.

Cloudflare: Routing + Worker + Email Service sender Routingforwards / triggers Email Workerraw MIME, you parse Email Servicebeta, sends reply sent …plus mimetext to construct the reply, In-Reply-To rules, a Worker CPU budget against 25 MB messages, and DNS that must live on Cloudflare ← yours to write and operate MailKite sender MX edgeparse + auth JSON webhooksigned, retried your appmk.send() reply sent the 25 lines below are the whole "your app" integration
Answering one inbound email: the Cloudflare assembly (Routing + Email Worker + Email Service) vs MailKite's loop (parsed webhook in, one send call out).

Here’s the whole loop on MailKite. Complete, runs as pasted on Node 18+ (npm install express mailkite):

// Receive parsed inbound, 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;
const app = express();

app.post("/hooks/mailkite", express.raw({ type: "application/json" }), 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);

(Can’t take a dependency? The same surface is plain REST with signed webhooks: POST /v1/send, documented in sending and receiving. Prefer the SDK; the signature check is easy to get subtly wrong by hand.)

To be fair up front: 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 with a Worker) it’s excellent. If all you need is forwarding, use it. And MailKite itself runs on Cloudflare, Workers, D1, and Queues included, so this isn’t a teardown of the platform. It’s a map of where the primitives stop and the assembly begins.

Routing forwards; replying is a different product

The wall is direction. You wire up Email Routing, mail forwards fine, and 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. There’s no “reply” in Routing because Routing was built to forward, not to originate mail from your domain.

The Email Worker gets you closer. Its message event carries the raw MIME plus a reply() helper, and reply() does work for one narrow case: answering the specific inbound message you’re currently handling, with a MIME reply you construct yourself that references the incoming Message-ID. But “send a new mail from your domain,” “reply later from a queue,” or “email a third party” is a different job. For that, Cloudflare points you at Email Service, which shipped in beta in April 2026 and can send. So the precise framing isn’t “Cloudflare can’t send.” It’s: Routing forwards, reply() is constrained to the message in hand, and general sending is a separate beta product you assemble with the other two.

The DIY assembly on Cloudflare, shown honestly

Here’s the Cloudflare-native version of the loop at the top of this post: catch the message in an Email Worker, parse the raw MIME with postal-mime, build the reply with mimetext, send it with message.reply().

inbound emailarrives for your address Email Routingforwards / triggers your Worker Email Workerraw MIME handed to your code postal-mimeparse on your CPU budget mimetextbuild reply, set In-Reply-To message.reply()reply leaves your domain Every gray stage is yours to build and run inside the Worker. On MailKite the message arrives parsed and the reply is one mk.send().
The Cloudflare reply assembly, stage by stage. MailKite collapses every stage into a parsed JSON webhook in and one send call out.
// Cloudflare Email Worker: parse raw MIME, construct and send a reply
// npm install postal-mime mimetext
import PostalMime from "postal-mime";
import { createMimeMessage } from "mimetext";
import { EmailMessage } from "cloudflare:email";

export default {
  async email(message, env, ctx) {
    const email = await PostalMime.parse(message.raw); // runs on your CPU budget

    const reply = createMimeMessage();
    reply.setHeader("In-Reply-To", message.headers.get("Message-ID"));
    reply.setSender({ name: "Support", addr: "support@yourdomain.com" });
    reply.setRecipient(message.from);
    reply.setSubject("Re: " + (email.subject ?? ""));
    reply.addMessage({ contentType: "text/plain", data: "Thanks, we've reopened your ticket." });

    await message.reply(
      new EmailMessage("support@yourdomain.com", message.from, reply.asRaw())
    );
  },
};

This works, and for “auto-acknowledge the message I’m holding” it’s a reasonable amount of code. Three costs ride along:

  • Parsing runs on your budget. postal-mime does the multipart tree walking and charset decoding for you, but inside the Worker's CPU limit, against messages up to 25 MB; a large base64 attachment gets decoded on your time. (Hand-roll instead and quoted-printable plus charset handling is where £ becomes £.)
  • Reply-in-hand only. message.reply() answers the current message, once, from the handler. New mail, a delayed reply from a queue, or mailing a third party means wiring up the Email Service binding as well, keeping the send and receive paths in sync, and accepting beta surface area for a production feature.
  • DNS lives on Cloudflare. The whole stack assumes your zone is on Cloudflare. Often fine; sometimes a constraint you didn't choose.

None of this is a knock on Cloudflare’s engineering. Routing + Workers + Email Service is a toolkit for building an email app, and what a lot of people want is the app: parsed message in, reply out, same domain, without becoming a MIME expert. If you’d rather assemble than buy, the toolkit above is the honest sketch, and Postmark’s inbound parsing or SES + Lambda are the other assembled and semi-assembled options worth pricing out.

The comparison, feature by feature

Routing + Worker + Email ServiceMailKite
Inbound handoffRaw MIME stream to your WorkerParsed JSON webhook, signed and retried
MIME parsingYours (postal-mime), on your CPU budgetDone at the edge before delivery
Reply to the message in handmessage.reply() + a MIME you constructmk.send()
New mail, delayed reply, third partyEmail Service binding (beta) you wire upSame mk.send() call
SPF/DKIM/DMARC verdictsRead them out of the raw headers yourselfauth block on every event
DNS requirementZone must be on CloudflareAny DNS host (MX + SPF/DKIM records)
CostRouting is free; Workers free tier; Email Service betaFree tier: 3,000 messages/mo, in + out

What actually hits your webhook

The inbound half of the MailKite loop is this payload: the whole message, already decoded, with the authentication verdicts attached so you can gate the auto-reply (as the code up top does with event.auth.dmarc):

{
  "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 you fetch on demand, and nothing large rides through a constrained runtime. That’s the loop Routing can’t close on its own: mail arrives parsed, and the reply leaves from the same address, through the same API, on any DNS. The same handler exists for Python, Ruby, Go, PHP, and Java; see the receiving and webhook security 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, 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: catch it in an Email Worker, parse the raw MIME, construct a reply with In-Reply-To set, and call message.reply() (see the Worker code above). With MailKite the inbound arrives already parsed as JSON and you call mk.send({ from, to, subject, html }) from the same domain.

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

What about attachments and the CPU limit? In an Email Worker, decoding a large base64 attachment eats your CPU budget and messages are capped at 25 MB. MailKite parses at the edge and hands attachments back as signed URLs you fetch on demand.


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. Hit a case this comparison doesn’t cover? Tell us and we’ll test it.

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 Share on LinkedIn

Related posts