Most AI email triage workflows break for engineering reasons that have nothing to do with the model’s classification quality: an over-broad OAuth scope that fails verification, a polling loop that burns through Gmail API quota in minutes, or a push subscription that silently goes quiet after a week. Each of these has an exact, documented cause and an exact fix. Here are the seven most common ones, in the order they tend to surface as a workflow moves from prototype to something running unattended.
Mistake 1: requesting mail.google.com when gmail.modify would do
The full https://mail.google.com/ scope grants read, compose, send, and permanent deletion that bypasses the trash. It’s tempting to request it up front “in case the workflow needs it later,” but Google classifies it as restricted, and an app requesting it must pass a security assessment if it stores or transmits the data on its own servers — a heavier verification path than the app usually needs. gmail.modify covers reading, composing, and sending, and explicitly does not allow permanent, bypass-the-trash deletion of threads and messages; for a triage workflow that labels, archives, and occasionally moves mail to trash, that’s everything it needs. Request the narrower scope and your verification review is correspondingly narrower.
Mistake 2: polling messages.list instead of watch + history.list
Gmail’s API meters usage in quota units, and the per-method costs make the difference between these two approaches concrete rather than theoretical:
| Method | Quota units | Typical use |
|---|---|---|
| messages.list | 5 | Polling for new mail on a timer |
| messages.get | 20 | Fetching a message’s full content |
| history.list | 2 | Fetching only what changed since a known history ID |
| watch | 100 (once per renewal) | Registering for push notifications via Pub/Sub |
| messages.batchModify | 50 | Labeling many messages in one call |
| messages.send | 100 | Sending a reply |
A workflow that polls messages.list every 30 seconds to check for new mail spends 5 units per call regardless of whether anything arrived — roughly 600 units an hour doing nothing. Registering a watch once costs 100 units, and each subsequent history.list call to pull only what actually changed costs 2 units. Against the default per-user cap of 6,000 quota units per minute, the polling approach is wasteful rather than fatal at low volume, but it’s the first thing to fix once a workflow needs to triage more than a handful of mailboxes, since the push-based approach can be two orders of magnitude cheaper per check for an inbox that’s mostly quiet.
Mistake 3: calling modify once per message instead of batching
Applying a label to 50 triaged messages one call at a time costs roughly 50 × the single-message modify cost. messages.batchModify applies the same label change to up to 1,000 message IDs in a single request for a flat 50 quota units — the batched call can cost less than modifying even a handful of messages individually, and the gap widens linearly with batch size. If your triage logic classifies a batch of unread messages and then loops over them calling a single-message modify endpoint, that loop is the first place to look when quota usage is higher than the math above would predict.
Mistake 4: letting the watch subscription expire silently
A watch registration is not permanent. Google’s documentation is explicit that you must call watch again at least once every 7 days, and recommends renewing once a day rather than waiting until just before the 7-day deadline. The watch response includes its own expiration timestamp — read and store it, and schedule the renewal off that stored value rather than assuming a fixed interval, since Google can change it. A workflow with no renewal job will keep running, logs will look clean, and then silently stop receiving push notifications a week after deployment with no error raised anywhere in the pipeline; the only symptom is new mail that quietly never gets triaged. Pair this with periodic history.list calls as a fallback — Google’s own guidance recommends checking history after any gap in notifications, since push messages can be delayed or dropped even while a watch subscription is technically still active.
Mistake 5: setting up Pub/Sub without the one required grant
Push notifications require a Cloud Pub/Sub topic that Gmail publishes to, and the specific step that trips people up is granting publish rights on that topic to Gmail’s own service account, gmail-api-push@system.gserviceaccount.com. Skip this and watch calls can still succeed, but no notifications ever arrive, because Gmail never had permission to publish to your topic in the first place — a failure mode that looks identical to Mistake 4 from the outside but has a completely different cause and fix. If your organization has domain-restricted sharing turned on for Pub/Sub, that policy can block the grant to Gmail’s service account specifically, and needs its own exception configured before the grant takes effect.
Mistake 6: not budgeting quota for a backlog re-triage
A workflow tuned for steady-state, incremental triage can fail the first time someone points it at an existing backlog of a few thousand unread messages, because that one-time pass behaves completely differently from the ongoing workflow it was tested against: it’s bursty, front-loaded, and far more likely to brush up against the per-user cap of 6,000 quota units per minute than anything your steady-state testing exercised. Fetching 2,000 full messages at 20 units each is 40,000 units — well over six minutes of a single user’s entire budget even if nothing else is running concurrently. Rate-limit your own backlog job explicitly (a deliberate delay or a token-bucket limiter) rather than discovering the platform’s limit via a wave of 429 responses partway through, and prefer fetching only the metadata and headers you actually need for a classification pass instead of the full message body for every item in a large backlog.
Mistake 7: treating a history.list 404 as a bug instead of a signal to resync
Eventually, every incremental-sync design hits this: the pipeline calls history.list with the last stored historyId, and Gmail returns an HTTP 404 instead of a list of changes. Google’s own sync guide is explicit about why: history records are “typically available for at least one week and often longer,” but that window “might be significantly shorter,” and in rare cases records can be unavailable even sooner. There is no fixed retention number to code against, so a 404 here is not an edge case to suppress or retry blindly — it is the API telling the client its reference point has aged out.
This shows up in practice whenever a triage worker has been paused for a few days (a deploy freeze, a crashed cron job, a developer disabling the watch while testing something else) and then resumes from a historyId it saved a week or more earlier. If the code treats the 404 as a transient error and retries the same request, it will get the same 404 forever, and the inbox will look “triaged” while nothing new is actually being processed.
The documented fix is a full resync, not a retry: when history.list returns 404, discard the stale historyId, run a fresh messages.list (or a time-boxed query such as everything newer than the last successfully processed message), triage that catch-up batch, and then store the historyId from the most recent message as the new sync anchor going forward. Two things make this safe to automate:
- Catch the 404 specifically (don’t swallow it inside a generic retry-on-error wrapper) and branch into a dedicated resync function, so the failure mode is visible in logs rather than hidden behind an automatic retry.
- Re-run the backlog quota math from Mistake 6 before kicking off the resync. A gap of a few hours costs little, but a gap of several days can mean re-triaging thousands of messages, which is exactly the per-minute cap scenario described above.
Logging every historyId the pipeline stores, along with the timestamp it was stored at, turns this from a mystery into a two-line root-cause note the next time triage silently stalls: compare the age of the last stored historyId against how long the worker was actually down, and the 404 stops being surprising.
One more thing worth building in from the start: treat email content as untrusted input
None of the six mistakes above are about model behavior, but a triage workflow that feeds raw email bodies to an LLM classifier has the same attack surface as any other system that processes external, uncontrolled text: a message can contain instructions aimed at your classifier rather than at a human reader. This isn’t hypothetical for inbox automation specifically — it’s the same category of risk covered in our guide to prompt injection, and worth red-teaming the same way our OWASP LLM Top 10 walkthrough describes, before you grant the workflow a scope that lets it send mail or move things to trash on the strength of what a message’s own body told it to do.
If email triage is one piece of a larger always-on automation — say, a daily digest or a scheduled follow-up pass — the scheduling half of that problem is covered separately in our walkthrough of running a recurring Claude API call with cron, including the error-handling checklist for a job that’s supposed to run unattended for months rather than get watched the first time it executes. And if the output of triage feeds into a team-facing summary rather than a fully automated action, the same async-first thinking behind our guide to running an async standup with AI applies just as well to a daily “here’s what came in overnight” digest assembled from triaged mail.



