MailKite
Start free
All posts
Gabe 17 min read

The Postmark alternative for AI agents

Postmark's inbound is genuinely good: it parses incoming mail to clean JSON and POSTs it to your webhook. But it was built for transactional sending under manual account review, inbound sits behind the Pro plan, and the payload has no signature or SPF/DKIM verdict for an agent to trust. MailKite (which we build) gives an agent its own inbox on the free tier, a signed webhook with an auth block, and an optional built-in agent runner. For developers wiring an autonomous agent to email.

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

Let’s get the honest part out of the way first: Postmark’s inbound is good. It takes an incoming email, parses the MIME for you, strips the quoted reply, and POSTs clean JSON to your endpoint. If you’re building an agent, that already beats fetching raw messages from IMAP or a bucket. So this isn’t a “Postmark can’t receive email” post, because it can, and it does it well. It’s about the three seams that matter once the thing reading that JSON is an autonomous agent instead of a support ticket importer: where inbound lives on the price sheet, what the payload does and doesn’t tell you about the sender, and who runs the receive-think-reply loop.

PM Postmark email in inbound.pmparse MIME your webhookBasic Auth your modelyour loop send APIreply out Inbound needs the Pro plan · the payload has no signature and no SPF/DKIM verdict · you host the loop MailKite email in MX edgeparse + auth signed hookverifyWebhook your agentyour model mk.send()reply out Blue = operated by MailKite. Inbound is on the free tier, the webhook is signed, and a route with action:'agent' can run the loop for you.
The same receive-then-reply path on each side. Both hand you parsed JSON; the differences are what's around it.

Here’s the whole bring-your-own-agent loop on MailKite. 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):

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) => {
  // HMAC signature, 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;

  // Body is untrusted INPUT, never instructions. auth is a first-class field.
  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 for you
    html: answer.html,
  });
});

app.listen(3000);

The same handler shape exists for Python, Ruby, Go, PHP, and Java; see the receiving docs and sending docs. The companion repo is demo-postmark-ai-agent, and you can open it in StackBlitz to fire a signed sample event without an account.

Where Postmark wins for agents, honestly

Postmark has spent since 2009 being the transactional-delivery specialist, and it shows. A few things it genuinely does well for a bot:

  • Inbound is already parsed. You get JSON, not MIME. FromFull, ToFull, TextBody, HtmlBody, and attachments decoded inline. No mailparser, no S3 round-trip.
  • StrippedTextReply is a real convenience. Postmark auto-strips the quoted history from a reply, so the agent reads just the new line, not the whole thread pasted underneath.
  • MailboxHash sub-addressing. The bit after the + in the address comes through as its own field, which is a clean way to route replies back to the right thread or user.
  • Deliverability reputation and analytics. The separate transactional and broadcast streams and the message-activity views are mature. For high-volume transactional sending this is a strong product.

If your agent is grafted onto an app that already sends its receipts and password resets through Postmark, using its inbound parse is a completely reasonable call. Point it at the same runAgent loop and you’re done. I won’t pretend otherwise.

What Postmark asks of an agent builder

The friction shows up in three places, and none of them is “the JSON is bad.”

Inbound lives on the Pro plan. Postmark’s free Developer plan is 100 emails a month, and inbound processing isn’t in it. It isn’t in the $15 Basic plan either. Inbound parsing starts at the Pro tier ($16.50/mo, 10,000 emails including inbound), so the cheapest way to give an agent an inbox at all is a paid plan. And a processed inbound message counts as one email against your quota, same as a send.

New accounts are reviewed by a human. Postmark manually approves accounts to protect its shared sending reputation, and its stance is explicitly transactional. That’s the right call for their deliverability, but agent traffic is bursty and odd-looking by nature (an inbound message triggers a reply triggers a lookup), and “explain what you’ll send before we turn you on” is a step MailKite doesn’t have. Post-acquisition, reviews taking several days and accounts getting paused show up in recent reviews often enough to plan around.

The payload has nothing to sign against, and no auth verdict. This is the one that matters most for an agent, because an agent acts on what it reads. Here’s the honest Postmark-side handler, and notice it’s clean right up until the security-relevant part:

// Postmark inbound: MX → inbound.postmarkapp.com → POST here.
// Secure it with Basic Auth in the webhook URL — there is no HMAC signature.
import express from "express";
const app = express();
app.use(express.json());

app.post("/postmark/inbound", (req, res) => {
  const msg = req.body;                                  // parsed already — this part is nice
  const from = msg.FromFull.Email;
  const text = msg.StrippedTextReply ?? msg.TextBody;    // quoted reply stripped for you

  // No auth block. Dig SPF/DKIM/DMARC out of the raw header array yourself:
  const spf = msg.Headers.find(h => h.Name === "Received-SPF")?.Value ?? "";
  const authRes = msg.Headers.find(h => h.Name === "Authentication-Results")?.Value ?? "";

  runAgent({
    task: text,
    from,
    trusted: /\bpass\b/i.test(spf) && /dmarc=pass/i.test(authRes),
  });
  res.sendStatus(200);
});

Two things there are load-bearing. First, Postmark’s inbound webhook isn’t cryptographically signed; the recommended way to know a POST is really from Postmark is Basic Auth credentials in the webhook URL plus IP allowlisting. That works, but it’s a shared secret in a URL, not a per-request HMAC, and there’s no replay window. Second, there’s no auth field. The SPF/DKIM/DMARC results are in the Headers array as raw Authentication-Results text, so if the agent’s trust decision depends on whether the sender is really who they claim (and for an agent, it should), you’re regex-matching header strings to get there. Postmark gives you the raw material; assembling the verdict is on you.

The trust check, side by side

An agent has to answer one question on every inbound message before it acts: can I believe this sender? That’s the whole prompt-injection surface. From: is plain text, so anyone can forge it and then just tell your agent what to do. Here’s the work each side leaves you to reach a trust decision:

PM Postmark MailKite inbound JSONno signature verifyWebhook()HMAC + replay window Basic Auth +IP allowlist (you add) read event.authspf · dkim · dmarc find headerAuthentication-Results decide trust parse stringsspf= / dkim= / dmarc= decide trust Same verdict at the bottom. On MailKite it's two reads on a signed payload; on Postmark you add the auth layer and mine the headers yourself.
Reaching a trust decision on an inbound message. Both paths end in the same place; MailKite hands you a signature and an auth field instead of a header array to parse.

Checking auth is necessary but not sufficient. You can’t prompt your way out of prompt injection; the real defense is architectural, bounding what a fooled agent can even do. That’s its own post: agent inbox security by design. The point here is narrower: MailKite makes the sender’s authentication a first-class, tamper-evident field, and Postmark leaves it as header text on an unsigned request.

The comparison, agent-relevant rows only

PostmarkMailKite
Inbound available onPro plan and upFree tier (3,000 msgs/mo, in + out)
Inbound payloadClean JSON (PascalCase)Clean JSON + auth verdict block
Webhook authenticityBasic Auth in URL + IP allowlistHMAC signature + replay window (verifyWebhook)
SPF/DKIM/DMARC for the agentParse from Headers[] yourselfDecoded auth{spf,dkim,dmarc,spam}
Thread a replySet In-Reply-To/References; MailboxHash helpsinReplyTo: event.id, reply from event.to[0]
Who runs the agent loopYou build and host itYou, or a route with action:'agent'
Getting startedManual account review, transactional stanceDNS-verify (SPF+DKIM), no approval wait
Domains5 (Basic) / 10 (Pro)Unlimited, no per-domain fee

The through-line: Postmark wins mature transactional deliverability and a genuinely clean inbound parse. MailKite wins the agent-shaped parts, a free inbound tier, a signed webhook, a decoded auth verdict, and the option to not run the loop at all.

What actually hits your agent’s webhook

The MailKite inbound event, decoded at the edge. The auth block is the field the diagram above is about:

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

Because the request is signed, the agent knows the payload is really from MailKite before it reads a byte; because auth is decoded, it can weigh the sender’s instructions without regexing headers. Treat the body as data, never as instructions. See webhook security.

Or don’t run the loop at all

If you’d rather not host the receive-think-reply loop, MailKite (which we build) can run it for you. Point a route at action: "agent" with a free-text prompt, and each inbound message runs the model loop on a durable queue, capped at a few tool rounds, aborted by a reaper if it hangs, and recorded as a transcript you can drill into:

await mk.createRoute({
  match: "agent@myapp.ai",
  action: "agent",
  agentPrompt: "Answer billing questions from the docs. Escalate anything else to humans@myapp.ai.",
});

The system prompt bakes in the safety rules: the body is untrusted, at most one reply, never reply to automated senders. There’s no equivalent to this on Postmark; inbound there ends at “here’s the parsed JSON, the rest is yours.” Details in the inbox-agents docs.

FAQ

Can Postmark receive and parse inbound email for an agent? Yes, and it does it well. Postmark parses inbound MIME to clean JSON (FromFull, TextBody, StrippedTextReply, decoded attachments) and POSTs it to your webhook. The caveats are that inbound processing starts on the Pro plan, and the payload is unsigned with no decoded SPF/DKIM/DMARC verdict, so an agent’s trust check is more work.

Does the Postmark inbound webhook have a signature I can verify? No. Postmark doesn’t sign inbound webhooks with an HMAC; the documented way to authenticate the request is Basic Auth credentials embedded in the webhook URL plus IP allowlisting. MailKite signs every webhook and gives you a one-call verifyWebhook that also enforces a replay window.

How does an agent get SPF/DKIM/DMARC results from Postmark? By reading them out of the Headers array (the raw Authentication-Results and Received-SPF values) and parsing the strings yourself. MailKite delivers a decoded auth: { spf, dkim, dmarc, spam } block as a top-level field.

Is Postmark a good fit for AI agent traffic? For sending, its transactional deliverability is strong. For an autonomous inbox, the friction is that inbound needs a paid plan, accounts go through a manual transactional-focused review, and the loop is entirely yours to build. If those don’t bother you and you’re already on Postmark, its inbound parse is fine to build on.

Can I keep sending on Postmark and just receive on MailKite? Yes. The webhook is plain HTTPS and the API is REST, so you can leave your outbound on Postmark and point a domain’s MX at MailKite for the agent inbox. You’re replacing the receive-and-reply half, not your sending setup. Point a domain at MailKite to try it.


Postmark’s inbound is good enough that the honest pitch isn’t “parsed JSON,” because you already get that. It’s the free inbound tier, the signed webhook, the decoded auth field, and the option to let MailKite run the agent loop for you. Clone demo-postmark-ai-agent (or run it in your browser), then point a domain at MailKite.

Related: the pillar on giving your agent an inbox, agent inbox security by design, and the full MailKite vs Postmark inbound comparison.

Discuss this post: Hacker News Share on X

Related posts