ZeptoMail is Zoho's cheap, transactional-only sending service, so an AI agent built on it can email out but has no inbox to read a login code or a reply. To give it one you bolt on a Zoho Mail mailbox over IMAP: two providers, MIME parsing, a poll loop. MailKite (which we build) is one provider that receives inbound as parsed JSON and sends the reply, for developers wiring an agent to its own address.
The gap shows up the first time the agent has to read something: a login code, a magic link, a human replying “approved.” ZeptoMail has no API for that, because it never receives mail at all. It is Zoho’s transactional sending service, and receiving is a different Zoho product (Zoho Mail) with a different login. Here’s the same agent on both paths, and everything you operate to give it an inbox on each side.
Here’s the MailKite side, whole: 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
), and it’s the entire integration, not a fragment:
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 (see the injection section).
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 full receive-think-reply loop. There is no ZeptoMail equivalent, because ZeptoMail has no email.received
. The same handler shape exists for Python, Ruby, Go, PHP, and Java; see the receiving docs and sending docs. A runnable demo repo wraps it with a sample-event firer you can run without an account, or open it in StackBlitz.
Where ZeptoMail wins for agents, honestly #
If the agent only ever speaks and never listens, ZeptoMail is a genuinely good outbound leg, and this post isn’t “ZeptoMail is bad.”
None of that changes the one fact that matters for an agent: it is send-only. There is no inbound webhook, no inbound parse, no IMAP on the ZeptoMail side. Its “webhooks” report outbound events on mail you already sent (soft bounce, hard bounce, open, click, feedback-loop complaints), not messages arriving. So the moment your agent must read a reply or a verification code, ZeptoMail is done and a second product begins.
What ZeptoMail asks of an agent builder #
To give a ZeptoMail agent an inbox, you run a Zoho Mail mailbox next to it and read that over IMAP. Two providers, two sets of credentials, and the entire decode step is back on your plate. Here’s the honest shape, in ZeptoMail’s own idiom on the way out and IMAP on the way in:
// OUT — ZeptoMail send. Note the Zoho-enczapikey token and the nested to[].email_address.
await fetch("https://api.zeptomail.com/v1.1/email", {
method: "POST",
headers: {
Authorization: `Zoho-enczapikey ${process.env.ZEPTO_SEND_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: { address: "agent@yourco.dev" },
to: [{ email_address: { address: replyTo } }],
subject: `Re: ${subject}`,
htmlbody: answer.html, // no inReplyTo/threading helper — set headers yourself
}),
});
// IN — ZeptoMail can't receive. Bolt on a Zoho Mail mailbox and poll it over IMAP:
import { ImapFlow } from "imapflow";
import { simpleParser } from "mailparser";
const imap = new ImapFlow({
host: "imap.zoho.com", port: 993, secure: true,
auth: { user: "agent@yourco.dev", pass: process.env.ZOHO_APP_PASSWORD }, // app password, 2FA
});
await imap.connect();
await imap.mailboxOpen("INBOX");
for await (const msg of imap.fetch({ seen: false }, { source: true })) {
const mail = await simpleParser(msg.source); // MIME → fields is yours
// dedupe, mark \Seen, resolve threading, verify SPF/DKIM/DMARC: also yours
await runAgent({ task: mail.text, from: mail.from?.value?.[0]?.address });
}
That poll loop is the part the diagram was warning about. It’s a persistent connection to babysit, MIME you decode, a \Seen
flag you have to set atomically so a crash mid-run doesn’t replay the same email into your agent twice, and no auth
result handed to you. Every stage below is yours to build and keep alive:
The comparison, no adjective inflation #
| ZeptoMail | MailKite | |
|---|---|---|
| Agent inbox (receive) | None; send-only service | Real scoped address, parsed JSON webhook |
| Read a verification code / reply | Bolt on Zoho Mail, poll IMAP | It’s in the email.received event |
| Inbound shape | n/a (raw MIME, if you add Zoho Mail) | One parsed JSON webhook |
| Providers to run an agent | Two (ZeptoMail out + Zoho Mail in) | One |
| Trust signal on inbound | Derive SPF/DKIM/DMARC yourself | auth block in the payload |
| Threaded reply from the sent address | Set headers yourself | inReplyTo , reply from event.to[0] |
| Send | API + SMTP, transactional-only | Send API + SMTP submission edge |
| Start | Verify domain (SPF+DKIM) to send | DNS-verify (MX to receive, SPF+DKIM to send) |
| Free to start | Free trial credit (10k, short validity) | 3,000 msgs/mo in + out, ongoing |
The through-line: ZeptoMail is a competent, cheap outbound leg with deliberate transactional discipline. It just isn’t an inbox, and an agent that can’t hear is only half an agent.
What actually hits your agent’s webhook #
Here’s the whole point of collapsing that pipeline. The inbound message arrives already decoded, so runAgent
gets fields, not MIME, and never re-derives SPF/DKIM/DMARC:
{
"id": "msg_2Hk9…",
"type": "email.received",
"from": { "address": "ada@example.com" },
"to": [{ "address": "agent@yourco.dev" }],
"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 for safety. Inbound email is untrusted input, From:
is trivially forged, and a naive agent that follows what an email says is a prompt-injection target. Reading auth
before you weight a sender’s instructions is necessary but not sufficient; the real defense is architectural, bounding what a fooled agent can even do. That’s its own post: agent inbox security by design.
MailKite, which we build, is the one provider doing both legs here. Point a domain at it, verify DNS (MX to receive, SPF + DKIM to send), and inbound mail is delivered as the JSON above while mk.send()
handles the reply, threaded, from the address it was sent to. There’s no sandbox or approval wait, the free tier is 3,000 messages a month in and out with no per-domain fee, and if you’d rather not host the loop, a route with action: "agent"
runs the model turns for you on a durable queue with a full transcript. One account, one inbox, one send path.
FAQ #
Does ZeptoMail support inbound (receiving) email? No. ZeptoMail is Zoho’s transactional sending service. It has no inbound webhook, no inbound parse, and no IMAP. Its webhooks report events on mail you sent (bounces, opens, clicks, complaints), not messages arriving. To read mail you use a separate product, Zoho Mail, over IMAP, or an inbound-parse provider.
What’s the difference between ZeptoMail and Zoho Mail? ZeptoMail sends transactional email (API + SMTP relay) and never receives. Zoho Mail is the actual mailbox product with IMAP/POP/SMTP for reading and sending as a person. An agent that must both send and read on ZeptoMail ends up running both, with two logins and two credentials.
Can I give an AI agent an inbox with ZeptoMail?
Not with ZeptoMail alone. You’d add a Zoho Mail mailbox and poll it over IMAP (parse MIME, dedupe, mark seen, verify auth yourself), then send replies back through ZeptoMail. MailKite does both halves under one account: inbound arrives as a parsed email.received
webhook and the agent replies with mk.send()
.
Is ZeptoMail cheaper than MailKite? For pure outbound at volume, ZeptoMail’s prepaid credits (one credit sends 10,000 emails, no monthly floor) are very cheap. But an agent inbox on ZeptoMail means also paying for and running Zoho Mail plus your own IMAP pipeline. MailKite starts free (3,000 messages/mo, in and out) with no per-domain fee, so for a receive-and-reply agent the total cost and setup usually favor one provider.
How does the MailKite agent read a message ZeptoMail can’t?
It doesn’t parse MIME or poll anything. MailKite decodes the message at its MX edge and POSTs text
, html
, a resolved threadId
, attachments as signed URLs, and an auth
result to your webhook. Your handler verifies the signature with MailKite.verifyWebhook()
and reads plain fields. See the receiving docs and webhook security.
If ZeptoMail is sending your agent’s mail well but you’ve quietly bolted a Zoho mailbox and an IMAP poller onto it just so the agent can read a reply, that’s the seam this closes. 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, replied to from the address it was sent to.
Related: the pillar on giving your AI agent its own inbox, and agent inbox security by design.