Skip to content

Commit 63ca24f

Browse files
authored
fix(gmail): drop an expired history cursor instead of re-saving it every poll (#9343)
Gmail answers history.list with 404 once the stored cursor ages out of its retained history and the client must full-sync. The fallback searched the inbox, then took the mailbox profile's historyId as the new cursor, but a quiet mailbox reports the expired id itself, so it was persisted again and every poll repeated the 404, search and profile calls with an ERROR log. The resync now never adopts the rejected cursor and drops it when nothing newer exists, so the webhook polls through search until matching mail supplies one. The expected 404 logs at info and other history errors at warn.
1 parent 4d26987 commit 63ca24f

2 files changed

Lines changed: 219 additions & 18 deletions

File tree

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { createLogger } from '@sim/logger'
2+
import { jsonResponse } from '@sim/testing/helpers/http'
3+
import { idempotencyServiceMock } from '@sim/testing/mocks/idempotency-service.mock'
4+
import {
5+
webhooksPollingUtilsMock,
6+
webhooksPollingUtilsMockFns,
7+
} from '@sim/testing/mocks/webhooks-polling-utils.mock'
8+
import {
9+
webhooksProcessorMock,
10+
webhooksProcessorMockFns,
11+
} from '@sim/testing/mocks/webhooks-processor.mock'
12+
import { beforeEach, describe, expect, it, vi } from 'vitest'
13+
14+
vi.mock('@/lib/core/idempotency/service', () => idempotencyServiceMock)
15+
16+
vi.mock('@/lib/webhooks/processor', () => webhooksProcessorMock)
17+
18+
vi.mock('@/lib/webhooks/polling/utils', () => webhooksPollingUtilsMock)
19+
20+
import { gmailPollingHandler } from '@/lib/webhooks/polling/gmail'
21+
import type {
22+
PollWebhookContext,
23+
WebhookRecord,
24+
WorkflowRecord,
25+
} from '@/lib/webhooks/polling/types'
26+
27+
const { mockUpdateWebhookProviderConfig: mockUpdateConfig, mockResolveOAuthCredential } =
28+
webhooksPollingUtilsMockFns
29+
const mockProcessEvent = webhooksProcessorMockFns.mockProcessPolledWebhookEvent
30+
31+
const EXPIRED_HISTORY_ID = '30000'
32+
33+
const WORKFLOW: WorkflowRecord = {
34+
id: 'gmail-listener',
35+
userId: 'gmail-owner',
36+
workspaceId: 'gmail-workspace',
37+
folderId: null,
38+
sortOrder: 0,
39+
name: 'Gmail listener',
40+
description: null,
41+
lastSynced: new Date('2026-10-01T00:00:00.000Z'),
42+
createdAt: new Date('2026-10-01T00:00:00.000Z'),
43+
updatedAt: new Date('2026-10-01T00:00:00.000Z'),
44+
isDeployed: true,
45+
deployedAt: new Date('2026-10-01T00:00:00.000Z'),
46+
isPublicApi: false,
47+
locked: false,
48+
forkSyncExcluded: false,
49+
runCount: 0,
50+
lastRunAt: null,
51+
variables: {},
52+
archivedAt: null,
53+
}
54+
55+
interface MailboxState {
56+
profileHistoryId: string
57+
inboxMessages: { id: string; historyId: string }[]
58+
}
59+
60+
function context(providerConfig: Record<string, unknown>): PollWebhookContext {
61+
const webhookData: WebhookRecord = {
62+
id: 'gmail-webhook',
63+
workflowId: 'gmail-listener',
64+
deploymentVersionId: null,
65+
registrationStatus: null,
66+
registrationGeneration: null,
67+
configFingerprint: null,
68+
preparedAt: null,
69+
blockId: null,
70+
path: 'gmail-listener',
71+
routingKey: null,
72+
provider: 'gmail',
73+
providerConfig: {
74+
labelIds: ['INBOX'],
75+
labelFilterBehavior: 'INCLUDE',
76+
markAsRead: false,
77+
lastCheckedTimestamp: new Date(Date.now() - 60_000).toISOString(),
78+
...providerConfig,
79+
},
80+
isActive: true,
81+
failedCount: 0,
82+
lastFailedAt: null,
83+
archivedAt: null,
84+
createdAt: new Date('2026-10-01T00:00:00.000Z'),
85+
updatedAt: new Date('2026-10-01T00:00:00.000Z'),
86+
}
87+
return {
88+
webhookData,
89+
workflowData: WORKFLOW,
90+
requestId: 'gmail-request',
91+
logger: createLogger('GmailTest'),
92+
}
93+
}
94+
95+
/**
96+
* A Gmail mailbox whose history records older than the stored cursor have aged out:
97+
* `history.list` answers 404 for that cursor, as the Gmail sync guide documents.
98+
*/
99+
function mailboxWithExpiredHistory(state: MailboxState) {
100+
const fetchMock = vi.fn(async (input: string | URL | Request) => {
101+
const url = new URL(typeof input === 'string' ? input : input.toString())
102+
if (url.pathname.endsWith('/users/me/history')) {
103+
return jsonResponse(
104+
{ error: { code: 404, message: 'Requested entity was not found.', status: 'NOT_FOUND' } },
105+
404
106+
)
107+
}
108+
if (url.pathname.endsWith('/users/me/profile')) {
109+
return jsonResponse({ historyId: state.profileHistoryId })
110+
}
111+
if (url.pathname.endsWith('/users/me/messages')) {
112+
return jsonResponse(
113+
state.inboxMessages.length
114+
? { messages: state.inboxMessages.map(({ id }) => ({ id, threadId: id })) }
115+
: { resultSizeEstimate: 0 }
116+
)
117+
}
118+
const message = state.inboxMessages.find((m) => url.pathname.endsWith(`/messages/${m.id}`))
119+
if (message) {
120+
return jsonResponse({
121+
id: message.id,
122+
threadId: message.id,
123+
historyId: message.historyId,
124+
labelIds: ['INBOX'],
125+
payload: { headers: [{ name: 'Subject', value: 'hello' }] },
126+
})
127+
}
128+
return new Response('unexpected request', { status: 500 })
129+
})
130+
vi.stubGlobal('fetch', fetchMock)
131+
}
132+
133+
function persistedHistoryIds() {
134+
return mockUpdateConfig.mock.calls.map(
135+
([, updates]) => (updates as { historyId?: string }).historyId
136+
)
137+
}
138+
139+
describe('gmailPollingHandler expired history cursor', () => {
140+
beforeEach(() => {
141+
mockResolveOAuthCredential.mockResolvedValue('access-token')
142+
mockProcessEvent.mockResolvedValue({ success: true })
143+
})
144+
145+
it('drops a cursor the quiet mailbox cannot replace instead of re-saving the expired one', async () => {
146+
mailboxWithExpiredHistory({
147+
profileHistoryId: EXPIRED_HISTORY_ID,
148+
inboxMessages: [],
149+
})
150+
151+
await expect(
152+
gmailPollingHandler.pollWebhook(context({ historyId: EXPIRED_HISTORY_ID }))
153+
).resolves.toBe('success')
154+
155+
expect(mockUpdateConfig).toHaveBeenCalledTimes(1)
156+
const [, updates] = mockUpdateConfig.mock.calls[0]
157+
expect(updates).toHaveProperty('historyId', undefined)
158+
})
159+
160+
it('resumes from the newest searched message when the full sync finds mail', async () => {
161+
mailboxWithExpiredHistory({
162+
profileHistoryId: '40100',
163+
inboxMessages: [{ id: 'm-new', historyId: '40050' }],
164+
})
165+
166+
await expect(
167+
gmailPollingHandler.pollWebhook(context({ historyId: EXPIRED_HISTORY_ID }))
168+
).resolves.toBe('success')
169+
170+
expect(mockProcessEvent).toHaveBeenCalledTimes(1)
171+
expect(persistedHistoryIds()).toEqual(['40050'])
172+
})
173+
174+
it('resumes from the mailbox cursor when activity moved it past the expired one', async () => {
175+
mailboxWithExpiredHistory({ profileHistoryId: '40100', inboxMessages: [] })
176+
177+
await expect(
178+
gmailPollingHandler.pollWebhook(context({ historyId: EXPIRED_HISTORY_ID }))
179+
).resolves.toBe('success')
180+
181+
expect(persistedHistoryIds()).toEqual(['40100'])
182+
})
183+
})

‎apps/sim/lib/webhooks/polling/gmail.ts‎

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,7 @@ export const gmailPollingHandler: PollingProviderHandler = {
8989
webhookId,
9090
{
9191
lastCheckedTimestamp: now.toISOString(),
92-
...(latestHistoryId || config.historyId
93-
? { historyId: latestHistoryId || config.historyId }
94-
: {}),
92+
historyId: latestHistoryId,
9593
},
9694
logger
9795
)
@@ -116,9 +114,7 @@ export const gmailPollingHandler: PollingProviderHandler = {
116114
webhookId,
117115
{
118116
lastCheckedTimestamp: now.toISOString(),
119-
...(latestHistoryId || config.historyId
120-
? { historyId: latestHistoryId || config.historyId }
121-
: {}),
117+
historyId: latestHistoryId,
122118
},
123119
logger
124120
)
@@ -174,8 +170,17 @@ async function fetchNewEmails(
174170

175171
if (!historyResponse.ok) {
176172
const status = historyResponse.status
173+
174+
if (status === 404 || status === 400) {
175+
await historyResponse.body?.cancel().catch(() => {})
176+
logger.info(
177+
`[${requestId}] Gmail rejected history cursor ${config.historyId} (${status}); resyncing through search`
178+
)
179+
return resyncAfterExpiredHistory(accessToken, config, requestId, logger)
180+
}
181+
177182
const errorData = await historyResponse.json().catch(() => ({}))
178-
logger.error(`[${requestId}] Gmail history API error:`, {
183+
logger.warn(`[${requestId}] Gmail history API error:`, {
179184
status,
180185
statusText: historyResponse.statusText,
181186
error: errorData,
@@ -188,17 +193,7 @@ async function fetchNewEmails(
188193
}
189194

190195
logger.info(`[${requestId}] Falling back to search API after history API error ${status}`)
191-
const searchResult = await searchEmails(accessToken, config, requestId, logger)
192-
if (searchResult.emails.length === 0) {
193-
const freshHistoryId = await getGmailProfileHistoryId(accessToken, requestId, logger)
194-
if (freshHistoryId) {
195-
logger.info(
196-
`[${requestId}] Fetched fresh historyId ${freshHistoryId} after invalid historyId (was: ${config.historyId})`
197-
)
198-
return { emails: [], latestHistoryId: freshHistoryId }
199-
}
200-
}
201-
return searchResult
196+
return searchEmails(accessToken, config, requestId, logger)
202197
}
203198

204199
const historyData = await historyResponse.json()
@@ -384,6 +379,29 @@ async function searchEmails(
384379
}
385380
}
386381

382+
/**
383+
* Full-syncs through search after Gmail rejects the history cursor (404 once it ages out of
384+
* retained history). Adopts a new cursor but never the rejected one, which a quiet mailbox's
385+
* profile still reports, so the webhook polls through search until matching mail supplies one.
386+
*/
387+
async function resyncAfterExpiredHistory(
388+
accessToken: string,
389+
config: GmailWebhookConfig,
390+
requestId: string,
391+
logger: Logger
392+
) {
393+
const searchResult = await searchEmails(accessToken, config, requestId, logger)
394+
const candidate =
395+
searchResult.latestHistoryId !== config.historyId
396+
? searchResult.latestHistoryId
397+
: await getGmailProfileHistoryId(accessToken, requestId, logger)
398+
const latestHistoryId = candidate && candidate !== config.historyId ? candidate : undefined
399+
if (!latestHistoryId) {
400+
logger.info(`[${requestId}] No replacement Gmail history cursor yet; polling through search`)
401+
}
402+
return { emails: searchResult.emails, latestHistoryId }
403+
}
404+
387405
async function getGmailProfileHistoryId(
388406
accessToken: string,
389407
requestId: string,

0 commit comments

Comments
 (0)