MailKite
Start free
All posts
Gabe 17 min read

The Brevo alternative for AI agents

Brevo (formerly Sendinblue) is a marketing platform with a transactional API, and it can receive: Inbound Parsing POSTs email as an items[] array. But there's no normalized SPF/DKIM/DMARC verdict and no agent loop, so you rebuild both. MailKite (which we build) hands an autonomous agent a real inbox as one parsed email.received event with an auth block and a receive→reply loop.

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

An autonomous agent doesn’t need a campaign designer or a CRM. It needs one thing most send-first platforms never shipped: its own real address that it can read from, decide on, and reply to with no human in the loop. Brevo can receive mail, so this isn’t a “can’t.” It’s a question of shape, and of what you rebuild after the JSON lands. Here’s the contrast in one picture.

Brevo email in Brevo inboundreply.you MX items[] webhookno auth verdict your appparse + dig auth Brevo APIsend reply MailKite email in MX edgeparse + auth JSON webhookauth block your agentyour model mk.send()reply out Blue = operated by MailKite. On Brevo the auth verdict isn't a field; you rebuild it from raw headers first.
The same inbound email reaching an agent on Brevo vs MailKite. Both parse; only one hands you a trust verdict.

Here’s the whole MailKite side: 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) => {
  // 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;

  // Body is untrusted INPUT, never instructions. Weight it by 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 the receive→think→reply loop, whole. The same handler shape exists for Python, Ruby, Go, PHP, and Java; see the receiving docs and sending docs. The rest of this post is the honest version of the diagram: where Brevo genuinely fits an agent, what its inbound path asks you to build, and what the parsed payload actually contains.

Where Brevo wins for agents, honestly

Brevo is a marketing platform first: email campaigns, a built-in CRM, marketing automation, SMS, and WhatsApp, with a transactional email API bolted alongside. That breadth is the point of the pitch, and for one kind of agent it’s the right tool.

If your agent’s job lives near marketing, having all of it behind one API key is real leverage. An agent that qualifies a lead, updates a contact attribute, and then triggers a lifecycle sequence can do all three against the same Brevo account it reads inbound replies from. You don’t wire a CRM and an ESP and an inbox parser together; they’re already one system.

Brevo’s inbound parse also does something genuinely useful for LLM input. Each parsed item carries an ExtractedMarkdownMessage field: the reply body cleaned up, with the quoted history and the signature split off into a separate ExtractedMarkdownSignature. If you’ve ever fed a raw email reply to a model and watched it dutifully summarize the person’s phone number and legal disclaimer, you know why that’s worth something. That extraction is nicer than what a lot of inbound APIs hand back.

So this isn’t “Brevo can’t receive.” It can. The question is what the shape costs an agent builder.

What Brevo asks of an agent builder

Brevo’s Inbound Parsing works by DNS: you add MX records pointing a subdomain (their example is reply.yourdomain.com) at inbound1.sendinblue.com and inbound2.sendinblue.com, then register a webhook by POSTing to /v3/webhooks with type: "inbound" and the inboundEmailProcessed event. From then on Brevo POSTs an object with an items array; each item has From, To, Subject, RawTextBody, RawHtmlBody, ExtractedMarkdownMessage, Attachments, Headers, and a SpamScore. (There’s a polling path too, but mind the gotcha: GET /v3/inbound/events returns metadata and delivery logs, not the parsed body. Only the webhook POST carries RawTextBody and the markdown extraction, so an agent that wants the content has to receive the push.)

Two things follow from that shape once an agent is the consumer.

First, it’s a batch. You iterate items, not “handle this one email.” Fine, but your loop owns it.

Second, and this is the load-bearing one: there is no normalized authentication verdict. SpamScore is a float from rspamd, not a pass/fail on SPF, DKIM, or DMARC. The actual SPF and DKIM results exist only as raw header lines (Received-SPF, Authentication-Results, ARC-Seal) inside the Headers field. If your agent is going to act on an email, it has to reconstruct the trust verdict itself, by string-matching headers, before it decides how much weight to give a sender’s instructions:

// Brevo inbound: POST { items: [...] } — parsed, but the auth verdict isn't a field.
import express from "express";
const app = express();
app.use(express.json());

app.post("/hooks/brevo", async (req, res) => {
  res.sendStatus(200); // guard this endpoint yourself: a secret path or IP allowlist
  for (const item of req.body.items ?? []) {
    const body = item.ExtractedMarkdownMessage ?? item.RawTextBody ?? "";

    // SpamScore is a number, not a pass/fail. There is no spf/dkim/dmarc field.
    // Reconstruct the verdict from the raw headers before trusting the sender:
    const rawHeaders = JSON.stringify(item.Headers ?? "");
    const spfPass = /spf=pass/i.test(rawHeaders);
    const dmarcPass = /dmarc=pass/i.test(rawHeaders);

    const answer = await runAgent({
      task: body,
      from: item.From?.Address,
      trusted: spfPass && dmarcPass,
    });

    // Reply is a separate product surface: the transactional send API.
    // Set In-Reply-To / References yourself if you want it to thread.
    await brevoSendTransactional({ to: item.From?.Address, ...answer });
  }
});

Grepping Authentication-Results for spf=pass is exactly the kind of thing that looks fine in a demo and quietly rots: the header format varies by upstream relay, dmarc= isn’t always present, and a regex over headers an attacker partly controls is a shaky foundation for a trust decision. It’s doable. It’s just yours to get right, and it’s the check that matters most for an autonomous agent.

The reply is also a separate concern. Inbound Parsing gets mail in; sending the answer is the transactional email API, a different surface (a different product to activate, in fact: new accounts open a support ticket before transactional sending turns on), and threading (In-Reply-To, References) is on you to set. None of this is exotic. It’s a marketing suite that happens to expose an inbound parser, not a receive→reply loop for a bot.

items[] webhookPOST { items: [ … ] } read the bodyExtractedMarkdownMessage SpamScorea float, not a pass/fail open Headersfind Received-SPF, Auth-Results parse it yourselfderive spf / dkim / dmarc decide trust → run modelthe step you actually wanted Every gray box is work before the agent can trust a sender. On MailKite it's one field, event.auth, so the blue box is the only box.
Reconstructing a trust verdict on Brevo, stage by stage. MailKite normalizes SPF/DKIM/DMARC into event.auth at the edge.

The comparison, agent-relevant rows only

BrevoMailKite
Product center of gravityMarketing suite (campaigns, CRM, automation)Inbound email → webhook
Inbound shapeitems[] array (batch) via webhook or pollOne email.received event
Auth verdict (SPF/DKIM/DMARC)Not a field — grep raw Headers yourselfNormalized auth block
Spam signalSpamScore float (rspamd)spam: ham/spam plus auth
Body for the modelExtractedMarkdownMessage (clean, nice)Decoded text + html
Agent loop built inNone — wire model + transactional sendOptional route action: agent, or BYO
Reply + threadingSeparate transactional API; set headers yourselfmk.send({ inReplyTo }) resolves it
Webhook authGuard the endpoint yourselfSigned; verifyWebhook() in one call
StartAdd MX + create webhook via APIDNS-verify, one webhook
Free tier300 emails/day3,000 messages/mo (in + out)

The through-line: Brevo wins when the agent’s work is marketing-shaped and you want CRM, campaigns, and inbound under one roof. MailKite wins when the agent’s work is “own an address, read what arrives, decide, reply safely,” because the trust verdict and the reply path are already assembled.

What actually hits your agent’s webhook

Here’s the MailKite email.received event: decoded body, a resolved threadId, attachments as short-lived signed URLs, and the auth block that the Brevo path made you reconstruct.

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

From: is plain text and trivially forged, so an email body is untrusted input, a prompt-injection vector, not instructions. The auth block is how the agent decides how much to trust a sender before it acts, without string-parsing a header itself. It’s necessary, not sufficient; the real defense is bounding what a fooled agent can do, but starting from a normalized verdict beats starting from a regex.

Two ways to run it on MailKite

MailKite, which we build, gives an agent a scoped address on a domain you control (agent@yourco.dev), and runs the loop one of two ways. Bring your own: the inbound webhook hits your endpoint, your model runs, you reply with mk.send(), which is the code at the top. Or let MailKite run it: create a route whose action is agent with an agentPrompt, and the model loop runs on a durable Cloudflare Queue with an agent_runs ledger and a per-route transcript you can drill into, capped and reaped so a slow model call can’t wedge ingest. Either way there’s no IMAP on a bot, no shared personal Gmail, no OAuth token-refresh churn, and no MIME parsing. To start, DNS-verify the domain (SPF + DKIM to send, MX to receive); there’s no sandbox or approval wait, the free tier is 3,000 messages a month across inbound and outbound, and SMTP-only apps can send through the submission edge on :587/:465. The companion repo demo-brevo-ai-agent runs both loops end to end; open it in StackBlitz and fire a sample event in your browser.

FAQ

Can Brevo receive inbound email? Yes. Brevo’s Inbound Parsing points a subdomain’s MX at inbound1/inbound2.sendinblue.com and POSTs parsed mail as an items[] array (or you poll the inbound events endpoint). Each item has the body, Headers, Attachments, and a SpamScore. MailKite delivers the same message as a single email.received event with a normalized auth block.

Does Brevo’s inbound payload include an SPF/DKIM/DMARC verdict? Not as a field. SpamScore is a spam float, and the SPF/DKIM results live only as raw lines (Received-SPF, Authentication-Results) inside Headers, so you parse the verdict yourself. MailKite normalizes it into auth: { spf, dkim, dmarc, spam } at the edge.

Is Brevo good for AI agents? For a marketing-shaped agent, yes: campaigns, CRM, automation, and transactional send behind one API key is real leverage, and ExtractedMarkdownMessage gives the model a clean body. For an agent whose core job is receive→decide→reply safely, you’ll rebuild the trust verdict, the reply threading, and the loop. MailKite ships those.

What does Brevo cost versus MailKite? Brevo’s free plan is 300 emails/day with paid transactional plans from around $9/mo; marketing automation caps contacts on lower tiers. MailKite’s free tier is 3,000 messages/month across inbound and outbound with no per-domain fee. Confirm current numbers on each pricing page before you commit.

Can I keep sending marketing email on Brevo and run the agent on MailKite? Yes. They’re not exclusive. Run campaigns and lifecycle automation on Brevo, and point the agent’s address (a subdomain or a separate domain) at MailKite so it receives parsed JSON with an auth block and replies over the Send API.


If your agent is fishing an SPF result out of raw headers before it dares trust an email, that’s the seam. Clone the demo repo (or run it in your browser), then point a domain at MailKite and your agent’s next inbound email arrives parsed, authenticated, and ready to answer.

Related: the pillar on giving your agent its own inbox, and agent inbox security by design.

Discuss this post: Hacker News Share on X

Related posts