Incremental mail sync has a simple rule: fetch changes, apply them, save the cursor. Every engineer building on Gmail or Microsoft Graph learns that pattern on day one. It is correct when your job is to mirror provider state into a cache or database.
It is wrong when something expensive sits between fetch and save — like an LLM that extracts tasks from each batch. We learned this after users reported empty task lists on weeks that clearly had actionable mail. The inbox had moved on. The extraction pipeline never got a second chance.
Mirror sync vs process-then-advance
Mirror sync: the cursor advances when the fetch succeeds. Gmail historyId updates, Outlook deltaLink updates, done. Correct for live inbox views where displaying mail is the end goal.
Process-then-advance: the cursor advances only when downstream processing commits. Fetch is step one. Extract tasks, enrich, bill, index — whatever your pipeline does — is step two. Step three persists the cursor. If step two throws, the cursor stays put.
The silent data loss bug
Here is the failure mode we hit. Task extraction pulls ten new emails via incremental sync. It stores the new Gmail historyId immediately. Gemini times out on email seven. The API returns 500. Those ten emails never re-enter the extraction queue — the cursor already advanced past them.
From the user's perspective: mail arrived, nothing became a task, no error surfaced in the UI. Support tickets described it as "AI missed my email" when the model never saw a retry.
Outlook had the same shape. Our delta route persisted outlookMailDeltaLink on every successful fetch. A crash after fetch but before task creation had identical consequences.
persist=false and deferred commit
The fix has two parts. First, fetch Outlook deltas with persist=false so the route returns a new deltaLink in the JSON response but does not write it to the integration row. Hold newGmailHistoryId and newOutlookDeltaLink in memory through the whole pipeline.
Second, only after all LLM batches succeed and tasks are persisted, commit both cursors in one step:
// ONLY after all Gemini batches complete without throwing:
if (googleIntegration && newGmailHistoryId !== gmailHistoryId) {
await prisma.integration.update({
where: { id: googleIntegration.id },
data: { data: { ...data, gmailHistoryId: newGmailHistoryId } },
})
}
if (microsoftIntegration && newOutlookDeltaLink !== storedDeltaLink) {
await prisma.integration.update({
where: { id: microsoftIntegration.id },
data: { data: { ...data, outlookMailDeltaLink: newOutlookDeltaLink } },
})
}If any batch throws, the catch block returns 500 and cursors remain unchanged. The next run re-processes the same mail. That is intentional.
Retries without duplicate tasks
At-least-once processing requires idempotent writes. Before creating a task from an email, we check for an existing row matching userId, source: EMAIL_AI, sourceId (the email ID), and title:
const existing = await prisma.task.findFirst({
where: {
userId: user.id,
source: 'EMAIL_AI',
sourceId: result.emailId,
title: task.title,
},
})
if (existing) continue // safe retryRe-running extraction after a failure may cost extra LLM tokens. Skipping mail permanently costs user trust. We chose tokens.
Batch sizing
Emails are chunked into batches of ten for a single extractTasksFromEmailsBatch call. We use temporary IDs (email_1, email_2) in the prompt so the model does not corrupt opaque Gmail/Outlook message IDs, then map results back to real IDs before persistence.
What you pay for this
Duplicate LLM work on retry. Acceptable. Token cost is visible in logs; lost mail is not.
Consent and rate limits gate the pipeline. Extraction requires explicit AI consent and is limited to six runs per minute per user. A rejected consent check fails before fetch — cursors never advance, which is correct.
Two cursor owners. The live inbox dashboard may advance cursors on its own schedule. Task extraction owns gmailHistoryId for its job. Document which subsystem writes which key or you will debug phantom full re-fetches for months.
Related: unified inbox incremental sync · batch LLM extraction details. Join the Kvika beta.