The Postal alternative for AI agents
Postal is a full self-hosted mail server: it sends, it receives inbound over incoming routes, and it can POST parsed messages to an HTTP endpoint. The catch for an agent inbox is operational, not technical. You run the box (MariaDB, Caddy, DNS, TLS, IP warmup, rDNS, blocklists). MailKite (which we build) is that same parsed-JSON receive→reply loop with no server to operate. For developers deciding whether to self-host an agent's inbox or hand off the ops.
The important thing to say up front is that Postal is good software and it is not missing a feature here. It receives inbound, it parses, and its HTTP endpoint will POST your agent a JSON object with plain_body, html_body, and decoded attachments. The question isn’t whether Postal can give an agent an inbox. It’s who operates the mail server that does it. With Postal that’s you: MariaDB, Caddy, Docker, DNS, TLS, PTR records, IP warmup, blocklist monitoring, patching. Here’s the same inbound email on both sides, and everything you keep alive to receive it.
Everything below is a repo you can run while you read. Clone demo-postal-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 — or a mail server — 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, and 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 Postal path next to it in postal-contrast/, so every contrast here is something you can diff and test, not take on faith. A domain — and, on Postal, a whole server — comes in only when you want real inbound email to arrive.
Here’s the entire MailKite side of that picture: email in, verify the signature, hand the parsed 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);
if (event.type !== "email.received") return;
// The body is untrusted INPUT, never instructions. Weight the auth verdict.
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 a fully autonomous email agent: it hears, it thinks, it answers — and no mail server in that file. 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, no server, no 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 it in your browser. The rest of this post is the honest version of the diagram: where Postal genuinely wins for an agent, and exactly what it asks you to run.
Where Postal wins for agents, honestly
Postal is a real, mature mail platform, MIT-licensed, that you host end to end. For an agent builder, three things make it a legitimate choice, not a compromise:
And a currency note, because most tutorials get it wrong: Postal v3 (2024) dropped RabbitMQ. Older guides that tell you to run RabbitMQ plus a cron requeuer are describing v1/v2. The current stack (latest release v3.3.7, June 2026) is Docker, MariaDB 10.6+, and Caddy, with a database-backed work queue. It’s a Ruby on Rails app. Fewer moving parts than the internet remembers, which makes the honest comparison closer than you’d think.
What Postal asks of an agent builder
Here’s the part nobody quotes you when they say “just self-host.” Giving your agent an inbox with Postal means standing up and keeping alive a full sending-and-receiving mail server. Top to bottom:
Now the code, in Postal’s idiom — it’s postal-contrast/handler.mjs in the same repo, sitting right next to the MailKite server.mjs above so you can diff them. This is a real handler for a Postal incoming route pointed at an HTTP endpoint, using its parsed (“processed”) payload. It’s honest work, and it’s more than the MailKite handler asks of you in two specific places:
// Postal incoming route → HTTP endpoint (processed payload).
// Postal must return 200 within 5s or it retries and eventually bounces.
import express from "express";
const app = express();
app.use(express.json({ limit: "50mb" })); // attachments arrive base64-inline
app.post("/postal/incoming", async (req, res) => {
const m = req.body; // { rcpt_to, mail_from, subject, plain_body, html_body,
// spam_status, attachments: [{ filename, content_type, data }], ... }
// 1) No SPF/DKIM/DMARC verdict block. You get spam_status; the aligned auth
// result an agent needs for injection-trust lives in the raw headers,
// so you'd parse Authentication-Results yourself to decide `trusted`.
const trusted = m.spam_status === "NotSpam"; // weaker signal than a real auth verdict
// 2) Attachments are base64 inline. Decode each one yourself.
const files = (m.attachments ?? []).map((a) => ({
filename: a.filename,
buffer: Buffer.from(a.data, "base64"),
}));
const answer = await runAgent({ task: m.plain_body, from: m.mail_from, trusted });
// Reply via Postal's send API — you host the auth and the server behind it.
await fetch("https://postal.yourco.dev/api/v1/send/message", {
method: "POST",
headers: { "X-Server-API-Key": process.env.POSTAL_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
to: [m.mail_from],
from: m.rcpt_to,
subject: `Re: ${m.subject}`,
html_body: answer.html,
}),
});
res.sendStatus(200); // must be fast, or Postal retries
});
app.listen(3000);
None of that is exotic if you run mail infrastructure. But two things are worth naming. First, the payload has spam_status, not a structured { spf, dkim, dmarc } verdict, so the trust signal your agent uses to resist prompt injection is weaker unless you parse Authentication-Results out of the raw message yourself. The repo makes that concrete: npm test feeds postal-contrast/handler.mjs a forged sender whose message simply isn’t spammy, and watches spam_status score it trusted while the real Authentication-Results says spf=fail — a normalized auth block would have caught it in one field. Second, and bigger: the fetch to your own Postal box only works because you’re keeping that box, its IP reputation, and its DNS healthy. If a fooled agent blasts a bad reply and your IP lands on a blocklist, that’s your incident to clean up, on your reputation.
The comparison, no adjective inflation
| Postal (self-hosted) | MailKite | |
|---|---|---|
| Who runs the mail server | You (Docker, MariaDB, Caddy) | Managed, nothing to host |
| Start receiving | Provision box, DNS, TLS, then routes | DNS-verify a domain, add a webhook |
| Inbound delivery | Parsed fields to your HTTP endpoint | One signed parsed JSON webhook |
| Auth for injection safety | spam_status; parse Authentication-Results yourself | auth block: spf/dkim/dmarc/spam inline |
| Attachments | Base64 inline in the payload | Short-lived signed URLs |
| IP reputation & warmup | Yours to build and defend | Handled on a monitored stream |
| Deliverability drift | Your incident, your blocklist delisting | Handled |
| Per-message cost | None (server + IP + your time) | Metered; free tier 3,000/mo in+out |
| Upgrades & patching | You run Postal + MariaDB upgrades | Handled |
The through-line: Postal wins total control, data residency, and no per-message fee, and it genuinely does parsed inbound. MailKite wins the ops you don’t run. If a fooled agent damages sender reputation, on Postal that IP is yours to rehabilitate; on MailKite the stream is monitored for you.
What actually hits your agent’s webhook
On MailKite the same inbound email arrives already parsed, and the auth block is the part that matters for an agent: it’s the verdict you weight before you let the body influence anything.
{
"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; on Postal that’s the field you’d parse out of Authentication-Results yourself:
The email body is untrusted input, never instructions. From: is plain text and trivially forged, so an agent that acts on what a message says is a prompt-injection target. The auth block is how you decide whether to trust a sender before you weight their words; it’s necessary, not sufficient, and the deeper rules live in the agent security post.
The managed version of the same loop
MailKite, which we build, is the hosted version of exactly the diagram above: a managed MX edge parses and authenticates the message, then POSTs your agent one signed email.received event with the body, a resolved threadId, attachments as signed URLs, and the auth verdict inline. To start, DNS-verify a domain (MX to receive, SPF + DKIM to send) and point a webhook at it. No sandbox, no approval wait, no server. If you’d rather not host the model loop either, a route with action: "agent" carries an agentPrompt and MailKite runs the loop for you on a durable queue, capped and recorded as a transcript you can drill into. Both ways live in the demo repo: server.mjs is bring-your-own (the code at the top), managed-route.mjs dry-runs the hosted route so you can see its shape without a key, and postal-contrast/handler.mjs is the same job the Postal way — npm test runs both sides so you can watch spam_status wave a forgery through before you decide which shape you want. The free tier is 3,000 messages a month, inbound and outbound, with no per-domain fee. SMTP-only apps can still send through the submission edge on :587/:465.
FAQ
Does Postal receive inbound email?
Yes. You point a domain’s MX at your Postal server and attach an incoming route to an address. The route forwards to an endpoint: an HTTP endpoint (POSTs the message to your URL), an SMTP endpoint, or a mailbox. The HTTP endpoint can send the raw base64 MIME or a parsed payload with plain_body, html_body, and decoded attachments. The catch is that you run the server doing it.
Is Postal free? The software is MIT-licensed with no per-message fee and no hosted SaaS tier. Your costs are the server, the sending IP, and your own operational time: DNS, TLS, IP warmup, blocklist monitoring, MariaDB maintenance, and Postal upgrades. Free-as-in-software, not free-as-in-no-work.
Does Postal still need RabbitMQ? No. Postal v3 (2024) removed the RabbitMQ requirement and uses a MariaDB-backed work queue instead; the cron requeuer is gone too. The current stack is Docker, MariaDB 10.6+, and Caddy. Guides that tell you to install RabbitMQ are describing v1 or v2.
Does Postal’s inbound payload include an SPF/DKIM/DMARC verdict?
Not as a structured block. The processed payload includes spam_status, but the aligned SPF/DKIM/DMARC result an agent should weight before trusting a sender lives in the raw Authentication-Results headers, so you’d parse it yourself. MailKite includes an auth object with the spf, dkim, dmarc, and spam verdicts already resolved.
Should my AI agent use self-hosted Postal or a managed inbox? Self-host Postal when control, data residency, or per-message economics at scale outweigh the ops, and you have the team to run a mail server. Use a managed inbox like MailKite when you’d rather receive a parsed, authenticated webhook and not own the MX, MariaDB, IP reputation, and patching. Both give the agent a real receive→reply loop; they differ in what you operate.
How does an agent reply from the address it was written to?
On Postal, POST to /api/v1/send/message with X-Server-API-Key, setting from to the route address. On MailKite, mk.send({ from: event.to[0].address, inReplyTo: event.id, … }) threads the reply automatically. Either way the reply goes out from the address the mail arrived at.
Postal is the right call if you want to own the whole mail stack and have the team to run it. If you want the same parsed receive→reply loop without standing up MariaDB, Caddy, DNS, TLS, and an IP reputation to defend, 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 parsed JSON.
Related: give your AI agent its own email inbox and agent inbox security by design.