The SendGrid alternative for AI agents
SendGrid is an outbound-first sending platform; its one inbound path, Inbound Parse, POSTs mail as multipart/form-data with a charsets map your code must honor before an agent can even read a verification code. MailKite (which we build) gives an agent a real inbox: parsed JSON, an auth block for trust, and a receive→reply loop. For developers wiring an autonomous agent to email.
Start from what the agent actually needs and the mismatch is immediate: an autonomous program that reads its own mail needs an inbox, and SendGrid’s whole gravity is the other direction. It’s a sending API. Receiving exists as one feature, Inbound Parse, and it hands your endpoint a multipart/form-data POST with a charsets map you have to honor before any field is safe to read. So before your agent can reason about an email or pull the six-digit code out of it, it has to decode a form and fix character sets. That decode step is the whole subject of this post.
Here’s the whole bring-your-own-agent loop. Email in, verify the signature, hand the decoded 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) => {
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;
// event.text is already UTF-8 — no charsets map. Body is untrusted INPUT, not instructions.
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 an agent that hears, thinks, and answers on its own. The companion repo is github.com/mailkite/demo-sendgrid-ai-agent with both sides wired up; open it in StackBlitz to run the loop against a signed sample event. The identical handler shape exists for Python, Ruby, Go, PHP, and Java; see the receiving docs and sending docs.
Where SendGrid wins for agents, honestly
SendGrid is a mature, high-scale sending platform, and for the outbound half of an agent it’s a solid pick. If your agent’s job is to send transactional mail (receipts, alerts, digests) at volume, SendGrid’s deliverability tooling, dedicated IPs, and years of sending reputation are real advantages, and its Event Webhook (opens, bounces, spam reports) is genuinely well-built and Ed25519-signed. Inbound Parse itself does parse MIME for you: you get text and html fields, a headers blob, SPF/dkim fields, and a spam score, not raw multipart bytes. If your agent already lives on SendGrid for sending and inbound is a light “email us and we POST it to a script,” it works and you don’t need to move. This post is for the case where the agent has to read mail reliably: pull a verification code, follow a thread, decide whether to trust a sender. That’s where the form gets in the way.
What SendGrid asks of your agent
The POST is multipart/form-data, and the charsets field is the tell. SendGrid hands your endpoint a JSON map of field to character set, like {"to":"UTF-8","subject":"UTF-8","from":"UTF-8","text":"iso-8859-1"}. It decodes the headers for you but leaves the body in whatever charset the sender used, so re-decoding that to UTF-8 is your code’s job. Skip it and you get the canonical bug: a £ arrives as £, a Japanese body as mojibake, because the field was latin-1 or Shift_JIS and you read it as UTF-8. For an agent that matters more than for a human, because a mangled body is a mangled prompt. A verification code that arrives with the surrounding text corrupted is a code your model may misread or refuse.
Here’s the handler that does it correctly, which is more than req.body.text:
// SendGrid Inbound Parse: multipart/form-data → re-decode per-field charsets, THEN run the agent.
import express from "express";
import multer from "multer";
import iconv from "iconv-lite";
const app = express();
const upload = multer(); // Parse POSTs multipart/form-data; attachments ride as file parts
app.post("/hooks/sendgrid", upload.any(), async (req, res) => {
// No signature on the POST — Inbound Parse doesn't sign it. Secure the URL / check SPF yourself.
const charsets = JSON.parse(req.body.charsets || "{}"); // {"subject":"UTF-8","text":"iso-8859-1"}
const decode = (field) =>
charsets[field] && charsets[field].toUpperCase() !== "UTF-8"
? iconv.decode(Buffer.from(req.body[field], "binary"), charsets[field])
: req.body[field];
const text = decode("text"); // only now is the body safe to feed a model
const from = decode("from");
// No spf/dkim/dmarc verdict in the payload — you parse it out of the `headers` blob yourself.
const answer = await runAgent({ task: text, from });
res.sendStatus(200); // and the reply is a separate Send API call you build
await sendGridReply(from, answer); // not shown: compose + POST to the v3 mail/send API
});
app.listen(3000);
Three things bite an agent builder here:
None of this is exotic. But it’s a form parser, a charset table, a header parser, and a homemade endpoint guard standing between an inbound email and “the agent can act on it.”
The agent path on each side
The comparison, no adjective inflation
| SendGrid Inbound Parse | MailKite | |
|---|---|---|
| Built for | Outbound sending; inbound is one feature | Inbound → webhook, and sending |
| Payload to the agent | multipart/form-data you parse | Single JSON email.received |
| Body encoding | Re-decode per charsets field | text/html pre-decoded to UTF-8 |
| Sender trust | Raw SPF/dkim, no DMARC verdict | auth{spf,dkim,dmarc,spam} in payload |
| Signature on the POST | None — secure the URL yourself | MailKite.verifyWebhook(sig, body, secret) |
| On endpoint error | 5xx retried ~72h; 4xx/DNS dropped | Retried with backoff, replayable |
| Reply / threading | Separate Send API call you compose | mk.send({ inReplyTo }), threaded |
| Managed agent loop | None | Route with action: 'agent' |
| Free tier (2025+) | 60-day trial, then paid | 3,000 msgs/mo, in + out |
The through-line: SendGrid is excellent at pushing mail out. For an agent that has to take mail in, decide whether to trust it, and reply, MailKite hands you a finished, authenticated message instead of a form to decode.
What actually hits your agent’s webhook
Same inbound email, delivered parsed. No charsets map, no multipart, and an auth block so the agent never re-derives SPF/DKIM/DMARC from a headers blob:
{
"id": "msg_2Hk9…",
"type": "email.received",
"from": { "address": "noreply@acme.dev" },
"to": [{ "address": "agent@myapp.ai" }],
"subject": "Your verification code is 481920",
"text": "Enter 481920 to finish signing in. Code expires in 10 minutes.",
"html": "<p>Enter <b>481920</b> to finish signing in.</p>",
"threadId": "<a1b2c3@mail.acme.dev>",
"auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass", "spam": "ham" },
"attachments": []
}
The auth block is the load-bearing part for an agent. Email From: is plain text, so a sender is trivially spoofable, which means the body is untrusted input and never instructions. Before your agent weights what a message tells it to do, check whether SPF and DMARC actually passed. That check is necessary but not sufficient: the real defense is architectural, bounding what a fooled agent can even do. The agent-security post is the honest write-up of that, and it’s worth reading before you point any loop at real mail.
If you’d rather not host the loop
MailKite, which we build, has a second mode: instead of running the receive→think→reply loop on your own server, make the route itself the agent. A route with action: "agent" carries a free-text prompt, and MailKite runs the model loop for you on a durable queue, records each run as a transcript, and replies with a threaded send_email tool call:
await mk.createRoute({
match: "agent@myapp.ai",
action: "agent",
agentPrompt: "Complete signups: read verification codes from trusted senders and confirm. Escalate anything else.",
});
Same parsed, authenticated inbound edge either way. To start, DNS-verify the domain (SPF + DKIM to send, MX to receive). There’s no sandbox and no approval wait. If your app can only speak SMTP, the submission edge takes AUTH on :587/:465 so it can still send.
FAQ
Can SendGrid give an AI agent its own inbox?
Not as a first-class feature. SendGrid is a sending platform; its one inbound path is Inbound Parse, which MX-routes a domain to mx.sendgrid.net and POSTs each message to your endpoint as multipart/form-data. You build the “inbox” yourself on top of that form. MailKite delivers inbound as parsed JSON to a webhook and can run the agent loop for you.
Why does the charsets map matter for an agent specifically?
Because a corrupted body is a corrupted prompt. Inbound Parse hands you a charsets map (e.g. {"subject":"UTF-8","text":"iso-8859-1"}) and you must re-decode the non-UTF-8 fields yourself. Skip it and a verification code or thread arrives as mojibake, which your model may misread or refuse. MailKite delivers text/html already in UTF-8.
How does the agent know whether to trust an email?
On MailKite the payload includes an auth{spf,dkim,dmarc,spam} verdict, so the agent can check authentication before acting on instructions in the body. Inbound Parse gives you raw SPF/dkim fields and a headers blob to derive trust from yourself, with no single DMARC verdict.
Does Inbound Parse sign its webhook so I can verify it?
No. Inbound Parse doesn’t sign the POST, so you secure the endpoint with a hard-to-guess URL or by checking SPF/sender_ip. (SendGrid’s separate Event Webhook does support Ed25519 verification; Inbound Parse never got it.) MailKite signs every webhook and MailKite.verifyWebhook checks the signature, replay window, and constant-time compare in one call.
Can I keep SendGrid for sending and use MailKite only for inbound? Yes. They’re independent. Point an address at MailKite for the agent’s inbound and authenticated replies, and keep SendGrid for whatever outbound volume you already run there. Nothing about MailKite’s webhook requires leaving your existing sending setup.
If your agent’s inbox is really a form parser and a charset table bolted onto a sending API, there’s a cleaner shape. 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, authenticated JSON.
Related: the pillar on giving your agent an inbox, agent inbox security by design, the full MailKite vs SendGrid Inbound Parse comparison, and the for-developers SendGrid Inbound Parse alternative.