The Mailchimp Transactional (Mandrill) alternative for AI agents
Mandrill can receive email, but it's gated behind a paid Mailchimp marketing plan and hands you inbound as a batched mandrill_events array with no auth verdict and no agent loop. MailKite (which we build) gives an agent a real scoped inbox delivered as one parsed JSON event with a receive→reply loop. For developers wiring an autonomous agent to an inbox.
So the first surprise isn’t technical: to point an agent at a Mandrill inbox you first need a paid Mailchimp marketing account (Standard or Premium), and then Transactional as an add-on on top. Mandrill hasn’t been standalone since 2016. Everything below is the honest version of what that buys an agent builder, where Mandrill genuinely holds up, and the ~20 lines that are the entire MailKite side.
mandrill_events array you unpack vs a single parsed email.received event with an auth verdict already attached.Everything below is a repo you can run while you read. Clone demo-mandrill-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 Mandrill path next to it in mandrill-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’s the whole MailKite side: 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);
const event = JSON.parse(req.body);
if (event.type !== "email.received") return;
// Body is untrusted INPUT, never instructions. Use auth to decide trust.
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 was sent to
to: event.from.address,
subject: `Re: ${event.subject}`,
inReplyTo: event.id, // threads the reply
html: answer.html,
});
});
app.listen(3000);
That’s the receive→think→reply loop, whole. mk.send() returns { id, status }, and 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 same handler shape exists for Python, Ruby, Go, PHP, and Java: see the receiving docs and sending docs. The rest of this post is the honest version of the diagram: where Mandrill genuinely fits an agent, what its inbound path asks you to build, and what the parsed payload actually contains.
Where Mandrill wins for agents, honestly
Mandrill is not a toy, and if you’re already living in Mailchimp it can be the pragmatic call:
I won’t pretend Mandrill can’t receive email. It can, and has for years. The friction for an agent is different: what it costs to get in the door, and how much unpacking sits between “an email arrived” and “my model can act on it.”
What Mandrill asks of an agent builder
Two things, before any model runs.
First, the account. Transactional is an add-on to a paid Mailchimp Standard ($20/mo) or Premium plan; Free and Essentials plans can’t enable it. Transactional itself is block-based: a block is 25,000 emails starting at ~$20, cheaper per block at volume. So an agent that just needs to read a few verification emails still starts around $40/month of marketing-suite spend before it sends its first reply.
Second, the inbound shape. Mandrill doesn’t hand you one clean message. It POSTs a form-encoded body whose mandrill_events field is a JSON string holding an array of events, and you verify the X-Mandrill-Signature by rebuilding the signed string yourself: the exact webhook URL, then every POST key and value concatenated in sorted key order, HMAC-SHA1’d with your webhook key, base64-encoded. Here’s that handler, honestly, in Mandrill’s own idiom — it’s mandrill-contrast/handler.mjs in the same repo, sitting right next to the MailKite server.mjs above so you can put them side by side:
// Mandrill inbound: form-encoded POST, mandrill_events is a JSON string you unpack
import express from "express";
import crypto from "node:crypto";
const app = express();
app.use(express.urlencoded({ extended: false }));
const WEBHOOK_KEY = process.env.MANDRILL_WEBHOOK_KEY;
const WEBHOOK_URL = "https://myapp.ai/hooks/mandrill"; // must match what you registered, exactly
function verify(body, signature) {
let signed = WEBHOOK_URL;
for (const key of Object.keys(body).sort()) signed += key + body[key]; // sorted key+value, no delimiter
const expected = crypto.createHmac("sha1", WEBHOOK_KEY).update(signed).digest("base64");
return expected === signature; // Mandrill sends base64 of the BINARY hmac, not hex
}
app.post("/hooks/mandrill", (req, res) => {
if (!verify(req.body, req.headers["x-mandrill-signature"])) return res.sendStatus(401);
const events = JSON.parse(req.body.mandrill_events); // a JSON array inside a form field
for (const ev of events) {
if (ev.event !== "inbound") continue;
const m = ev.msg;
// assemble your own trust verdict from separate fields
const trusted = m.spf?.result === "pass" && m.dkim?.signed && m.dkim?.valid;
runAgent({ task: m.text, from: m.from_email, trusted });
// …then a SEPARATE Messages/send API call to reply. No inReplyTo helper; you set headers.
}
res.sendStatus(200);
});
app.listen(3000);
None of this is hard. But it’s a batch you iterate, a signature you rebuild from sorted POST pairs, and a trust verdict you compose by hand, all before the interesting part. Then replying is a second, unrelated API call where threading is on you. The repo pins both costs so they aren’t just claims — npm test shows inbound arriving as a batch array you iterate, and trustFromResults() returning no DMARC verdict at all, the field the MailKite path reads straight off event.auth.
email.received event with an auth verdict already resolved.The comparison, for an agent
| Mandrill (Mailchimp Transactional) | MailKite | |
|---|---|---|
| To start | Paid Mailchimp Standard+ plan, then Transactional add-on | DNS-verify a domain (SPF+DKIM to send, MX to receive) |
| Pricing floor | ~$20/mo plan + ~$20 per 25k-email block | Free tier: 3,000 messages/mo (in + out), no per-domain fee |
| Inbound shape | mandrill_events[] array in a form-encoded POST | One parsed email.received JSON event |
| Auth verdict | Separate spf/dkim/spam_report you combine | One auth block: { spf, dkim, dmarc, spam } |
| Signature check | Rebuild HMAC-SHA1 over sorted POST pairs yourself | verifyWebhook(sig, raw, secret) — one call |
| Reply / threading | Separate send call; you set threading headers | mk.send({ inReplyTo: event.id }) threads it |
| Agent loop | None; you host the whole model loop | BYO loop, or a route with action: 'agent' |
The through-line: Mandrill receives, but every convenience an agent wants (a single message, a ready trust verdict, a threaded reply, a place to run the loop) is left for you to assemble, on top of a paid marketing plan you may not otherwise want.
What actually hits your agent’s webhook
On MailKite the same inbound email arrives already decoded, one event, no array to unpack and no signature to rebuild by hand:
{
"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 Mandrill path makes you assemble from separate result strings, with no DMARC to read at all:
That auth block is load-bearing for an agent. The email body is untrusted input, never instructions: From: is trivially forged, so a naive loop that obeys what an email says is a prompt-injection target. Check auth before you weight a sender, and bound what a fooled agent can even do. The full argument is in agent inbox security by design; read it before you point any loop at real mail.
Don’t want to host the loop?
MailKite, which we build, can run the agent for you. Give a route an action of agent and an agentPrompt, and every matching inbound message runs the model loop on a durable queue, capped and recorded as a transcript you can drill into:
await mk.createRoute({
match: "support@myapp.ai",
action: "agent",
agentPrompt: "Answer billing questions from the docs. Escalate anything else to humans@myapp.ai.",
});
Same parsed inbound edge either way, and both are in the repo: bring-your-own is server.mjs (the code at the top), and the hosted path is managed-route.mjs, which dry-runs the route creation with no key. Bring-your-own wins when the agent’s brain is your code; the built-in route wins when the job is “read this mail, answer or escalate” and you’d rather not run infrastructure. Either way, mandrill-contrast/handler.mjs is the same job the Mandrill way, and npm test runs both sides so you can diff the shapes.
FAQ
Can Mandrill (Mailchimp Transactional) receive inbound email?
Yes. You add an inbound domain, point your MX records at Mailchimp Transactional’s servers, and create mailbox routes that POST matching mail to your webhook as a mandrill_events array. It receives fine; the work is unpacking the batched array, verifying the X-Mandrill-Signature, and composing a trust verdict from separate spf/dkim/spam_report fields yourself.
Do I need a paid Mailchimp account to use Mandrill? Yes. Since 2016 Mandrill isn’t standalone: it’s an add-on that requires a paid Mailchimp Standard or Premium marketing plan, then transactional blocks (25,000 emails per block) on top. MailKite has no marketing-plan prerequisite and a free tier of 3,000 messages a month, inbound and outbound.
How is the Mandrill inbound webhook signed?
With an X-Mandrill-Signature header: base64 of an HMAC-SHA1, keyed by your webhook’s auth key, over your exact webhook URL followed by every POST key and value concatenated in sorted key order. It must be the binary HMAC base64-encoded, not hex. MailKite verifies its signature in one call: MailKite.verifyWebhook(sig, rawBody, secret).
Can an agent read verification codes and reply on its own with MailKite?
Yes. Inbound arrives as parsed JSON, your handler passes event.text to your model, and the agent replies with mk.send({ inReplyTo: event.id }) from the address it was written to. No IMAP polling, no shared personal Gmail, no OAuth token refresh.
Is MailKite cheaper than Mandrill for an agent?
For a low-volume agent, almost always: MailKite starts free (3,000 messages/mo, in + out) with no per-domain fee, while Mandrill needs a paid Mailchimp plan ($20/mo) plus at least one transactional block ($20). At very high transactional send volume Mandrill’s block pricing gets competitive per message; the trade is the marketing plan and the inbound assembly you own.
If Mandrill has you standing up a marketing plan and unpacking a mandrill_events array just so an agent can read one email, there’s a shorter path. Clone the demo repo (or run it in your browser), then point a domain at MailKite and your agent’s next inbound email arrives as one parsed event with an auth verdict attached.
Related: the pillar on giving your agent its own inbox and agent inbox security by design.