Integrations
Inbox-agent valkuilen: 17 fouten in Graph, Gmail, Exchange
Zeventien Microsoft Graph, Gmail en Exchange Online valkuilen uit de uitrol bij een Tilburgs accountantskantoor, gerangschikt op welke je stilletjes data kosten.

Het is dinsdag in maart, 22:47, en de operations lead bij een accountantskantoor van 26 man in Tilburg stuurt haar negende jaarrekening van de avond door naar een klant. De triage-agent die we twee weken eerder live zetten zou deze in één gesprek moeten houden, het bestand taggen en een follow-up in de wachtrij zetten. In plaats daarvan ziet de agent zeven losse threads. De PDF van 5,3 MB kwam überhaupt niet aan in de inbox van de klant, maar in onze log staat een keurige 202 Accepted van Microsoft Graph. Er werd niks opnieuw geprobeerd, want er ging niks fout.
Die avond begonnen we een lijst met valkuilen. Hij staat nu op zeventien, gerangschikt op hoe hard elke valkuil je raakt voordat je het doorhebt. Dit is de spiekbrief die we op dag één hadden willen hebben.
Hoe we ze gerangschikt hebben
Luide fouten zijn makkelijk. Een 429 throttle, een 403 permissions error, een 400 met een fatsoenlijke message — die komen allemaal boven in elke half-fatsoenlijke observability-stack. Wat je sloopt is de stille fout: de API die success teruggeeft en óf data laat vallen, óf een veld herschrijft waar je join op leunt, óf het werk stilletjes inkort. Alles in tier 1 liet of de conversationId stil vallen op een doorgestuurde mail, of gaf een 2xx terug terwijl de bijgevoegde PDF verdween. Tier 2 is luid maar duur als je 'm verkeerd diagnosticeert. Tier 3 is structureel; je week wordt er niet door verpest, maar je architectuur wel.
Tier 1: stille dataverliezen
1. Graph /sendMail met bijlagen groter dan 4 MB
De ergste valkuil in Microsoft Graph. Als je een bericht POST met een base64-encoded bijlage in de attachments-array en het bestand is groter dan 4 MB, krijg je terug... het hangt ervan af. Soms 413, soms een 400, en het scenario dat onze jaarrekening opat: 202 Accepted met de bijlage stilletjes uit de uitgaande MIME-body gestript. Microsoft documenteert dat alles boven de 3 MB via een upload session moet, en het stille-strip-gedrag begint rond de 4 MB. De fix is niet onderhandelbaar: gebruik altijd createUploadSession voor alles boven de 3 MB. Vertrouw die 2xx niet. Het standaardrecept, in Python:
import requests
GRAPH = "https://graph.microsoft.com/v1.0"
CHUNK = 4 * 1024 * 1024 # 4 MiB; Graph allows up to ~60 MiB per chunk
def attach_large(token: str, user_id: str, msg_id: str,
name: str, blob: bytes) -> None:
create = f"{GRAPH}/users/{user_id}/messages/{msg_id}" \
f"/attachments/createUploadSession"
body = {"AttachmentItem": {
"attachmentType": "file", "name": name, "size": len(blob),
}}
session = requests.post(
create, json=body,
headers={"Authorization": f"Bearer {token}"},
).json()
upload_url = session["uploadUrl"]
size = len(blob)
for start in range(0, size, CHUNK):
end = min(start + CHUNK, size) - 1
r = requests.put(upload_url, data=blob[start:end + 1], headers={
"Content-Range": f"bytes {start}-{end}/{size}",
})
r.raise_for_status()
2. conversationId wordt herschreven bij forwards uit Outlook web
Stuur een bericht door vanuit Outlook on the web en het nieuwe bericht krijgt een verse conversationId. Stuur hetzelfde bericht door vanuit Outlook desktop en de conversationId blijft staan. We jaagden hier drie dagen op voordat we doorhadden dat de client-mix ertoe deed. Als je agent threadt op alleen conversationId, fragmenteren OWA-forwards de thread. Val terug op internetMessageId en de In-Reply-To / References headers; die overleven de heen-en-weer.
3. Gmail watch-subscriptions verlopen stilletjes na 7 dagen
Gmail push-notificaties lopen via Pub/Sub. De watch-call geeft een expiration timestamp terug, en na die timestamp stoppen de notificaties. Geen laatste notificatie, geen 410, geen callback. Als je verlengings-cron vastloopt, kom je erachter als een klant vraagt waarom hun mail van dinsdag niet getriageerd is. Verleng dagelijks, en alert op een gemiste verlenging — een heartbeat op de renewal-job is goedkoper dan een postmortem over verdwenen mail.
4. Gmail historyId-gaten bij inactieve mailboxen
Is een mailbox langer dan zeven dagen inactief, dan snoeit Gmail de history weg. Je opgeslagen historyId is dan ouder dan het oudste beschikbare record, en history.list geeft een 404. Er is geen manier om de gemiste delta op te halen — je moet volledig syncen. Detecteer de 404, draai een full sync, en pak de draad weer op. Sla de nieuwe historyId pas op nadat de resync gecommit is; sla je 'm gretig op en crasht de sync halverwege, dan ben je de rest kwijt.
5. Graph delta tokens ouder dan 30 dagen
Zelfde vorm, andere vendor. Microsoft Graph delta tokens voor mail verlopen na grofweg 30 dagen. De error is hier minstens een 410 in plaats van een stille skip, maar het herstelpad is hetzelfde: full re-sync. Bouw 'm op dag één, niet op dag dertig. Datzelfde pad dekt ook het geval waarin je de $select-set op de delta query verandert — Graph maakt het token ongeldig en geeft een 410.
6. Gmail batchModify stopt bij 1000 IDs
Stop 1001 message-IDs in batchModify en je krijgt een 400 met een message die het maximum niet noemt. Een klant verplaatste een label van 4.000 berichten en zag driekwart stilletjes verdwijnen. Chunk op 1000.
7. Shared-mailbox permissions op Graph
Application permissions op een shared mailbox hebben Mail.ReadWrite.Shared en Mail.Send.Shared nodig, niet de non-Shared varianten. De non-Shared scopes werken prima tegen de eigen mailbox van de app en falen met een 403 op de shared. Erger: heb je daarnaast ook Mail.ReadWrite geconfigureerd, dan ziet het consent-scherm er compleet uit, en kom je het gat pas tegen als je iets in de shared inbox probeert te doen. Verifieer door met de application identity een read te doen tegen de shared mailbox vóór je het send-pad bouwt; die read faalt snel en goedkoop.
Tier 2: luid maar duur om verkeerd te diagnosticeren
8. Graph throttling is per-app per-tenant ÉN per-mailbox
De gepubliceerde limiet is 10.000 requests per 10 minuten per app per tenant. De minder besproken limiet die ons opbrak is 4 gelijktijdige requests per mailbox. Met 26 medewerkers en een triage-agent die uitwaaiert, zaten we ruim onder de tenant-cap en werden we per gebruiker gethrotteld. Concurrency, niet volume, was de bottleneck. Wij draaien een token bucket op de mailbox-principal gecapped op drie gelijktijdige calls en 600 requests per minuut, met exponential backoff die de Retry-After-header respecteert. Globaal rate-limiten op de tenant redt je niet.
9. Graph webhook subscriptions: plafond van 4230 minuten
Microsoft Graph mail-subscriptions stoppen bij 4230 minuten — net onder de drie dagen. Verleng op 80% van de TTL, niet op het laatste moment. We zagen verlengingen die achter throttles in de wachtrij stonden het venster missen tijdens een drukke week, en de gap die daaruit volgde was onzichtbaar tot aan de reconciliatie.
10. Gmail-bijlagen zijn base64url, geen base64
Het data-veld op een bijlage is base64url-encoded zonder padding. Stop het in een standaard base64-decoder en je krijgt een corrupt bestand. In Python:
import base64
def decode_gmail_attachment(data: str) -> bytes:
# base64url, padding stripped
padding = 4 - (len(data) % 4)
if padding != 4:
data += "=" * padding
return base64.urlsafe_b64decode(data)
11. Domain-wide delegation heeft beide kanten nodig
De domain-wide delegation van Gmail vereist dat de OAuth-client in de Google Admin-console aanstaat met de scope, ÉN dat diezelfde scope wordt aangevraagd bij de token-exchange. Zet 'm op één plek maar niet op de andere en de call geeft een 403 met een vage message. Configureer eerst de admin-console, test daarna tegen een gebruikersmailbox die je in eigen beheer hebt, en pas dán promoveer je de wijziging naar productie-tenants.
12. Outlook "Send on behalf" vs "Send as"
"Send on behalf" heeft Mail.Send.Shared nodig; "Send as" heeft de mailbox-level Send As-permission nodig die in Exchange Online wordt ingesteld, niet in Azure AD. We hadden een partner die twee dagen lang dacht dat we een bug hadden uitgerold, omdat zijn bevestigingsmails "via" het agent-adres uitkwamen.
Tier 3: structureel — bouw je architectuur hieromheen
13. EWS gaat met pensioen in oktober 2026
Erf je een systeem dat Exchange Web Services gebruikt, dan loopt de migratieklok al. Microsoft stopt EWS in Exchange Online per 1 oktober 2026. Halverwege 2026 is dat ongeveer een kwartaal weg. Plan de Graph-migratie nu — de API-surface is niet 1:1 en je zult throttling, attachments en subscription handling opnieuw moeten implementeren.
14. conversationId en Gmail threadId zijn niet uitwisselbaar
Draait je agent over beide providers, dan heb je een normalisatielaag nodig die de Message-ID-, In-Reply-To- en References-headers hasht. Cross-provider threading op alleen vendor-IDs fragmenteert binnen een week. Het recept dat wij gebruiken: sha256(message_id + "|" + (in_reply_to or "") + "|" + first_reference) als canonieke thread-key, met conversationId en threadId als secundaire lookup-indexes.
15. Gmail-labels zijn user-scoped
Een label op de mailbox van gebruiker A is niet hetzelfde object als het gelijknamige label op de mailbox van gebruiker B. Ze hebben verschillende IDs. Wil je een taxonomie over de hele tenant, sla dan de labelnaam op en zoek de ID per gebruiker op tijdens versturen.
16. Graph internetMessageHeaders stopt bij 5 op send
Meer dan 5 custom headers heen-en-weer via Graph op uitgaande mail? Kan niet. Vijf is de gedocumenteerde cap, en de zesde wordt stilletjes gedropt. Pak je state in één JSON-encoded header als je er meer nodig hebt.
17. De Outlook-webclient strippt inline-signature-afbeeldingen bij agent-forwards
De minst urgente, de meest gênante. Stuurt je agent een bericht door met een inline-image handtekening, dan dropt de OWA-renderer de afbeelding vaak en laat een kapotte alt-tag staan. Strip ze zelf vóór de forward en plak ze opnieuw aan als een platte footer.
De dag-één-stack waar we op uitkwamen
Na die zeventien klapte de architectuur in elkaar tot vijf regels:
- Behandel 2xx als "in de wachtrij", niet als "klaar". Elke send schrijft een pending record met als sleutel de SHA-256 van de gerenderde MIME-body, en wist die pas als een delivery-webhook afgaat of een sent-items presence check binnen vijf minuten dezelfde hash vindt.
- Thread op een hash van
Message-ID+In-Reply-To+ de eerste 30 tekens van het onderwerp, metconversationId/threadIdals secundaire key. Het onderwerp zit in de hash zodat quoted-reply chains zonderReferencesook clusteren. - Upload sessions voor alles boven de 3 MB. Altijd. Ook als het bestand "waarschijnlijk 2,8 MB" is. De size check draait op de gerenderde bytes, niet op het input-bestand, want base64-expansie tipt de drempel over in zat grensgevallen.
- Log elke send als een gestructureerd event met body-hash, recipient count en attachment count. Het reconciliatie-job speelt die events terug tegen de sent-items folder. Een ontbrekende match na vijf minuten pagest de on-call met de originele payload pre-attached, zodat de fix een re-send-knop is en geen onderzoek.
- Draai per tenant een synthetic mailbox die elk uur een bekend testbericht binnenkrijgt. Verwerkt de agent het niet binnen 90 seconden, pagest hij iemand. Dit vangt verlopen Graph-subscriptions, throttle storms en gefaalde Gmail watch renewals voor een klant er iets van merkt.
Als je één ding uit deze lijst meeneemt: in Microsoft Graph is 202 Accepted géén delivery confirmation. Het is "we komen er nog aan toe". Bouw je reconciliatie-loop op de aanname dat elke send misschien nooit verstuurd is.
Het kleinste wat je vandaag kunt doen
Open je mail-API client en grep op sendMail. Check op elke call site of de payload bijlagen bevat en of de grootte gecontroleerd wordt vóór de call. Gebruik je POST /me/sendMail met inline bijlagen en heb je ze nooit expliciet gecapped, dan zit er een silent-drop bug klaar voor de volgende PDF van 5 MB. Zoek meteen in dezelfde codebase naar retries op een Graph 2xx response — zijn die er niet, dan is je reconciliatie-loop het gat.
Toen we de inbox-triage AI-agents voor het Tilburgse accountantskantoor bouwden, was de les die ons het meest gekost heeft de eerste op de lijst: een 2xx vertrouwen. We hebben er een dunne reconciliatie-laag overheen gelegd die elke send vijf minuten later vergelijkt met de sent-items folder, en die ene check heeft meer stille drops gevangen dan alle retries die we erbij gebouwd hebben.
Kern
In Microsoft Graph is een 202 Accepted op /sendMail geen delivery; bouw je reconciliatie-loop op de aanname dat elke send stilletjes gedropt kan zijn.
FAQ
Geeft Microsoft Graph echt 202 Accepted terug op een mislukte send?
Ja. /sendMail accepteert de request voor asynchrone verwerking en garandeert geen SMTP-aflevering. Verifieer met een sent-items-check of een delivery-webhook voordat je het pending record wist.
Hoe stuur ik een bijlage groter dan 4 MB via Microsoft Graph?
Gebruik createUploadSession om het bestand in stukken te knippen. Inline base64-bijlagen boven de 3 MB zijn onbetrouwbaar, en rond de 4 MB wordt de bijlage stilletjes gestript terwijl de API alsnog 202 Accepted teruggeeft.
Waarom stopt een Gmail watch-subscription zonder waarschuwing?
De watch-call geeft een expiration timestamp terug, waarna notificaties stoppen zonder finale callback. Verleng dagelijks en alert wanneer een verlenging faalt, zodat je geen dag binnenkomende mail kwijt bent.
Kan ik één threading-key gebruiken over Gmail en Outlook heen?
Nee. Gmail's threadId en de conversationId van Graph zijn niet uitwisselbaar. Normaliseer op een hash van de RFC 5322 Message-ID, In-Reply-To en References headers als je agent over beide loopt.