cd /news/ai-agents/the-microsoft-graph-outlook-alternat… · home topics ai-agents article
[ARTICLE · art-47526] src=mailkite.dev ↗ pub= topic=ai-agents verified=true sentiment=· neutral

The Microsoft Graph (Outlook) alternative for AI agents

MailKite launches an alternative to Microsoft Graph for AI agents, offering scoped email addresses and parsed JSON webhooks without requiring Entra app registration, tenant-wide admin consent, or expiring push subscriptions. The service targets builders who need autonomous email agents with a simpler setup, while acknowledging Graph remains superior for agents that must operate inside a real Microsoft 365 tenant.

read10 min views1 publishedJul 4, 2026
The Microsoft Graph (Outlook) alternative for AI agents
Image: Mailkite (auto-discovered)

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.

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.

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:

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 comparison, for an agent #

Microsoft Graph (Outlook) MailKite
Give the agent an inbox Licensed M365 mailbox + Entra app Scoped address on a domain you control
Inbound delivery Change-notification ping → you GET the message One parsed JSON webhook
Push subscription Expires (≤7 days, ≤1 day with body); renew on a cron Durable; nothing to renew
Setup to start App registration + admin consent (tenant-wide) DNS-verify, one webhook URL
Scope to one mailbox RBAC for Applications / access policy The address is the scope
Sender auth for the agent You derive SPF/DKIM/DMARC yourself auth block in every payload
Throttling 10,000 requests / 10 min per app per mailbox (429) Metered, no per-mailbox cap
Cost floor Per-user license (~$4–6/mo) + your infra Free 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.

followswhat 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.

── more in #ai-agents 4 stories · sorted by recency
── more on @microsoft graph 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/the-microsoft-graph-…] indexed:0 read:10min 2026-07-04 ·