MailKite
Get started
All posts
Gabe 5 min read

The honest Mailgun Routes alternative

Mailgun Routes is a filter-expression engine for inbound mail: you write match_recipient/match_header rules that fire forward() and store() actions, and Mailgun POSTs the parsed message to your endpoint as form-encoded fields. MailKite is a routes alternative that drops the rule DSL — point an address or catch-all at a webhook and the message arrives as a single JSON payload with decoded text/html, SPF/DKIM/DMARC results, and signed attachment URLs. Here's a fair comparison, and where I won't overclaim.

Mailgun Routes is Mailgun’s inbound routing feature: you author filter expressions like match_recipient(".*@myapp\.ai") and match_header("subject", ".*urgent.*") that trigger actions such as forward("https://…"), store(), and stop(), evaluated in priority order for every inbound message. Mailgun parses the mail and POSTs it to your endpoint as form-encoded fields (body-plain, body-html, stripped-text, attachment parts). MailKite is a routes alternative that removes the expression engine: you point an address or a catch-all at a webhook in a dashboard, and the message arrives as one JSON payload with decoded text/html, an auth{spf,dkim,dmarc,spam} object, and attachments as short-lived signed URLs.

I’ll be fair to Mailgun, because Routes is a capable feature and this is a comparison, not a hit piece. Mailgun has moved inbound mail for well over a decade, Routes genuinely parses MIME for you (you get body-plain and body-html, not raw multipart), and the expression engine is powerful — you can match on recipient, headers, or a catch-all and chain actions. If you already live in Mailgun and your routing is a couple of stable rules, it works and you don’t need us.

This post is for when the rules multiply, the payload fights you, or the account underneath you keeps changing hands.

What Routes actually gives you

You write rules in Mailgun’s filter syntax. A typical one:

Priority: 1
Expression:  match_recipient(".*@inbound.myapp.ai")
Actions:     forward("https://myapp.ai/hooks/mailgun"), stop()

Every inbound message is tested against every route in priority order; matching routes fire their actions, and stop() halts evaluation. It’s expressive — and it’s a small rule language you now own: authored, ordered, kept in sync with your app, and debugged when a message doesn’t match what you expected.

When a route forwards to your URL, Mailgun POSTs form-encoded data (multipart/form-data when there are attachments). You get parsed fields — from, subject, body-plain, body-html — plus signature fields (timestamp, token, signature) you HMAC-verify, and attachments as file parts with an attachment-count. Two things bite here in practice:

  • The convenience fields can eat content. Mailgun offers stripped-text and stripped-signature — the message with quoted replies and signatures removed. Reply/signature stripping is an unsolved problem industry-wide (the underlying classifiers top out around 93% on messy real-world mail), so stripped-text will sometimes silently drop a line of the actual message along with the quote. If you build on it without also keeping body-plain, you lose real content and never see it happen.
  • Attachments are still yours to extract. They arrive as multipart parts you pull out of the request body — not as a URL you fetch on demand — so a large file rides inline in the POST.

There’s also a platform reality worth naming plainly: inbound routing has drifted behind paid plans over the years, and Mailgun has changed owners more than once (Rackspace → Pathwire → Sinch). Neither is a knock on the parsing — but “will my inbound pipeline and its pricing still look the same next year” is a fair question when the product keeps getting passed along. (See Mailgun’s own Routes documentation for the current rule syntax and plan gating.)

The honest comparison

No adjective inflation — here’s the shape of the difference:

Mailgun RoutesMailKite inbound
Routing modelFilter-expression rules you author + orderPoint an address/catch-all at a webhook
Payload formatForm-encoded (multipart/form-data)Single JSON webhook
Bodybody-plain / body-html (+ lossy stripped-text)text / html, pre-decoded
AttachmentsMultipart file parts, inlineMetadata + short-lived signed url
Sender authDig SPF/DKIM out of headersauth{spf,dkim,dmarc,spam} in payload
Signature checktimestamp+token+signatureMailKite.verifyWebhook(sig, rawBody, secret)
Free tierInbound gated to paid plans3,000 msgs/mo, no daily cap

The through-line: Mailgun parses the body for you (credit where due), but you still own the rule engine, the form decoding, the attachment extraction, and the auth inference. With MailKite that work — and the routing config — is done before the webhook reaches you.

What the JSON looks like

Same inbound email, delivered parsed:

{
  "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 route to author, no stripped-text to second-guess (you get the full text), attachments out of the payload as signed URLs, and auth right there — which matters the moment you act on an email: a forged From: is an authorization decision, and having SPF/DKIM/DMARC in the payload means you don’t infer trust from raw headers or trust blindly.

The handler

// Express
import express from "express";
import { MailKite } from "mailkite";

const SECRET = process.env.MAILKITE_WEBHOOK_SECRET;

// Verify the RAW bytes — re-serializing breaks the HMAC.
app.use("/hooks/mailkite", express.raw({ type: "application/json" }));

app.post("/hooks/mailkite", (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);
  if (event.type === "email.received" && event.auth.spf === "pass") {
    console.log(event.from.address, "·", event.subject);
    // event.text / event.html already decoded; attachments are signed URLs.
  }

  res.sendStatus(200); // ack fast; heavy work out of band
});

app.listen(3000);

No rule expressions to maintain, no form parser, no attachment extraction — verify the signature, read the fields. Per-address routing that would be a match_recipient rule in Mailgun is just a different address (or a catch-all) pointed at your webhook. The same handler exists for Python, Ruby, Go, PHP, and Java; see the receiving docs and webhook security.

Where I won’t overclaim

Mailgun is a mature sending platform with real deliverability tooling and a decade-plus track record, and unlike some inbound options, Routes genuinely parses MIME rather than dumping raw multipart on you. If you’re already on Mailgun for outbound and your inbound is a couple of stable routes that work, switching purely for inbound may not be worth it. My claim is narrower and specific: for the inbound direction, MailKite replaces the route-expression engine and the form-decoding-plus-attachment work with a single parsed JSON webhook and dashboard routing — and doesn’t gate inbound behind a plan tier. That’s the friction Routes accumulates as it scales, and it’s the one we built for.

FAQ

What are Mailgun Routes, exactly? Routes are Mailgun’s inbound routing rules: filter expressions like match_recipient(...) and match_header(...) that trigger actions (forward(), store(), stop()) in priority order for each inbound message, with the parsed mail POSTed to your endpoint as form-encoded fields.

Does Mailgun give me the email as JSON? No — it POSTs form-encoded data (multipart/form-data when attachments are present) with fields like body-plain, body-html, and stripped-text. MailKite delivers a single JSON object with text/html decoded, auth{spf,dkim,dmarc,spam}, and attachments as signed URLs.

What’s the catch with stripped-text? It’s the message with quoted replies and signatures removed, produced by a classifier that isn’t perfect — so it can silently drop a line of the real message along with the quote. If you use it, keep body-plain too. MailKite hands you the full text and leaves stripping to you.

Do I have to rewrite my match rules to switch? Usually not one-for-one. A match_recipient rule becomes an address or a catch-all pointed at a webhook in the dashboard; there’s no expression syntax to port or re-order.

Is inbound free? On MailKite, yes within the free tier — 3,000 messages a month across inbound and outbound, no daily cap, metered overage instead of a hard cutoff. Mailgun has moved inbound routing behind paid plans over time.


If your Routes config has grown into a rule engine you maintain — or the platform under it keeps changing hands — there’s a simpler shape. Point a domain at MailKite and your next inbound email arrives as parsed JSON.

Related: Receiving email is the part nobody warns you about makes the full case for inbound, the SendGrid Inbound Parse alternative is this comparison for SendGrid, and Cloudflare Email Routing can’t reply is the same for Cloudflare.

Discuss this post: Hacker News Share on X

Related posts