Integrations
Microsoft Graph: 17 valkuilen bij een Outlook-agent rollout
We rolden een inbox-triage agent uit in een Outlook-tenant van 22 gebruikers in Hengelo en stuitten in drie weken op zeventien Graph-eigenaardigheden. Hier is de lijst, ergste eerst.

Het is vrijdagmiddag in Hengelo. De operations-lead bij een zakelijk dienstverlener met 22 mensen kijkt naar haar inbox. De agent die we twee dagen eerder hebben uitgerold, heeft net gereageerd op een doorgestuurde RFP-thread, en die reactie kwam terecht in een nieuwe conversatie in plaats van onder het origineel. Ze opent Outlook, scrolt, en ziet dezelfde thread in drie boxen gesplitst. De agent deed zijn werk. Microsoft Graph niet.
Die middag was het startsein voor drie weken catalogiseren van elke eigenaardigheid die Graph, Exchange Online en de Outlook REST-oppervlakken naar ons gooiden. We eindigden op zeventien. De lijst hieronder is gerangschikt: de bovenste falen stil en corrumperen data; de onderste kosten je een developer-uur en gedragen zich daarna netjes. Richt je iets op Outlook namens meer dan een handvol gebruikers, lees dan omlaag tot er iets bekend voorkomt.
1. conversationId roteert bij lange doorgestuurde threads
De ergste. Microsoft documenteert conversationId als het stabiele handvat voor een thread. In de praktijk roteert het id zodra een thread ongeveer veertig berichten passeert en minstens één keer buiten de tenant is doorgestuurd. De nieuwe waarde wijst naar een sub-conversatie die Exchange uitvindt om zijn eigen interne index op orde te houden. Jouw agent, die zijn geheugen op het oude id indexeerde, denkt nu dat hij de klant nog nooit heeft gezien. Hij begroet ze beleefd. De klant is geïrriteerd.
De oplossing is om ook internetMessageId (de Message-ID-header uit RFC 5322) en References op te slaan voor elk bericht dat je indexeert. Zie je een onbekend conversationId, loop dan de references twee stappen terug; vind je een bericht dat je al kent, voeg dan samen.
// fallback when conversationId looks new
async function resolveThread(msg: Message): Promise<ThreadKey> {
const known = await db.thread.byConvId(msg.conversationId);
if (known) return known.key;
// walk Message-ID chain — survives the rotation
for (const h of msg.internetMessageHeaders ?? []) {
if (h.name === 'In-Reply-To' || h.name === 'References') {
const parent = await db.message.byInternetId(h.value);
if (parent) return parent.threadKey;
}
}
return db.thread.create(msg);
}
2. 202 Accepted die stilletjes categorieën dropt
Deze beet ons in week twee. Een PATCH naar /users/{shared}/messages/{id} die categories: ["Triaged"] zet, geeft binnen ongeveer 120 ms een 202 Accepted terug. De status voelt als een schrijfbevestiging. Dat is hij niet. Bij gedeelde mailboxen met meer dan zo’n vijfentwintig gedelegeerde gebruikers wordt de categories-array gedropt vóór de wijziging in de mailbox-store wordt gecommit. Een vervolg-GET toont het veld leeg. Geen 4xx, geen waarschuwingsheader, geen regel in de change log.
We zagen het alleen doordat onze reconciler na elke PATCH een GET uitvoert. De workaround is terugvallen op een single-user write via X-AnchorMailbox ingesteld op de gedelegeerde, en daarna opnieuw delen via je eigen metadata-tabel. Categorieën op gedeelde mailboxen zijn sowieso per gedelegeerde (eigenaardigheid #9), dus het gedeelde label was vanaf het begin fictie.
3. Throttling heeft niet-gedocumenteerde sub-limieten
De publieke throttling-pagina noemt 10.000 requests per 10 minuten per app per mailbox. Wat er niet bij staat is een burst-plafond van ongeveer 4 req/s per mailbox dat afgaat voordat je het 10-minuten-venster raakt. Voor een triage-agent die bij binnenkomst classificeert, is vier berichten in dezelfde seconde normaal verkeer. Je krijgt een 429 met Retry-After: 1. Respecteer ’m; back-off niet exponentieel, anders wordt één seconde contentie een minuut stilte.
4. Delta-tokens sterven na 30 dagen, zonder waarschuwing
Gebruik je $deltaToken voor incrementele sync (de moeite waard; zie Microsofts delta query overview), dan geeft de token na 30 dagen 410 Gone terug. Prima. Maar het failure-pad voor een agent die elke nacht draait is: hij werkt een maand, en op een ochtend hersynchroniseert hij de hele mailbox. Voor een mailbox van 50.000 berichten is dat veertig minuten throttled GETs en een verrassing op het kostendashboard. Stempel elke delta-token met een created-at en ververs proactief op dag 25.
5. Webhook-abonnementen vereisen elke ~70 uur verlenging
Mail-abonnementen maxen uit op 4230 minuten, ongeveer 70 uur dus. Verleng op 48. Hang de verlenging niet aan de webhook zelf; mis je om wat voor reden ook een delivery, dan gaat de verlenging mee, en sterft het abonnement in zijn slaap. Gebruik een aparte scheduled job.
6. internetMessageHeaders wordt standaard niet teruggegeven
Elke Graph-response die “complete message” beweert, liegt tenzij je de headers expliciet met $select opvraagt. internetMessageHeaders, parentFolderId en singleValueExtendedProperties zijn allemaal opt-in. De default payload is gevormd voor Outlook web, niet voor een agent die de RFC-velden nodig heeft.
7. immutableId vereist een Prefer-header
Message-ids veranderen wanneer een bericht van map wisselt. Wil je een stabiel id, zet dan op elke request Prefer: IdType="ImmutableId". Vergeet ’m op één endpoint en hetzelfde bericht verschijnt onder twee ids in je store.
8. /me en /users delen geen scopes
Gedelegeerde Mail.Read op /me geeft geen toegang tot /users/{id}. Je hebt Mail.Read.Shared nodig voor het gedelegeerde geval en Mail.Read als application permission voor het unattended geval. We zagen een senior dev een hele middag verbranden aan een 403 omdat het consent-scherm dat verschil had weggepoetst.
9. Categorieën op gedeelde mailboxen zijn per gedelegeerde
Elke gedelegeerde heeft een eigen masterCategories-lijst. Een categorie die gebruiker A zet, is onzichtbaar voor gebruiker B, tenzij beiden een categorie met dezelfde naam hebben. Er is geen gedeeld categorie-oppervlak in Graph. Bouw je eigen tabel en projecteer ’m per gedelegeerde terug.
10. ReplyAll dropt stilletjes Bcc
De Graph-acties /reply en /replyAll volgen de Outlook UX, die Bcc niet toont bij een reply. Wil je dat Bcc overleeft (audit, archive-to-CRM-patronen), gebruik de actie dan niet. POST een nieuwe draft met toRecipients, ccRecipients en bccRecipients expliciet gezet, en doe daarna /send.
11. changeKey-conflicten bij draft-updates
Elke Outlook-entity draagt een changeKey mee voor optimistische concurrency. PATCH een draft twee keer achter elkaar vanuit twee threads in hetzelfde agent-proces en de tweede wint, of de tweede geeft een 412 — niet-deterministisch. Serialiseer writes per resource-id.
12. Bijlagen boven ~3MB vereisen een upload session
De docs zeggen 4MB inline. In de praktijk wordt alles boven 3MB af en toe geweigerd met een 413, omdat base64-encoding de payload over de limiet duwt. Gebruik /createUploadSession voor alles boven 2,5MB en stop met gokken.
13. Mentions vereisen een expliciete @odata.type
Wil je dat een berichttekst in Outlook een @mention rendert, dan moet de mentions-collectie op de message-entity per item "@odata.type": "#microsoft.graph.mention" bevatten. Laat je ’m weg, dan wordt het veld stilletjes genegeerd. Geen 4xx.
14. Send-as vereist application permissions
Send-on-behalf werkt met de gedelegeerde Mail.Send.Shared. Send-as heeft Mail.Send als application permission nodig, plus een Exchange Online RBAC-rol. De twee zijn niet uitwisselbaar, en de failure-modus is een verzonden bericht dat aankomt met de verkeerde From-header.
15. inferenceClassification reset bij verplaatsen
Outlooks Focused/Other-splitsing is via inferenceClassification blootgelegd. Verplaats een bericht tussen mappen via Graph en de waarde reset naar focused. Verplaatst je agent berichten, herstel dan de classificatie in dezelfde call.
16. Prefer: outlook.timezone werkt per request
Zet de timezone-header één keer bij startup en je krijgt nog steeds UTC op elke vervolg-request. De header is niet session-sticky. Bak ’m in je HTTP-client middleware.
17. GET op een gedeelde mailbox markeert als gelezen
Een bericht via Graph lezen op een gedeelde mailbox zet isRead: true, ook al heb je alleen de metadata opgevraagd. De oplossing is een PATCH terug, of $select gebruiken om de body over te slaan en de flip te vermijden. Het team in Hengelo merkte het als eerste; de agent verbrandde elke ochtend de ongelezen-badges voordat er iemand binnen was.
Het patroon achter het patroon
Twaalf van de zeventien eigenaardigheden delen dezelfde vorm: de API geeft 2xx terug, het neveneffect is fout, en er is geen waarschuwing. Dat is het deel dat sneller dan wat ook het vertrouwen in agent-systemen ondergraaft. De onbetrouwbare laag in een agent-stack is zelden het model; het is het oppervlak waar het model naartoe schrijft. Microsoft Graph is een van de eerlijker voorbeelden. Hij vertelt je dat hij je write heeft geaccepteerd, en dat klopt, maar accepted is niet committed, en committed is niet zichtbaar.
De defensieve houding die voor ons werkte, na drie weken hiervan:
- Lees je eigen write voordat je er iets mee doet. Altijd.
- Sleutel op
internetMessageId, nooit opconversationIdalleen. - Behandel elke 2xx als adviserend. Verifieer met een GET.
- Houd een aparte scheduled job voor token-, subscription- en delta-token-refreshes. Nest ze niet binnen de webhook-handler.
- Log de
request-id-response-header bij elke Graph-call. Open je een Microsoft support-ticket, dan is dat het enige wat ze vragen.
De failure-modus van Microsoft Graph is de stille 2xx. Leest jouw agent zijn eigen writes niet terug, dan liegt hij volgens schema tegen je.
Wat we daarna geleverd hebben
Toen we de inbox-triage AI-agent bouwden voor de Hengelose zakelijk dienstverlener, was het probleem dat geen van deze eigenaardigheden in één document staat; ze staan verspreid over zeventien, half op learn.microsoft.com en half in oude blogposts. We losten het op door een reconciler te schrijven die tussen elke Graph-call en onze agent-state zit, plus een nachtelijke diff-job die elke mailbox markeert waar onze lokale view afdrijft van die van Outlook.
Sta je op het punt om voor het eerst een agent op Outlook te richten: schrijf eerst de reconciler, dan de agent. Investeer vanmiddag een paar uur in het loggen van de request-id-header bij elke Graph-response in je prototype; je hebt ’m binnen een week nodig.
Kern
De failure-modus van Microsoft Graph is de stille 2xx. Leest jouw agent zijn eigen writes niet terug, dan liegt hij volgens schema tegen je.
FAQ
Treedt de conversationId-rotatie ook op bij threads die alleen intern bleven?
We reproduceerden ’m alleen op threads die minstens één keer buiten de tenant waren doorgestuurd en die ongeveer veertig berichten hadden gepasseerd. Pure interne lange threads bleven in onze tests stabiel.
Kunnen application permissions categorieën van een gedeelde mailbox over alle gedelegeerden heen lezen?
Ja, maar de categories-array blijft per gedelegeerde. Lezen via app permissions geeft de lijst van de mailbox-eigenaar terug, niet de unie over gedelegeerden heen. Een gedeelde view moet je zelf projecteren.
Staat het burst-plafond van 4 req/s ergens gedocumenteerd?
Niet publiek. We leidden het af uit de Retry-After-headers op 429s en bevestigden het telefonisch met Microsoft support. De 10.000 per 10 minuten is het enige cijfer dat op papier staat.
Hoe vernieuw je een delta-token voordat hij na 30 dagen verloopt?
Stempel elke token met een created-at-timestamp en trigger een verse delta-run op dag 25. Er is geen extend-endpoint; je primet de cursor opnieuw en gooit de oude token weg.
Is send-as de extra RBAC-setup waard ten opzichte van send-on-behalf?
Alleen als de From-header voor ontvangers uitmaakt. Send-on-behalf toont in de meeste clients ‘X namens Y’, wat prima is voor interne flows maar extern verdacht overkomt.