All posts
The Mailgun alternative for AI agents
Gabe 20 min read

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.

Mailgun vs MailKite
Mailgun vs MailKite — the same job (an inbox for your AI agent), two approaches.

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.

Mailgun sender Mailgunroute DSL form POSTurlencoded fields your codedecode + verify sig + auth your agent …plus the Routes rule engine you author and keep, and the SPF/DKIM result you read out of the message-headers yourself (no single verdict block, no DMARC) ← yours to write before the model runs MailKite sender MX edgeparse + auth JSON webhooksigned, parsed, auth block your agent the ~20 lines below are the whole agent integration
Giving an agent an inbox: what runs before the model on Mailgun vs on MailKite. Same input, two very different first steps.

Everything below is a repo you can run while you read. Clone demo-mailgun-ai-agent and npm start, or open it in StackBlitz (real Node in a browser tab, no account) where it runs on load. You don’t need a domain to see the loop run: a webhook normally needs a public URL, so npm start boots the server and self-fires a correctly signed email.received event at its own localhost — the whole receive→verify→think→reply loop runs in one command (the reply is a dry-run, and managed-route.mjs dry-runs the hosted path too). It also ships the Mailgun path next to it in mailgun-contrast/, so every contrast here is something you can diff and test, not take on faith. A domain comes in only when you want real inbound email to arrive.

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. This is the heart of server.mjs in that repo — the repo wraps it in a dry-run harness and a stub agent so it runs with no key and no LLM, but the loop is exactly this, and it runs as pasted on Node 18+ (npm install mailkite express):

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. npm start boots the server and self-fires the exact payload MailKite’s delivery worker sends, so in one command you watch the agent read the task, take its trust verdict straight off event.auth, and dry-run the reply — no account or LLM required. 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:

  • The Routes filter DSL is expressive. Filters take regular expressions: match_recipient("agent.*@mg.myapp.ai") catches an entire family of scoped addresses with one rule, and catch_all() plus priority ordering lets you fan many addresses into one handler. If an agent mints a fresh address per task, that regex is real leverage.
  • store() decouples big attachments. A route can store() the message (up to 3 days) and notify you with a retrieval URL, so a 20 MB PO doesn't time out your webhook. Useful when an agent processes large files.
  • Mature sending, logs, and EU data residency. Deliverability tooling, tag-level analytics, detailed event logs, and an EU region are all first-class. If your agent sends at volume and you need that depth, it's there.

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 single normalized auth verdict block. Mailgun does check SPF and DKIM — with spam filtering enabled it stamps its own X-Mailgun-Spf (Pass/Neutral/Fail/SoftFail) and X-Mailgun-Dkim-Check-Result (Pass/Fail) headers, which is real and stable across relays — but you enable that, read the two results out of the message-headers array, and get no DMARC verdict at all, so the trust call is a partial one you fold together yourself.

Here’s that honestly, in Mailgun’s own idiom (mailgun-contrast/handler.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 single verdict block — with spam filtering on, Mailgun stamps
  // X-Mailgun-Spf / X-Mailgun-Dkim-Check-Result headers you read from `headers` and fold
  // yourself (no DMARC result), 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 pulling the auth results out of the headers array — all sitting between an inbound email and the first line of agent logic. The repo makes the auth step concrete: npm test reads Mailgun’s own X-Mailgun-Spf/X-Mailgun-Dkim-Check-Result headers — stable, to Mailgun’s credit — and shows the cost that remains: they only appear once you enable spam filtering, they’re two MIME headers you fold yourself rather than one verdict block, and there’s no DMARC result to weight at all. Stage by stage:

Mailgun MXan email arrives Route matchesregex DSL you author form POSTx-www-form-urlencoded parse formcontent-type branch, hyphen keys verify signatureHMAC(timestamp+token), replay read SPF/DKIMX-Mailgun headers, no DMARC your agent logicfinally, think and reply Every box above the blue one is yours to build. On MailKite, the blue box is the only box.
The Mailgun inbound path for an agent, stage by stage. MailKite collapses every gray stage into a signed, parsed JSON webhook with an auth block.

The comparison, no adjective inflation

MailgunMailKite
Give an agent an addressAuthor a Route (filter DSL + action)Set a webhook on the address
Inbound payloadx-www-form-urlencoded / multipart fieldsOne parsed JSON webhook
Field accessHyphenated keys (body-plain, stripped-text)Typed event.text, event.html
SignatureIn the body; you HMAC timestamp+tokenOne verifyWebhook() call
Replay protectionCache the token yourselfBuilt into verifyWebhook()
Auth resultsSPF/DKIM as X-Mailgun headers (spam filter on); no DMARCDecoded auth verdict block, incl. DMARC
Reply / threadingSeparate Send call; set In-Reply-To yourselfmk.send({ inReplyTo: event.id })
Run the agent for youNoRoute with action: "agent"
Inbound routes limitPlan-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 SPF/DKIM as headers you fold yourself with no DMARC. MailKite hands it JSON, one verify call, and a single decoded auth block with DMARC included — 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 folds SPF/DKIM together or goes without DMARC:

{
  "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=…" }
  ]
}

Don’t take the shape on faith — fire one and watch it come back, auth block and all. Spoof the SPF/DKIM to fail and see the verdict flip; that’s the field the Mailgun path makes you fold from its X-Mailgun headers, still without a DMARC result:

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 — managed-route.mjs in the demo dry-runs creating exactly that route. 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 read SPF/DKIM out of Mailgun’s X-Mailgun headers (spam filtering enabled, no DMARC) and fold them yourself — 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.

Discuss this post: Hacker News Share on X Share on LinkedIn

Related posts