Integrations
Mail API-eigenaardigheden: 15 lessen uit een advocatenkantoor
Een vertrouwelijke mail wordt doorgestuurd via een agent op een Haags advocatenkantoor. De X-Original-Sender header is weg. Vijftien quirks, gerangschikt op blast radius.

Maandagochtend, 09:14. Een partner op een advocatenkantoor met 26 mensen aan de Lange Voorhout stuurt een vertrouwelijke mail door naar de inbox-triage agent die we twee weken eerder live zetten. De agent classificeert 'm, stelt een antwoord op, zet het in de reviewqueue. Alles groen. Behalve de X-Original-Sender header, het enige autoritatieve spoor van wie de mail werkelijk verstuurde, die is verdwenen. In de doorgestuurde kopie in de mailbox van de agent staat de partner als afzender. De audit log liegt nu.
Het kantoor draait drie mailsystemen: Microsoft 365 voor de partners, Google Workspace voor de medewerkers en paralegals, en Zoho Mail voor het ondersteunend personeel. In zes weken uitrol verzamelden we vijftien quirks over de drie API's heen die ons vertrouwen in hun responses braken. Dit is de cheatsheet, gerangschikt op hoe hard ze falen.
Hoe we ze rangschikten
Op blast radius, niet op frequentie. Een 429 kun je opnieuw proberen. Een 200 OK met een gestripte header is een class-action die op afroep wacht. Tier 1 is stille dataloss: de API gaf success terug en de bytes zijn weg. Tier 2 is stille functionele loss: success terug, gedrag stilletjes verkeerd. Tier 3 zijn luidruchtige failures. Die vertellen je het tenminste.
Tier 1 — stille dataloss
1. Microsoft Graph /forward dropt internetMessageHeaders
De duurste quirk die we vonden. POST /me/messages/{id}/forward accepteert een comment en een toRecipients array en geeft een 202 Accepted terug. De internet message headers van de oorspronkelijke mail blijven niet behouden, dus ook niet X-Original-Sender, Authentication-Results, of wat de compliance gateway van het kantoor er extra op stempelt. De enige manier om ze te bewaren: maak een forward draft, patch de headers terug op de draft, verstuur de draft.
POST /me/messages/{id}/createForward
Content-Type: application/json
{ "toRecipients": [{ "emailAddress": { "address": "triage@firm.nl" }}] }
PATCH /me/messages/{draftId}
{ "internetMessageHeaders": [
{ "name": "X-Original-Sender", "value": "client@externe.nl" }
]}
POST /me/messages/{draftId}/send
Het gedrag staat in de documentatie maar wordt makkelijk gemist; Microsofts referentie voor internetMessageHeader meldt dat de property alleen op drafts schrijfbaar is. Tegen de tijd dat de partner in Outlook op Doorsturen heeft geklikt, zijn de headers buiten bereik.
2. Zoho partial-attachment retry verliest de S/MIME-handtekening
Als de eerste poging om een multipart-bericht met een bijlage van 20 MB op te halen op de load balancer timeout, doet de client een retry. Zoho geeft 200 OK op de retry. De body ziet er identiek uit. Maar de multipart/signed wrapper is vervangen door een opnieuw geëncodeerde multipart/mixed, en de application/pkcs7-signature part is weg. De handtekening was geldig; de bytes die dat bewijzen zitten niet in de response. De verifier van de agent merkt de mail aan als ongetekend, en het compliance-dashboard markeert een tegenpartij omdat die zonder S/MIME zou hebben verzonden — terwijl de strip aan onze kant gebeurde.
Workaround: vraag altijd ?mode=raw aan en doe nooit een blinde retry op dezelfde fetch. Haal eerst de metadata van het bericht opnieuw op en gebruik het nieuwe messageId token.
3. Gmail messages.send herschrijft de Message-ID
Verstuur een raw RFC822-bericht via users.messages.send met je eigen Message-ID header en Gmail overschrijft 'm stilletjes met een eigen waarde. Threading breekt. Auditors die op Message-ID matchen tussen systemen raken het spoor kwijt. De fix is users.messages.insert met internalDateSource=dateHeader. Die behoudt de headers byte-voor-byte, maar verstuurt het bericht niet daadwerkelijk, dus voor inbound replays heb je beide calls nodig.
4. Microsoft Graph chunked attachment upload breekt de S/MIME-boundary op retry
Bijlagen boven 3 MB gebruiken een upload session. Als het netwerk wegvalt op de laatste chunk en je hervat vanaf de byte offset die Graph teruggaf, herberekent Exchange Online af en toe de MIME-boundary string. De 200 OK is eerlijk over de delivery; oneerlijk over de handtekening, die nu over de verkeerde boundary loopt en bij de ontvanger faalt op verificatie. De fix: gooi de upload session helemaal weg bij elke 5xx na de voorlaatste chunk, verwijder het deelresultaat en begin opnieuw.
5. Zoho forward vervangt X-Original-Sender door X-OriginalArrivalTime
Een Exchange-compatibility shim, vermoedelijk. De headernaam lijkt genoeg om door grep-based audit tooling op het kantoor heen te glippen. Het adres van de oorspronkelijke afzender is uit het doorgestuurde bericht verdwenen; de timestamp van wanneer Zoho de mail voor het eerst zag, neemt z'n plek in.
Tier 2 — stille functionele loss
6. Gmail forward via API stript X-Original-Sender
De webinterface van Gmail bewaart X-Original-Sender bij doorsturen. De API niet, tenzij je het raw RFC822-bericht zelf opbouwt en insert aanroept. Alles wat het handige messages.send met een threadId gebruikt, gooit 'm weg. We hebben dit op vier testaccounts gecheckt voordat we het geloofden.
7. Gmail S/MIME stilletjes gestript zonder CSE
Gmails S/MIME-ondersteuning vereist Enterprise Plus en client-side encryption (CSE) geconfigureerd met een KACLS endpoint. Zonder dat: verstuur je een ondertekend bericht via de API, dan accepteert Gmail het, geeft 200 terug, en levert een ongetekende versie af. Geen waarschuwing, geen header die de strip aangeeft. De IT-lead van het kantoor kwam erachter via een tegenpartij wiens Outlook het ongetekende antwoord als downgrade markeerde.
8. Microsoft Graph verbergt X-headers achter singleValueExtendedProperties
Lees een bericht via /me/messages/{id} en je krijgt internetMessageHeaders als top-level array, maar alleen voor een vaste set bekende headers. Custom X-headers, inclusief de compliance-stempel van het kantoor, komen null terug. Om ze op te halen heb je dit nodig:
GET /me/messages/{id}?$expand=singleValueExtendedProperties(
$filter=id eq 'String 0x7D'
)
PR_TRANSPORT_MESSAGE_HEADERS (0x7D) geeft het volledige raw header-blok terug. Vertrouw je het top-level veld, dan mis je stilletjes elke custom header waarop het kantoor leunt.
9. Shared mailbox /sendMail zonder Mail.Send.Shared
Application permissions voor een gedeelde mailbox vereisen Mail.Send.Shared, niet alleen Mail.Send. Met alleen die laatste geeft /users/{shared}/sendMail 202 Accepted terug en gaat het bericht nergens heen. Geen 403. Geen bounce. Niets in de map Verzonden. We vingen het op omdat de receptioniste zich afvroeg waarom niemand antwoordde.
10. Gmail watch() verloopt elke 7 dagen
Push notifications via Pub/Sub stoppen na zeven dagen tenzij je verlengt. De renewal call moet vanaf hetzelfde service account komen en hetzelfde topic refereren; als je cron over de deadline schuift, krijg je geen notificaties, geen error, alleen stilte. Zet het renewal-interval op vier dagen en alarmeer op gemiste renewals, niet op gemiste berichten.
Tier 3 — luidruchtige failures, alsnog goed om te weten
11. Zoho stript MIME-parts tenzij je ?mode=raw opgeeft
Een default fetch geeft een geparset object terug dat juist de parts dropt die je S/MIME-verifier nodig heeft. Vraag altijd raw aan; vertrouw nooit de geparste body voor ondertekende mail.
12. Zoho OAuth heeft ZohoMail.messages.ALL én ZohoMail.accounts.READ nodig
Alleen de eerste toestaan werkt tot de eerste message-fetch op een gedeeld org-alias, daarna komt er een verwarrende 401 terug zonder scope-indicatie. Voeg ze allebei toe bij het consent-moment; je kunt niet incrementeel upgraden zonder alle 26 gebruikers opnieuw te laten goedkeuren.
13. Zoho threading breekt op Outlook-stijl Message-ID suffixen
De conversation API van Zoho negeert In-Reply-To als het gerefereerde Message-ID een Exchange Online prefix bevat zoals @DM6PR04MB.... Elk Outlook-antwoord wordt z'n eigen thread. De fix: houd je eigen threading map bij in de database van de agent en stop met leunen op Zoho's threadId.
14. Microsoft Graph RBAC scope-wijzigingen
Application-level toegang tot /users/{id}/messages vereist nu een Application Access Policy of RBAC scope-toewijzing. Zonder dat werkt de call voor de eerste user die je provisionde (de testmailbox) en geeft 403 voor de rest. De 403 is luid; de aanname van de developer dat "het gisteren op dev werkte" is luider.
15. Gmail-verificatie van forwarding-adres gaat naar de bestemming
Een forwarding address aanmaken via users.settings.forwardingAddresses.create verstuurt een verificatiemail naar het bestemmingsadres, niet naar de operator die de API-call deed. Met 26 gebruikers om te onboarden zijn dat 26 verificatie-klikken, allemaal verstopt in inboxen waar niemand naar kijkt. Maak de adressen aan op een vrijdagmiddag en jaag de klikken op vanaf maandag.
Als je agent-uitrol vertrouwelijke of gereguleerde mail raakt, vertrouw dan geen enkele 2xx-response van een provider zonder zelf de bytes te verifiëren die je belangrijk vindt. Hash de raw RFC822 vóór verzending, haal 'm terug, hash 'm opnieuw. Al het andere is geloof.
De audit van vijf minuten
Heb je vandaag een inbox agent in productie staan, dan is dit het goedkoopste wat je vanmiddag kunt doen: stuur één testbericht door via elke provider met een unieke X-header en een kleine S/MIME-handtekening, haal 'm terug uit de mailbox van de agent en grep. Overleeft de header en verifieert de handtekening, dan heb je een baseline. Zo niet, dan heb je een lijst met vijftien plekken om te beginnen.
Toen we de inbox-triage agent voor het Haagse kantoor bouwden, onderschatten we hoeveel stille successen de drie providers teruggeven op precies de paden die in een gereguleerde sector tellen. Uiteindelijk laten we elk uitgaand en inkomend bericht door een verificatieproxy lopen die de headers en het signed block bij de API-grens hasht, en die de operatie pas afgerond markeert als de round-trip hashes matchen.
Kern
Mail API's geven 200 OK terug op precies de paden die stilletjes headers en handtekeningen strippen. Hash de bytes die je belangrijk vindt, haal ze terug, vergelijk.
FAQ
Behoudt Microsoft Graph X-Original-Sender bij doorsturen?
Nee. POST /me/messages/{id}/forward geeft 202 Accepted terug maar dropt internet message headers. Maak een forward draft, PATCH de headers terug, verstuur dan de draft.
Waarom geeft Zoho 200 OK maar verlies ik mijn S/MIME-handtekening?
Bij een partial-attachment retry encodeert Zoho multipart/signed opnieuw als multipart/mixed en gooit de pkcs7-signature part weg. Gebruik ?mode=raw en doe nooit een blinde retry op dezelfde fetch.
Hoe behoud ik mijn eigen Message-ID via Gmail?
Gebruik users.messages.insert met internalDateSource=dateHeader voor replay. messages.send herschrijft de Message-ID altijd, wat threading en cross-system audit trails breekt.
Wat is de goedkoopste manier om te checken dat een inbox agent geen headers dropt?
Stuur een testbericht met een unieke X-header en een kleine S/MIME-handtekening door elke provider, haal 'm terug uit de mailbox van de agent en grep. Overleven beide, dan heb je een baseline.