The Mailgun alternative for AI agents
Mailgun does receive inbound, but through Routes — a filter-expression rule engine you author and maintain — and the message lands as application/x-www-form-urlencoded fields you decode and verify yourself, not clean JSON. MailKite (which we build) gives an agent a real scoped inbox that arrives as parsed JSON with a receive→reply loop. For developers wiring an autonomous agent to email.
Here is the difference an agent feels, in one picture: the same inbound email, and everything the agent (or you, on its behalf) has to operate to read it on each side. Mailgun genuinely receives mail. But to give an agent an inbox on it you first author a rule engine in a filter DSL, and the message still arrives form-encoded, so the agent’s “read my mail” step is a decode-and-verify chore before a single model token runs. The rest of this post is the honest version of that diagram — where Mailgun wins for agents, what it asks you to build, and the ~20 lines that are the whole MailKite side.
Here is the whole MailKite side: the bring-your-own-agent loop. Email in, verify the signature, hand the body to your model, reply through the same client. It runs as pasted on Node 18+ (npm install mailkite express), and it’s the entire integration from the runnable demo repo.
import express from "express";
import { MailKite } from "mailkite";
const app = express();
const mk = new MailKite(process.env.MAILKITE_API_KEY);
const SECRET = process.env.MAILKITE_WEBHOOK_SECRET;
app.use("/hooks/agent", express.raw({ type: "application/json" }));
app.post("/hooks/agent", async (req, res) => {
// signature check, replay window, constant-time compare — one call
if (!MailKite.verifyWebhook(req.headers["x-mailkite-signature"], req.body, SECRET)) {
return res.sendStatus(401);
}
res.sendStatus(200); // ack fast; run the agent out of band
const event = JSON.parse(req.body); // already parsed email, not form fields
if (event.type !== "email.received") return;
// Treat the body as untrusted INPUT, never as instructions (see below).
const answer = await runAgent({
task: event.text,
from: event.from.address,
trusted: event.auth.spf === "pass" && event.auth.dmarc === "pass",
});
await mk.send({
from: event.to[0].address, // reply from the address it hit
to: event.from.address,
subject: `Re: ${event.subject}`,
inReplyTo: event.id, // threads the reply
html: answer.html,
});
});
app.listen(3000);
That’s a fully autonomous email agent: it hears, it thinks, it answers. The identical handler shape exists for Python, Ruby, Go, PHP, and Java; see the receiving docs and sending docs. Open it in StackBlitz to run real Node in a browser tab and watch a signed sample event land.
Where Mailgun wins for agents, honestly
This isn’t “Mailgun is bad.” Mailgun has been doing developer email for over a decade, and a few of its properties are genuinely nice for automation:
If you’re already on Mailgun for outbound and just want to bolt on a receive path, Routes is a reasonable place to do it. The rest of this post is about what that path costs an agent builder.
What Mailgun asks of an agent builder
Two things, and both land before your model does any work. First, inbound isn’t a setting — it’s a rule engine you author and maintain: a Route is a filter expression paired with an action. Second, when a route forwards to a URL, Mailgun POSTs application/x-www-form-urlencoded (and multipart/form-data when there are attachments, which is a different parser you also have to handle). The parsed fields use hyphenated keys like body-plain and stripped-text, the signature arrives in the body rather than a header, and there’s no normalized SPF/DKIM/DMARC verdict — you read auth results out of the raw message-headers yourself.
Here’s that honestly, in Mailgun’s own idiom (mailgun-contrast/server.mjs in the demo repo):
// 1. Author the rule engine — a Route is a filter DSL + an action.
await mg.routes.create({
expression: 'match_recipient("agent.*@mg.myapp.ai")',
action: ['forward("https://myapp.ai/hooks/mailgun")', "stop()"],
priority: 10,
});
// 2. Receive it. Mailgun POSTs application/x-www-form-urlencoded
// (multipart/form-data when there are attachments — a different parser).
import crypto from "node:crypto";
app.post("/hooks/mailgun", express.urlencoded({ extended: false }), (req, res) => {
const { timestamp, token, signature } = req.body;
// verify: HMAC-SHA256 over timestamp+token, signing key, constant-time
const digest = crypto.createHmac("sha256", process.env.MG_SIGNING_KEY)
.update(timestamp + token).digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(signature));
if (!ok) return res.sendStatus(401); // a replay cache is still yours to add
const from = req.body.sender;
const text = req.body["body-plain"]; // hyphenated cousins: stripped-text,
const headers = JSON.parse(req.body["message-headers"] || "[]"); // message-headers…
// SPF/DKIM aren't a verdict block — you find them in `headers` yourself,
// then run your agent on `text`, then POST /messages to reply. That's a
// separate Send API call, and threading is yours to set with In-Reply-To.
res.sendStatus(200);
});
None of this is exotic. But it’s a rule engine, a content-type branch, a hand-rolled HMAC (with the replay cache still a TODO), hyphenated field access, and header-spelunking for auth — all sitting between an inbound email and the first line of agent logic. Stage by stage:
auth block.The comparison, no adjective inflation
| Mailgun | MailKite | |
|---|---|---|
| Give an agent an address | Author a Route (filter DSL + action) | Set a webhook on the address |
| Inbound payload | x-www-form-urlencoded / multipart fields | One parsed JSON webhook |
| Field access | Hyphenated keys (body-plain, stripped-text) | Typed event.text, event.html |
| Signature | In the body; you HMAC timestamp+token | One verifyWebhook() call |
| Replay protection | Cache the token yourself | Built into verifyWebhook() |
| Auth results | Dig SPF/DKIM out of raw headers | Decoded auth verdict block |
| Reply / threading | Separate Send call; set In-Reply-To yourself | mk.send({ inReplyTo: event.id }) |
| Run the agent for you | No | Route with action: "agent" |
| Inbound routes limit | Plan-gated (e.g. 5 on Basic) | Webhooks on any verified address |
The through-line: Mailgun’s Routes are flexible and its sending is mature, but the inbound half hands an agent form fields, a hand-rolled verify, and no auth verdict. MailKite hands it JSON, one verify call, and a decoded auth block — the fields an agent actually branches on.
What actually hits your agent’s webhook
The same inbound email, delivered parsed. No form decode, no content-type branch, and the auth block means the agent never re-derives SPF/DKIM/DMARC from raw headers:
{
"id": "msg_2Hk9…",
"type": "email.received",
"from": { "address": "ada@example.com" },
"to": [{ "address": "agent@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=…" }
]
}
That auth block is load-bearing for agents. The email body is untrusted input, never instructions: From: is plain text, so anyone can forge a sender and then tell your agent what to do. Check auth before you weight a sender, and remember that checking it is necessary, not sufficient — bound what a fooled agent can actually do. The reasoning is in agent inbox security by design; read it before you point any loop at anything that matters.
If you’d rather not host the loop at all, MailKite, which we build, can run the agent for you: a route whose action is "agent" carries a free-text agentPrompt, and each inbound message runs the model loop on a durable queue with a recorded transcript, a tool-round cap, and a timeout reaper. Same parsed inbound edge; the difference is only where the agent’s turns execute. Details in the inbox-agents docs.
FAQ
Can Mailgun receive inbound email for an agent?
Yes. You create a Route whose filter (for example match_recipient(...)) matches the address and whose action forward()s to your URL or store()s the message. Mailgun POSTs a parsed version of the email as application/x-www-form-urlencoded (or multipart/form-data with attachments). MailKite delivers the same message as JSON with an auth block, and you set a webhook instead of authoring a rule engine.
Why does the payload shape matter for an AI agent?
Because the first thing an agent has to do is read the mail. On Mailgun that’s a decode-and-verify step — branch on content-type, parse hyphenated form keys, HMAC timestamp+token, add your own replay cache, and dig SPF/DKIM out of raw headers — all before the model runs. On MailKite the handler reads event.text and event.auth directly.
How do I verify a Mailgun inbound request?
Concatenate the timestamp and token fields (no separator), compute HMAC-SHA256 with your Webhook Signing Key, and compare the hex digest to the signature field with a constant-time compare. The signature is in the body, not a header, and replay protection (caching the token) is yours to add. MailKite folds all of that into one verifyWebhook() call.
Is Mailgun’s Routes DSL ever the better choice?
When you need regex fan-out across many scoped addresses or a catch_all(), or you’re already on Mailgun for sending and want inbound in the same account, Routes is reasonable. The cost is that you author and maintain that rule engine and still decode form-encoded payloads on your side.
Do I have to leave Mailgun to use MailKite?
No. MailKite is a plain HTTPS webhook and REST API — call it from wherever your agent runs. You’re replacing the inbound Routes-and-form-decode path (and getting a decoded auth block), not your infrastructure. To start, DNS-verify a domain (MX to receive, SPF + DKIM to send); there’s no sandbox approval wait, and the free tier is 3,000 messages a month, in and out.
If Mailgun has your agent authoring Routes and decoding form fields just to read one email, there’s a simpler shape. Clone the demo repo (or run it in your browser), then point a domain at MailKite and your next inbound email reaches the agent as parsed JSON.
Related: the pillar on giving your AI agent its own inbox, agent inbox security by design, the full MailKite vs Mailgun Routes comparison, and the for-developers post the Mailgun Routes alternative.