Kvika Team·· 8 min read

Building a Unified Gmail + Outlook Inbox

Gmail uses historyId. Outlook uses deltaLink. Two JSON shapes, two deletion semantics — how we merge both into one live inbox without re-fetching everything on every refresh.

emailgmailmicrosoft-outlookengineeringsync

Engineers on mixed stacks do not want two browser tabs for mail. They want one unread count, one search, one place to triage. That sounds like a UI problem until you try to keep Gmail and Outlook accurate without pulling thousands of messages every time someone switches tabs.

At Kvika we unified both providers behind one dashboard. The hard part is not rendering a combined list — it is incremental sync: Gmail tracks changes with a monotonic historyId, Microsoft Graph hands you an @odata.deltaLink, and the two APIs disagree on what a "change" even looks like. This post walks through the architecture we landed on.

Two inboxes, one screen

A naïve unified inbox fetches all Gmail threads, fetches all Outlook messages, concatenates, sorts by date. That works in a demo. In production it means slow loads, rate limits, and stale data the moment a webhook fires.

The correct model is the same one you use for calendar sync: maintain a cursor per provider, fetch only deltas, merge into client state. Gmail and Outlook cursors are stored separately on the user's integration record — gmailHistoryId and outlookMailDeltaLink — and advanced independently.

Treat provider cursors as independent streams. Never assume Gmail and Outlook advance on the same schedule.

Gmail: history.list, not messages.list

Gmail's incremental API is users.history.list. You pass your last startHistoryId and receive a list of history records — not full messages. Each record may contain messagesAdded, messagesDeleted, labelsAdded, or labelsRemoved.

That last pair matters more than people expect. Marking a thread read/unread or starring it is a label change. If you only listen for messageAdded, your unread badge drifts until the next full refresh.

typescript
// Collect changed IDs from history records
const changedIds = new Set<string>()
const deletedIds = new Set<string>()

for (const record of history) {
  record.messagesAdded?.forEach(m => changedIds.add(m.message!.id!))
  record.messagesDeleted?.forEach(m => deletedIds.add(m.message!.id!))
  record.labelsAdded?.forEach(m => changedIds.add(m.message!.id!))
  record.labelsRemoved?.forEach(m => changedIds.add(m.message!.id!))
}

// Fetch full message only for changed IDs
const emails = await Promise.all(
  [...changedIds].map(id => gmail.users.messages.get({ userId: 'me', id, format: 'full' }))
)

return { emails, deleted: [...deletedIds], historyId: newHistoryId }

historyId expiry

Gmail history IDs expire. If the user has not synced in a while, history.list returns 400. Catch that, fall back to a bounded full fetch (we scope initial loads to today's inbox), and store a fresh historyId from the profile or watch response. Without this fallback, returning users see a frozen inbox.

Outlook: delta queries on mail

Microsoft Graph mail sync uses delta queries. The first call hits an endpoint like /me/mailFolders('inbox')/messages/delta with a filter — we use today's received time as the initial window. Graph returns changed items plus either @odata.nextLink (more pages) or @odata.deltaLink (sync complete).

On subsequent runs you call the stored deltaLink URL directly. You get upserts since the last run. Outlook does not give you explicit deletions the same way Gmail does in every case — your merge layer treats delta results as upserts and relies on separate delete semantics where Graph marks removals.

We persist outlookMailDeltaLink on the Microsoft integration row. The delta route supports deltaLink=clear to wipe state when tokens rot, and persist=false to fetch a new cursor without writing it yet — important for downstream pipelines that we cover in a later post.

Pagination footgun: @odata.deltaLink often appears only on the last page. If you stop at page one on a busy inbox, you truncate mail and store a cursor that thinks you are caught up.

Merging upserts and deletions

The unified dashboard keeps React state as a list but merges incrementally via a Map keyed by message ID:

typescript
// Incremental merge in the client
const emailMap = new Map(prevEmails.map(e => [e.id, e]))

gmailData.emails.forEach(e => emailMap.set(e.id, { ...e, provider: 'gmail' }))
gmailData.deleted?.forEach(id => emailMap.delete(id))
outlookData.emails.forEach(e => emailMap.set(e.id, e))

return [...emailMap.values()].sort(
  (a, b) => new Date(b.receivedDateTime).getTime() - new Date(a.receivedDateTime).getTime()
)

On first load or explicit force refresh, replace the list entirely. On incremental passes — triggered by polling, SSE, or user action — patch in place so scroll position and read state do not flicker.

Normalise both providers to one shape before merge: id, subject, from (name + address), receivedDateTime, provider, webLink. Gmail gives you snippet and labelIds; Graph gives bodyPreview and isRead. Map both to the same fields.

What broke in production

Cursor key schizophrenia. Gmail watch setup stored historyId under one integration key; task extraction read gmailHistoryId under another. Symptoms looked like random full re-fetches. Align keys or document which subsystem owns which cursor.

forceRefresh on every push notification. Webhooks should nudge an incremental merge. Calling full fetch on every SSE event discards cursor savings and hammers APIs during label-sync bursts.

Parallel fetch with silent partial failure. We fetch Gmail and Outlook in parallel. If one provider's token expired, show partial results and a reconnect prompt for the failing side — not an empty inbox.

Next: why we do not save sync cursors until AI extraction finishes. Join the Kvika beta.

Kvika Team

Kvika unifies your calendar, email, and tasks across Google and Microsoft. Join the beta at kvika.work/waitlist.