@@ -126,6 +126,23 @@ export function isGmailRateLimitError(error: unknown): boolean {
126126 ) ;
127127}
128128
129+ /**
130+ * True when a {@link GmailApiError} says the entity is gone: an HTTP 404. For a
131+ * thread or message fetch this is TERMINAL, not a blip — the id no longer
132+ * resolves in the mailbox because it was permanently deleted, or because it was
133+ * a draft (Gmail replaces the draft message on every autosave and purges it
134+ * outright on discard, so a draft's ids routinely die inside the history
135+ * window). Retrying can never succeed, so callers drop the id instead of
136+ * carrying it in a pending set, and log it quietly rather than as an error.
137+ *
138+ * Note the caller's context decides the meaning: `history.list` also answers
139+ * 404, and there it means the history window expired and a full re-sync is
140+ * needed — those call sites handle the status themselves.
141+ */
142+ export function isGmailNotFoundError ( error : unknown ) : boolean {
143+ return error instanceof GmailApiError && error . status === 404 ;
144+ }
145+
129146/**
130147 * In-process retry budget for {@link GmailApi.call}. Kept small and short so a
131148 * brief blip (a momentary 429, a 5xx, a dropped connection) is absorbed inside
@@ -1517,6 +1534,10 @@ async function syncGmailChannelIncremental(
15171534 const thread = await api . getThread ( threadId ) ;
15181535 threads . push ( thread ) ;
15191536 } catch ( error ) {
1537+ if ( isGmailNotFoundError ( error ) ) {
1538+ logVanishedThread ( threadId ) ;
1539+ continue ;
1540+ }
15201541 console . error ( `Failed to fetch thread ${ threadId } :` , error ) ;
15211542 }
15221543 }
@@ -1622,7 +1643,11 @@ async function syncGmailChannelFull(
16221643 try {
16231644 return await api . getThread ( threadRef . id ) ;
16241645 } catch ( error ) {
1625- console . error ( `Failed to fetch thread ${ threadRef . id } :` , error ) ;
1646+ if ( isGmailNotFoundError ( error ) ) {
1647+ logVanishedThread ( threadRef . id ) ;
1648+ } else {
1649+ console . error ( `Failed to fetch thread ${ threadRef . id } :` , error ) ;
1650+ }
16261651 return null ;
16271652 }
16281653 }
@@ -1657,6 +1682,40 @@ async function syncGmailChannelFull(
16571682 */
16581683export const MAX_HISTORY_PAGES_PER_PASS = 20 ;
16591684
1685+ /**
1686+ * Logs a thread that no longer exists in the mailbox. Deliberately NOT
1687+ * `console.error`: a vanished thread is an ordinary outcome of a mailbox
1688+ * changing under us (a deleted message, a discarded draft), not a fault, and
1689+ * the connector's log is the author-facing surface.
1690+ */
1691+ function logVanishedThread ( threadId : string ) : void {
1692+ console . log (
1693+ `[gmail] thread ${ threadId } no longer exists in the mailbox; skipping`
1694+ ) ;
1695+ }
1696+
1697+ /**
1698+ * True when a history record's message is a draft. Gmail issues a fresh message
1699+ * id on every draft autosave and deletes the previous one (and discarding a
1700+ * draft purges it outright, without a trip through Trash), so draft ids churn
1701+ * constantly and are frequently gone before an incremental pass can fetch them.
1702+ * Drafts are also skipped when notes are built, and the Draft channel is off by
1703+ * default — so unless the user actually syncs Drafts, a draft change is work we
1704+ * would throw away even when the fetch succeeds.
1705+ */
1706+ function isDraftMessage ( message : { labelIds ?: string [ ] } ) : boolean {
1707+ return message . labelIds ?. includes ( "DRAFT" ) ?? false ;
1708+ }
1709+
1710+ /**
1711+ * Result of one `threads.get` during an incremental pass. `gone` (404) is
1712+ * terminal and dropped; `failed` is retryable and carried forward.
1713+ */
1714+ type ThreadFetchOutcome =
1715+ | { status : "ok" ; thread : GmailThread }
1716+ | { status : "gone" }
1717+ | { status : "failed" } ;
1718+
16601719/**
16611720 * Mailbox-wide incremental sync. Calls the Gmail History API without a
16621721 * `labelId` filter, so it returns every change in the mailbox since
@@ -1675,12 +1734,22 @@ export const MAX_HISTORY_PAGES_PER_PASS = 20;
16751734 * window) but must re-attempt the failed IDs on a subsequent sync by passing
16761735 * them back via `retryThreadIds` — otherwise the cursor would move past
16771736 * changes we never ingested, permanently losing that mail.
1737+ *
1738+ * A thread that answers 404 is the exception: it no longer exists, so it is
1739+ * dropped rather than returned as failed. Reporting it as failed sent every
1740+ * vanished thread around the retry ladder — one wasted `threads.get` and one
1741+ * logged error per pass, a stolen slot in the per-pass budget, and finally a
1742+ * "its change may be lost" warning about a thread we never had.
1743+ *
1744+ * Pass `includeDrafts` when the Draft channel is enabled; otherwise draft
1745+ * messages are skipped entirely (see {@link isDraftMessage}).
16781746 */
16791747export async function syncGmailMailboxIncremental (
16801748 api : GmailApi ,
16811749 historyId : string ,
16821750 retryThreadIds : string [ ] = [ ] ,
1683- maxThreads : number = Infinity
1751+ maxThreads : number = Infinity ,
1752+ options : { includeDrafts ?: boolean } = { }
16841753) : Promise <
16851754 | { expired : true }
16861755 | {
@@ -1730,6 +1799,10 @@ export async function syncGmailMailboxIncremental(
17301799 for ( const entry of historyResult . history ) {
17311800 lastReadRecordId = entry . id ;
17321801 for ( const added of entry . messagesAdded ?? [ ] ) {
1802+ // Draft autosave churn: skipped unless the user syncs Drafts. Only
1803+ // applied to added messages — a LABEL change on a draft (starring,
1804+ // archiving) is the user acting on the thread, so those still sync.
1805+ if ( ! options . includeDrafts && isDraftMessage ( added . message ) ) continue ;
17331806 changedThreadIds . add ( added . message . threadId ) ;
17341807 }
17351808 for ( const labeled of entry . labelsAdded ?? [ ] ) {
@@ -1760,23 +1833,31 @@ export async function syncGmailMailboxIncremental(
17601833
17611834 // Fetch a bounded number of threads in flight; results (and the failed
17621835 // list) keep `toFetch` order — retry ids first — so downstream processing
1763- // is deterministic.
1836+ // is deterministic. Each fetch resolves to one of three outcomes, because
1837+ // "gone" and "failed" must not be conflated: only a failure is worth
1838+ // retrying.
17641839 const fetched = await mapWithConcurrency (
17651840 toFetch ,
17661841 THREAD_FETCH_CONCURRENCY ,
1767- async ( threadId ) : Promise < GmailThread | null > => {
1842+ async ( threadId ) : Promise < ThreadFetchOutcome > => {
17681843 try {
1769- return await api . getThread ( threadId ) ;
1844+ return { status : "ok" , thread : await api . getThread ( threadId ) } ;
17701845 } catch ( error ) {
1846+ if ( isGmailNotFoundError ( error ) ) {
1847+ logVanishedThread ( threadId ) ;
1848+ return { status : "gone" } ;
1849+ }
17711850 console . error ( `Failed to fetch thread ${ threadId } :` , error ) ;
1772- return null ;
1851+ return { status : "failed" } ;
17731852 }
17741853 }
17751854 ) ;
1776- const threads = fetched . filter (
1777- ( thread ) : thread is GmailThread => thread !== null
1855+ const threads = fetched . flatMap ( ( outcome ) =>
1856+ outcome . status === "ok" ? [ outcome . thread ] : [ ]
1857+ ) ;
1858+ const failedThreadIds = toFetch . filter (
1859+ ( _ , i ) => fetched [ i ] . status === "failed"
17781860 ) ;
1779- const failedThreadIds = toFetch . filter ( ( _ , i ) => fetched [ i ] === null ) ;
17801861
17811862 return {
17821863 expired : false ,
0 commit comments