← Blog

Integrations

Mail API quirks: 15 from a Den Haag inbox-agent rollout

A confidentieel email forwards through an agent at a Den Haag advocatenkantoor. The X-Original-Sender header vanishes. Fifteen quirks, ranked by blast radius.

Jacob Molkenboer· Founder · A Brand New Company· 19 Jun 2026· 8 min
Cream wax-sealed envelope, brass letter-opener and carbon-copy slip on ivory desk, green ribbon trailing from flap.

Maandagochtend, 09:14. A partner at a 26-person advocatenkantoor on the Lange Voorhout forwards a confidentieel email to the inbox-triage agent we shipped two weeks earlier. The agent classifies it, drafts a reply, queues it for review. Everything green. Except the X-Original-Sender header, the only authoritative record of who the message actually came from, is gone. The forwarded copy in the agent's mailbox shows the partner as the sender. The audit log is now a lie.

The firm runs three mail systems: Microsoft 365 for the partners, Google Workspace for the junior associates and paralegals, and Zoho Mail for the support staff. Over six weeks of rollout we collected fifteen quirks across the three APIs that broke our trust in their responses. This is the cheatsheet, ranked by how loudly they fail.

How we ranked them

By blast radius, not frequency. A 429 you can retry. A 200 OK with a stripped header is a class-action waiting to be filed. Tier 1 is silent data loss: the API returned success and bytes are gone. Tier 2 is silent functional loss: success returned, behaviour silently wrong. Tier 3 is loud failures. At least they tell you.

Tier 1 — silent data loss

1. Microsoft Graph /forward drops internetMessageHeaders

The single most expensive quirk we found. POST /me/messages/{id}/forward accepts a comment and a toRecipients array and gives you a 202 Accepted. It does not preserve the original message's internet message headers, including X-Original-Sender, Authentication-Results, and anything custom the firm's compliance gateway stamps. The only way to keep them is to create a forward draft, patch the headers back onto the draft, and send the 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

The behaviour is documented but easy to miss; Microsoft's reference for internetMessageHeader notes the property is writeable only on draft messages. By the time the partner has hit Forward in Outlook, the headers are out of reach.

2. Zoho partial-attachment retry loses the S/MIME signature

If the first attempt to pull a multipart message with a 20MB attachment times out at the load balancer, the client retries. Zoho returns 200 OK on the retry. The body looks identical. But the multipart/signed wrapper has been replaced with a re-encoded multipart/mixed, and the application/pkcs7-signature part is gone. The signature was valid; the bytes that prove it are not in the response. The agent's verifier marks the message as unsigned, and the compliance dashboard flags a counter-party for sending without S/MIME — when in fact the strip happened on our side.

Workaround: always request ?mode=raw and never blind-retry the same fetch. Re-fetch the message metadata first and use the new messageId token.

3. Gmail messages.send rewrites the Message-ID

Send a raw RFC822 message via users.messages.send with your own Message-ID header and Gmail will silently overwrite it with one of its own. Threading breaks. Auditors who match on Message-ID across systems lose the trail. The fix is users.messages.insert with internalDateSource=dateHeader. It preserves the headers byte-for-byte but does not actually transmit the message, so you need both calls for inbound replays.

4. Microsoft Graph chunked attachment upload breaks the S/MIME boundary on retry

Attachments over 3MB use an upload session. If the network drops on the final chunk and you retry from the byte offset Graph returned, Exchange Online occasionally recomputes the MIME boundary string. The 200 OK is honest about delivery; it is dishonest about the signature, which now spans the wrong boundary and fails verification at the receiver. The fix is to abandon the upload session entirely on any 5xx after the second-to-last chunk, delete the partial, and start over.

5. Zoho forward replaces X-Original-Sender with X-OriginalArrivalTime

An Exchange compatibility shim, presumably. The header name is similar enough that grep-based audit tooling at the firm did not flag it. The original sender's address is gone from the forwarded message entirely; the timestamp of when Zoho first saw the message takes its slot.

Tier 2 — silent functional loss

6. Gmail forward via API strips X-Original-Sender

The Gmail web UI preserves X-Original-Sender on forward. The API does not, unless you build the raw RFC822 message yourself and call insert. Anything that uses the convenient messages.send with a threadId will drop it. We checked this against four test accounts before we believed it.

7. Gmail S/MIME silently stripped without CSE

Gmail's S/MIME support requires Enterprise Plus and client-side encryption (CSE) configured with a KACLS endpoint. Without it, send a signed message via API and Gmail will accept it, return 200, and deliver an unsigned version. There is no warning, no header indicating the strip. The firm's IT lead found out from a counter-party whose Outlook flagged the unsigned reply as a downgrade.

8. Microsoft Graph hides X-headers behind singleValueExtendedProperties

Read a message via /me/messages/{id} and you get internetMessageHeaders as a top-level array, but only for a fixed set of well-known headers. Custom X-headers, including the firm's compliance stamp, come back null. To get them you need:

GET /me/messages/{id}?$expand=singleValueExtendedProperties(
  $filter=id eq 'String 0x7D'
)

PR_TRANSPORT_MESSAGE_HEADERS (0x7D) returns the full raw header block. If you trust the top-level field, you will quietly miss every custom header the firm relies on.

9. Shared mailbox /sendMail without Mail.Send.Shared

Application permissions for a shared mailbox require Mail.Send.Shared, not just Mail.Send. With only the latter, /users/{shared}/sendMail returns 202 Accepted and the message goes nowhere. No 403. No bounce. Nothing in the sent folder. We caught it because the firm's receptionist wondered why nobody was replying.

10. Gmail watch() expires every 7 days

Push notifications via Pub/Sub stop after seven days unless you renew. The renewal call has to come from the same service account and reference the same topic; the failure mode if your cron drifts past the boundary is no notifications, no error, just silence. Set the renewal interval to four days and alarm on missing renewals, not on missing messages.

Tier 3 — loud failures, still worth knowing

11. Zoho strips MIME parts unless you specify ?mode=raw

Default fetch returns a parsed object that drops the parts your S/MIME verifier needs. Always request raw; never trust the parsed body for signed mail.

12. Zoho OAuth needs ZohoMail.messages.ALL and ZohoMail.accounts.READ

Granting only the first works until your first message fetch on a shared org alias, then returns a confusing 401 with no scope indication. Add both at consent time; you cannot incrementally upgrade without re-consenting all 26 users.

13. Zoho threading breaks on Outlook-style Message-ID suffixes

Zoho's conversation API ignores In-Reply-To if the referenced Message-ID contains an Exchange Online prefix like @DM6PR04MB.... Each Outlook reply becomes its own thread. The fix is to maintain your own threading map in the agent's database and stop relying on Zoho's threadId.

14. Microsoft Graph RBAC scope changes

Application-level access to /users/{id}/messages now requires an Application Access Policy or RBAC scope assignment. Without it the call works for the first user you provisioned (the test mailbox) and returns 403 for everyone else. The 403 is loud; the developer assumption that "it worked yesterday on dev" is louder.

15. Gmail forwarding-address verification goes to the destination

Creating a forwarding address via users.settings.forwardingAddresses.create sends a verification email to the destination address, not to the operator who made the API call. With 26 users to onboard, that means 26 verification clicks, all hidden in inboxes nobody is watching. Pre-create the addresses on a Friday afternoon and chase the clicks on Monday.

Warning

If your agent rollout touches confidential or regulated mail, do not trust any provider's 2xx response without independently verifying the bytes you care about. Hash the raw RFC822 before send, fetch it back, hash it again. Anything else is faith.

The five-minute audit

If you have an inbox agent in production today, the cheapest thing you can do this afternoon: forward one test message through each provider with a unique X-header and a small S/MIME signature, then fetch it back from the agent's mailbox and grep. If the header survives and the signature verifies, you have a baseline. If not, you have a list of fifteen places to start.

When we built the inbox-triage agent for the Den Haag firm, the thing we underestimated was how many silent successes the three providers return on the paths that matter for a regulated industry. We ended up running every outbound and inbound message through a verification proxy that hashes the headers and the signed block at the API boundary, and refuses to mark the operation complete until the round-trip hashes match.

Key takeaway

Mail APIs return 200 OK on the exact paths that quietly strip headers and signatures. Hash the bytes you care about, fetch them back, compare.

FAQ

Does Microsoft Graph preserve X-Original-Sender on forward?

No. POST /me/messages/{id}/forward returns 202 Accepted but drops internet message headers. Create a forward draft, PATCH the headers back, then send the draft.

Why does Zoho return 200 OK but lose my S/MIME signature?

On partial-attachment retry, Zoho re-encodes multipart/signed as multipart/mixed and discards the pkcs7-signature part. Use ?mode=raw and never blind-retry the same fetch.

How do I keep my own Message-ID through Gmail?

Use users.messages.insert with internalDateSource=dateHeader for replay. messages.send always rewrites the Message-ID, which breaks threading and cross-system audit trails.

What's the cheapest way to verify an inbox agent isn't dropping headers?

Forward a test message with a unique X-header and a small S/MIME signature through each provider, fetch it back from the agent mailbox, and grep. If both survive, you have a baseline.

integrationsemail automationai agentscase studysecurityoperations

Building something?

Start a project