MailKite
Start free
All posts
Gabe 16 min read

The Microsoft Graph (Outlook) alternative for AI agents

Microsoft Graph gives an agent a real Outlook mailbox, at the cost of an Entra app registration, admin-consented (tenant-wide) mail permissions, and push subscriptions that expire and need renewing. MailKite (which we build) gives the agent its own scoped address on a domain you control and pushes parsed JSON to a receive→reply loop. For builders wiring an autonomous email agent.

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

The friction isn’t the mail API itself, which is fine. It’s everything wrapped around it before an agent reads one message: a directory app, a consent grant that touches every mailbox in the tenant, and a webhook subscription with an expiry date. Here is that difference in one picture: the same inbound email, and everything an autonomous agent operates to receive it on each side.

MSMicrosoft Graph (Outlook) email in M365 mailboxlicensed acct subscriptionexpires in days notify → GET msghandshake + app token your agent …plus the Entra ID app registration, admin-consented Graph permissions (tenant-wide by default), and the renewal cron that re-subscribes before each subscription lapses MailKite email in MX edgeparse + auth JSON webhooksigned, retried, replayable your agent no app registration, no admin consent, no subscription to renew — the webhook just arrives
One inbound email reaching an agent: Microsoft Graph vs MailKite. Same input, two very different pipelines.

Here is the whole MailKite side: the agent’s receive→think→reply loop. 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;

  // Treat the body as untrusted INPUT, never as instructions (see the security 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);

No token cache, no subscription, no directory app. Inbound mail is parsed to JSON and POSTed as an email.received event; the agent replies with mk.send(), which returns { id, status }. The same handler shape exists for Python, Ruby, Go, PHP, and Java: see the receiving docs and sending docs. There’s a runnable companion in demo-microsoft-graph-ai-agent you can open in StackBlitz.

Where Graph wins for agents, honestly

Graph is the right tool, and sometimes the only tool, when the agent must live inside a real Microsoft 365 tenant. If your agent is an assistant sitting in an actual employee’s Outlook, or it needs to touch the rest of that person’s work (calendar, contacts, Teams, OneDrive, SharePoint), Graph is one consistent API across all of it, and nothing on the market matches that reach into M365. It carries the mailbox’s real history, its org’s compliance and retention and eDiscovery, and its existing corporate identity. An agent that has to be an employee inside a tenant belongs on Graph. This post isn’t “Graph is bad.” It’s “Graph makes you stand up a tenant-scoped identity and a subscription lifecycle before the agent reads a word,” and for an agent that just needs its own inbox, that’s a lot of machinery.

What Graph asks of an agent builder

To read mail with no signed-in user (the daemon shape an autonomous agent needs), you register an app in Entra ID, get it admin consent for application mail permissions, and then keep a change-notification subscription alive. Application permissions like Mail.Read are tenant-wide by default: the grant reads “read mail in all mailboxes,” and Mail.Send sends as any user. To confine the agent to one mailbox you add RBAC for Applications in Exchange Online (or the legacy Application Access Policy), and you have to remember that an unscoped Entra grant is not narrowed by that scope, so you also remove the org-wide grant. Then the subscription itself expires and has to be renewed. Here is the honest inbound path, top to bottom:

MSregister app (Entra ID)client ID + secret or certificate admin consentMail.* application perms, tenant-wide scope to one mailboxRBAC for Apps / access policy create subscriptionPOST /subscriptions validationToken handshakeecho the token back in 10s renew before expiryPATCH on a cron, or resubscribe GET message → agentapp token, then read the mail Every box above the blue one is infrastructure you register, consent to, scope, and renew. On MailKite, the blue box is the only box.
The Graph inbound path for an agent, stage by stage. MailKite collapses every gray stage into a signed JSON webhook.

And a change notification isn’t the message. It’s a pointer: Graph tells you something changed at this resource, and you go GET it. Here’s the real daemon flow, app token to subscription to handshake to fetch, as it actually looks in code:

// Graph app-only agent inbox: token → subscribe → validation handshake → GET the message.
// Needs an Entra app with admin-consented application Mail.Read, ideally RBAC-scoped to one mailbox.
import { createServer } from "node:http";

const { TENANT, CLIENT_ID, CLIENT_SECRET, MAILBOX, NOTIFY_URL } = process.env;

async function token() {
  const r = await fetch(`https://login.microsoftonline.com/${TENANT}/oauth2/v2.0/token`, {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,          // or a certificate; secrets expire too
      scope: "https://graph.microsoft.com/.default",
      grant_type: "client_credentials",
    }),
  });
  return (await r.json()).access_token;
}

// Create the subscription. expirationDateTime is capped and lapses — you must re-PATCH it.
async function subscribe() {
  const t = await token();
  await fetch("https://graph.microsoft.com/v1.0/subscriptions", {
    method: "POST",
    headers: { authorization: `Bearer ${t}`, "content-type": "application/json" },
    body: JSON.stringify({
      changeType: "created",
      notificationUrl: NOTIFY_URL,
      resource: `/users/${MAILBOX}/mailFolders('inbox')/messages`,
      expirationDateTime: new Date(Date.now() + 6 * 24 * 3600 * 1000).toISOString(),
    }),
  });
}

createServer(async (req, res) => {
  const url = new URL(req.url, "https://x");

  // 1. Validation handshake: on subscribe, Graph POSTs ?validationToken=…; echo it back,
  //    plain text, HTTP 200, within 10 seconds — or the subscription is never created.
  const validationToken = url.searchParams.get("validationToken");
  if (validationToken) {
    res.writeHead(200, { "content-type": "text/plain" }).end(validationToken);
    return;
  }

  // 2. A change notification is just a pointer. GET the actual message with an app token.
  let raw = ""; for await (const c of req) raw += c;
  const { value } = JSON.parse(raw);
  const t = await token();
  const msg = await fetch(`https://graph.microsoft.com/v1.0/${value[0].resource}`, {
    headers: { authorization: `Bearer ${t}` },
  }).then((r) => r.json());

  // msg.body.content is HTML by default; from is msg.from.emailAddress.address.
  // Nothing here tells you if SPF/DKIM/DMARC passed — that's still on you.
  console.log(msg.from.emailAddress.address, "·", msg.subject);
  res.writeHead(202).end();
}).listen(3000);

await subscribe();

None of this is exotic if you already run an M365 tenant. But it’s a directory identity, a consent grant, a scoping decision, and a renewal loop standing between the agent and “an email came in, do something with it.”

The subscription expires while you sleepMail subscriptions cap at 10,080 minutes (under 7 days) for basic notifications, and 1,440 minutes (under 1 day) if you ask Graph to include the message body. Miss the renewal and inbound simply stops arriving, silently, until you resubscribe. A dropped renewal cron is a class of outage MailKite's durable webhook doesn't have.

The comparison, for an agent

Microsoft Graph (Outlook)MailKite
Give the agent an inboxLicensed M365 mailbox + Entra appScoped address on a domain you control
Inbound deliveryChange-notification ping → you GET the messageOne parsed JSON webhook
Push subscriptionExpires (≤7 days, ≤1 day with body); renew on a cronDurable; nothing to renew
Setup to startApp registration + admin consent (tenant-wide)DNS-verify, one webhook URL
Scope to one mailboxRBAC for Applications / access policyThe address is the scope
Sender auth for the agentYou derive SPF/DKIM/DMARC yourselfauth block in every payload
Throttling10,000 requests / 10 min per app per mailbox (429)Metered, no per-mailbox cap
Cost floorPer-user license (~$4–6/mo) + your infraFree tier: 3,000 msg/mo, no per-domain fee

The through-line: Graph wins when the agent must operate inside a real tenant and reach the rest of M365. MailKite wins when the agent just needs its own inbox: no directory app, no consent, no subscription to keep alive, and inbound that arrives already parsed and authenticated.

What actually hits your agent’s webhook

The same inbound email, delivered parsed. No subscription, no second round-trip to GET the body, and the auth block means the agent never re-derives SPF/DKIM/DMARC:

{
  "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 for safety. Inbound email is untrusted input: From: is plain text, so a sender can forge who they are and then simply tell your agent what to do. Check SPF/DKIM/DMARC before you weight instructions, and treat the body as data the agent reasons over, never as commands it obeys. That check is necessary, not sufficient; bound the agent’s authority too. The full argument is in agent inbox security by design.

The email body is a prompt-injection vectorWhichever provider carries the mail, the moment your agent follows what an email says, that body is an attack surface. Use the auth verdict to gauge the sender, keep the agent's tools owner-scoped, and cap it at one reply. You cannot prompt your way out of prompt injection.

MailKite, which we build, is that whole receiving stack assembled: the MX edge, the MIME parse, the SPF/DKIM/DMARC verdict, a signed and retried webhook, and the Send API for the reply. To start you DNS-verify a domain (MX to receive, SPF + DKIM to send), point a webhook at it, and pick an address like agent@yourco.dev. There’s no app registration, no admin to chase for consent, and no subscription to renew. If you’d rather not host the loop at all, a route with action: 'agent' runs the model turns for you on a durable queue and hands back a per-run transcript; the pillar post walks both paths. SMTP-only apps can still send through the submission edge with AUTH on :587.

FAQ

Can an AI agent read an Outlook mailbox with Microsoft Graph? Yes. For an autonomous, no-user agent you register an app in Entra ID, get admin consent for application mail permissions (Mail.Read, Mail.ReadWrite, Mail.Send), and either poll or subscribe to change notifications, then GET each message. It works well, especially when the agent lives inside a real M365 tenant. It’s more setup than pointing a domain at a webhook, which is the MailKite path.

Do Microsoft Graph mail subscriptions expire? Yes, and this is the part that bites. A messages subscription caps at 10,080 minutes (just under 7 days) for basic notifications, and 1,440 minutes (under 1 day) if you include the message body in the notification. You renew it with a PATCH before it lapses, or resubscribe. If your renewal cron dies, inbound quietly stops. MailKite’s webhook is durable, so there’s nothing to renew.

Do Graph application permissions give the agent access to every mailbox? By default, yes. Application Mail.Read grants read access to all mailboxes in the tenant and requires admin consent; Mail.Send can send as any user. To confine the agent to one mailbox you scope it with RBAC for Applications in Exchange Online (the current method) or a legacy Application Access Policy. Note the two grants are additive: an unscoped Entra grant isn’t narrowed by an RBAC scope, so you also remove the org-wide grant.

Does an agent mailbox on Microsoft 365 need a paid license? Yes. The mailbox has to be a licensed account, roughly Exchange Online Plan 1 at about $4/user/month or Microsoft 365 Business Basic at about $6/user/month (annual, USD; check current pricing). MailKite’s free tier covers 3,000 messages a month, inbound plus outbound, with no per-domain fee, and the agent’s address lives on a domain you already control.

What’s the real difference between Graph and MailKite for an agent inbox? Graph is an API onto a full M365 mailbox and the rest of the tenant; it’s the right call when the agent must operate as someone inside that tenant. MailKite gives the agent its own scoped address that pushes parsed, authenticated JSON to a receive→reply loop, with no directory app, no admin consent, and no subscription lifecycle. Pick Graph for “an agent inside our tenant,” MailKite for “an agent with its own inbox.”


If Graph has you standing up an Entra app, chasing admin consent for a tenant-wide grant, and babysitting a subscription that expires every few days just so an agent can read its mail, there’s a simpler shape for an agent that only needs its own inbox. Clone the demo repo (or run it in your browser), then point a domain at MailKite and the agent’s next inbound email arrives as parsed JSON.

Related: the pillar on giving your agent an inbox and agent inbox security by design.

Discuss this post: Hacker News Share on X

Related posts