MailKite
Start free
All posts
Gabe 16 min read

The Amazon SES alternative for AI agents

Amazon SES can send and receive, but it hands an autonomous agent a human-reviewed sandbox before it can email anyone and raw MIME in an S3 bucket before it can read a reply. MailKite (which we build) gives the agent a real inbox: inbound arrives as parsed JSON and a receive→think→reply loop runs with no pipeline to operate. For developers wiring an agent to email.

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

An agent’s first job on email is usually the least glamorous one: read a six-digit code out of a signup message and type it back, or take a task someone emailed in and answer it. On SES that job starts with plumbing, not with the agent. Here’s the same inbound email on each side, and everything the agent has to run to act on it.

SES Amazon SES email in SESreceipt rule S3raw MIME SNS Lambdafetch + parse MIME agent …plus a sandbox approval before the agent can reply to anyone, the IAM roles, and the same-region SNS/Lambda setup you build and keep alive ← yours to write and operate MailKite email in MX edgeparse + auth JSON webhooksigned, retried, replayable agent the loop below is the whole "agent" box — receive, think, reply
One inbound email reaching an agent: what you operate on SES vs on MailKite. Same input, two very different pipelines.

Here’s the whole MailKite side: the agent’s receive→think→reply loop. It runs as pasted on Node 18+, one dependency (npm install mailkite).

// server.mjs — the whole agent loop. Full repo: github.com/mailkite/demo-amazon-ses-ai-agent
import { createServer } from "node:http";
import { MailKite } from "mailkite";

const mk = new MailKite(process.env.MAILKITE_API_KEY);
const SECRET = process.env.MAILKITE_WEBHOOK_SECRET;

createServer(async (req, res) => {
  let raw = ""; for await (const c of req) raw += c;
  // signature check, replay window, constant-time compare — one call
  if (!MailKite.verifyWebhook(req.headers["x-mailkite-signature"], raw, SECRET)) {
    return res.writeHead(401).end();
  }
  res.writeHead(200).end("ok");                     // ack fast; run the agent out of band

  const event = JSON.parse(raw);                    // already parsed — no S3, no MIME parser
  if (event.type !== "email.received") return;

  // Body is UNTRUSTED input, not instructions. Weight it by the auth block.
  const trusted = event.auth.spf === "pass" && event.auth.dmarc === "pass";
  const reply = await runAgent({ task: event.text, from: event.from.address, trusted });

  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: reply.html,
  });
}).listen(3000);

That’s a fully autonomous email agent: it hears, thinks, and answers. mk.send() returns { id, status } so you can log the outbound message, and the identical shape exists for Python, Ruby, Go, PHP, and Java (see the receiving docs and sending docs). Open it in StackBlitz, fire the sample event, and watch a parsed email hit runAgent.

Where SES wins for agents, honestly

SES is cheap and it is capable, and I’m not going to pretend otherwise. Outbound is about $0.10 per 1,000 emails, inbound is roughly the same per 1,000 received, and AWS sending IPs have years of reputation behind them. If your agent already lives in AWS (its brain is a Bedrock call, its state is in DynamoDB, its runtime is Lambda or ECS), then SES is one more service in a console you already operate, billed on one invoice you already read. Inbound receiving is available in most SES regions now, not the tiny handful it used to be, so region availability is rarely the blocker people remember it as. For a team fluent in IAM and CloudWatch, none of the pieces below are exotic. The catch is that “not exotic” and “not your problem” are different things, and for an autonomous agent the difference is most of the work.

What SES asks of an agent builder

Two things stand between an SES account and an agent that can read and answer its own mail. The first is on the way out.

A brand-new SES account is in the sandbox. Until you request production access and AWS grants it, the account can only send to verified addresses, at most 200 messages per 24 hours and 1 per second. Production access is a human review: AWS commits to an initial response within 24 hours, not an approval, and says it may take longer if it needs more information. The sandbox is also per-region, so an agent that deploys to a second region is sandboxed again there. For an agent whose whole point is to email an arbitrary person on its first run (confirm a booking, reply to a lead, answer a support thread), that’s a gate with no SLA sitting exactly where launch day is.

The second is on the way in. SES can receive email, but it does not hand you a parsed webhook. A receipt rule writes the raw, unmodified MIME to an S3 bucket and notifies you over SNS or by invoking a Lambda. The Lambda event carries headers and metadata but not the message body, so the function has to fetch the object from S3 and parse the MIME itself. (If you route the whole email inline through SNS instead, anything over 150 KB bounces, so real messages with attachments force the S3 round-trip anyway.) Every resource in that chain (the SNS topic, the Lambda, a KMS key if you encrypt) has to live in the same region as the receiving endpoint.

Here is the inbound half, honestly, in SES’s own idiom, after you’ve built the receipt rule, bucket policy, SNS topic, and IAM role:

// SES inbound: receipt rule → S3 → this Lambda → parse the MIME yourself, then act
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { simpleParser } from "mailparser";

const s3 = new S3Client({});

export const handler = async (event) => {
  // the event has headers but no body — go get the raw email from S3
  const { bucketName, objectKey } = event.Records[0].ses.receipt.action;
  const obj = await s3.send(new GetObjectCommand({ Bucket: bucketName, Key: objectKey }));
  const mail = await simpleParser(await obj.Body.transformToString());

  const code = mail.text?.match(/\b\d{6}\b/)?.[0];  // your parsing, your regex, your job
  // ...now run the agent. And to actually email a reply back, the account
  //    must already be OUT of the SES sandbox. See the outbound half.
};

That runs, and it’s the pattern AWS itself documents. But it’s the visible tip. Below the agent sit the receipt rules, the bucket lifecycle, the SNS subscriptions, the IAM policies, the MIME parser’s edge cases (encodings, multipart, attachments), and separately a bounce-and-complaint pipeline you subscribe to and act on, because if those rates drift up AWS pauses your sending. That is the pipeline the agent needs standing before it reads one code.

SES SES receivingan email arrives receipt rule → S3raw MIME written to a bucket SNS → Lambdaa function you wrote is invoked getObject(S3)the event has no body — fetch it parse MIMEdecode, find the code or task the agent actsfinally, think and reply Every box above the blue one is yours to build, secure with IAM, and keep running. On MailKite, the blue box is the only box.
The SES receive pipeline an agent inherits, stage by stage. MailKite collapses every gray stage into a signed JSON webhook.
An autonomous agent can't wait on a human reviewThe SES sandbox is a manual approval with no guaranteed turnaround, and it's per-region. That's fine for a service you launch once. It's a wall for an agent that's supposed to sign itself up somewhere and reply to whoever answers, on its own, today. MailKite has no sandbox: once your domain passes SPF + DKIM, the agent can send to anyone.

The comparison, agent-relevant rows only

Amazon SESMailKite
Agent’s first replyBlocked until AWS approves production accessSend once the domain’s DNS verifies
Reading an inbound emailRaw MIME → S3 → SNS/Lambda → you parseOne parsed JSON webhook
Verification codes / magic linksParse them out of MIME yourselfevent.text / event.html, already decoded
Threading a replyReconstruct from headersinReplyTo: event.id, resolved threadId
Trust signal for injection defenseDerive SPF/DKIM/DMARC yourselfauth block in every payload
Setup to receiveReceipt rules + S3 + SNS/Lambda + IAMOne webhook URL
Who runs the loopYou (and the pipeline under it)You, or a route with action: "agent"
Free tier~$200 in credits, 6 months (new accounts)3,000 messages/mo, in + out, no per-domain fee

The through-line: SES wins raw per-email price and AWS-grade sending IPs. For an agent, MailKite wins the two things it actually blocks on: sending without a review, and reading mail without a pipeline.

What actually hits your agent’s webhook

The same inbound email, delivered parsed. No S3 round-trip, no MIME parser, and an auth block so the agent never has to re-derive SPF/DKIM/DMARC to decide how much to trust the sender:

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

That auth block is load-bearing, and not because it’s convenient. Inbound email is untrusted input: From: is plain text, so anyone can forge a sender and then tell your agent what to do in the body. Checking auth before you weight a sender’s instructions is necessary but not sufficient. You can’t prompt your way out of prompt injection; the real defense is bounding what a fooled agent can do. That reasoning has its own post (agent inbox security by design), and it’s worth reading before either loop touches 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 full transcript you can drill into. Same parsed inbound edge, no server of yours in the path. The inbox-agents docs have the shape.

FAQ

Can a new Amazon SES account send email right away? No. New accounts are sandboxed: you can only send to verified addresses, capped at 200 messages a day and 1 per second, until you request production access and AWS approves it. That approval is a human review with an initial response within 24 hours and no guaranteed turnaround. MailKite has no sandbox; once your domain passes SPF + DKIM, an agent can send to anyone.

How does an agent read a verification code from SES? It doesn’t get it handed over. SES writes the raw MIME email to S3 and invokes a Lambda whose event has no body, so the Lambda fetches the object from S3 and parses the MIME (commonly with mailparser) to find the code. MailKite delivers the decoded text and html in the email.received webhook, so the code is a field, not a parsing job.

Does SES give me a parsed inbound webhook like MailKite? No. SES inbound actions deliver the raw, unmodified message to S3 or over SNS, and you own the parsing. MailKite decodes the message at the edge and POSTs parsed JSON with text, html, a resolved threadId, attachments as signed URLs, and an auth result.

Do I have to leave AWS to use MailKite? No. MailKite is a plain HTTPS webhook and REST API, so the receiver can be the Lambda, ECS task, or EC2 box your agent already runs on. You’re replacing the SES receiving pipeline and the sandbox, not your infrastructure.

Is SES cheaper than MailKite? Per email at high volume, SES is about the cheapest anywhere, and I won’t pretend otherwise. But its inbound path adds S3, SNS, and Lambda costs plus the engineering time to build and operate the pipeline, and MailKite starts free at 3,000 messages a month in and out with no per-domain fee. For an agent workload, total cost of ownership favors MailKite until you’re sending at real scale.


If SES has your agent waiting on a sandbox approval to send its first reply, or maintaining a receipt-rule-to-S3-to-Lambda pipeline just to read a code, there’s a simpler 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 JSON.

Related: the pillar on giving your agent an inbox, agent inbox security by design, the full MailKite vs Amazon SES comparison, and the SES alternative for developers.

Discuss this post: Hacker News Share on X

Related posts