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.
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().
// 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:
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 Service | MailKite | |
|---|---|---|
| Inbound handoff | Raw MIME stream to your Worker | Parsed JSON webhook, signed and retried |
| MIME parsing | Yours (postal-mime), on your CPU budget | Done at the edge before delivery |
| Reply to the message in hand | message.reply() + a MIME you construct | mk.send() |
| New mail, delayed reply, third party | Email Service binding (beta) you wire up | Same mk.send() call |
| SPF/DKIM/DMARC verdicts | Read them out of the raw headers yourself | auth block on every event |
| DNS requirement | Zone must be on Cloudflare | Any DNS host (MX + SPF/DKIM records) |
| Cost | Routing is free; Workers free tier; Email Service beta | Free 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.