The Mailgun Routes alternative for developers – MailKite

bucabay1 pts0 comments

The Mailgun Routes alternative for developers — MailKite Beta<br>50% off your first year — founding rate for the first 1,000 customers.<br>View pricing →

Sign in · Get started

All posts<br>Mailgun Routes is Mailgun’s inbound routing engine: you author filter expressions like match_recipient(".*@myapp\.ai") and match_header("subject", ".*urgent.*") that fire actions (forward("https://…"), store(), stop()) in priority order, and Mailgun POSTs the parsed message to your endpoint as form-encoded fields (body-plain, body-html, stripped-text, attachment parts). MailKite (which we build) is the alternative that removes the expression engine: point an address or a catch-all at a webhook 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. This is the comparison for developers weighing the two: what Routes actually gives you, where it bites, where Mailgun genuinely wins, and the receiving code for both sides.

Here is the entire MailKite side. Not a fragment: this runs as pasted on Node 18+, one dependency (npm install mailkite).

// The whole MailKite integration: verify, parse, act.<br>import { createServer } from "node:http";<br>import { MailKite } from "mailkite";

const SECRET = process.env.MAILKITE_WEBHOOK_SECRET ?? "whsec_demo_secret";

createServer(async (req, res) => {<br>let raw = "";<br>for await (const chunk of req) raw += chunk;

// signature check, replay window, constant-time compare: one call<br>if (!MailKite.verifyWebhook(req.headers["x-mailkite-signature"], raw, SECRET)) {<br>res.writeHead(401).end();<br>return;

const event = JSON.parse(raw); // one JSON object: no form decoding, no multipart parts<br>if (event.type === "email.received") {<br>console.log(event.from.address, "·", event.subject, "·", event.text);<br>res.writeHead(200).end("ok");<br>}).listen(3000);<br>(Can’t take a dependency? The header is x-mailkite-signature: t=,v1=, where v1 is an HMAC-SHA256 over "." with your webhook secret and t is in milliseconds . You can hand-roll that with node:crypto, but the SDK call also handles the replay window and the constant-time compare, which is where hand-rolled versions usually go wrong. Details in webhook security.)

The two pipelines, side by side

Mailgun Routes<br>sender<br>MailgunMX + parse<br>routespriority rules<br>POSTform-enc<br>your handlerHMAC + form decode<br>your app

…plus the rule expressions you author, prioritize, keep in sync with your app,<br>and re-debug when a message doesn't match<br>← form decode, timestamp+token HMAC, attachment parts: yours<br>MailKite<br>sender<br>MX edgeparse + auth<br>JSON webhooksigned, retried, replayable<br>your app

the 20 lines above are the whole "your app" integration

Receiving one email: what you author and operate with Mailgun Routes vs MailKite. Same input, one rule engine and one form decoder apart.

Where Mailgun wins, honestly

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 genuinely expressive: you can match on recipient, headers, or a catch-all and chain actions. If you already live in Mailgun for outbound 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<br>Expression: match_recipient(".*@inbound.myapp.ai")<br>Actions: forward("https://myapp.ai/hooks/mailgun"), stop()<br>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 also 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). Your handler decodes the form, verifies an HMAC over timestamp + token, and pulls attachments out as file parts. This is Mailgun’s documented idiom:

// The Mailgun side: form decode + timestamp/token HMAC + attachment parts.<br>import express from "express";<br>import multer from "multer";<br>import crypto from "node:crypto";

const app = express();<br>const upload = multer(); // routes POST multipart/form-data when attachments ride along

app.post("/hooks/mailgun", upload.any(), (req, res) => {<br>const { timestamp, token, signature } = req.body;<br>const expected = crypto<br>.createHmac("sha256", process.env.MAILGUN_SIGNING_KEY)<br>.update(timestamp + token)<br>.digest("hex"); // Mailgun's documented scheme; use a timing-safe compare in production<br>if (expected !== signature) return res.sendStatus(401);

console.log(req.body.from, "·", req.body.subject, "·", req.body["body-plain"]);<br>// attachments: req.files, multipart...

mailgun mailkite routes form body from

Related Articles