Skip to content

Commit d12e31c

Browse files
authored
fix(search): preserve Gmail reads when replies disappear (#8388)
* fix(search): preserve Gmail reads when replies disappear * fix(search): handle replies missing during Gmail verification
1 parent fc78ff0 commit d12e31c

2 files changed

Lines changed: 107 additions & 13 deletions

File tree

‎apps/sim/lib/sim-search/live/google.ts‎

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -435,20 +435,30 @@ export async function readGmail(
435435
for (const candidate of latest) {
436436
options.signal.throwIfAborted()
437437
const candidateId = string(candidate.id)
438-
if (
439-
!(await options.verify({
440-
id: candidateId,
441-
accessMetadata: { id: candidateId, labelIds: candidate.labelIds },
442-
}))
443-
) {
444-
warnings.add('messages outside the source search scope were omitted')
445-
continue
438+
let row: Record<string, unknown>
439+
try {
440+
if (
441+
!(await options.verify({
442+
id: candidateId,
443+
accessMetadata: { id: candidateId, labelIds: candidate.labelIds },
444+
}))
445+
) {
446+
warnings.add('messages outside the source search scope were omitted')
447+
continue
448+
}
449+
row = object(
450+
await client.json(`/gmail/v1/users/me/messages/${segment(candidateId)}`, {
451+
query: { format: 'full' },
452+
})
453+
)
454+
} catch (error) {
455+
options.signal.throwIfAborted()
456+
if (error instanceof NativeSearchError && error.httpStatus === 404) {
457+
warnings.add('some messages are no longer available')
458+
continue
459+
}
460+
throw error
446461
}
447-
const row = object(
448-
await client.json(`/gmail/v1/users/me/messages/${segment(candidateId)}`, {
449-
query: { format: 'full' },
450-
})
451-
)
452462
if (row.id !== candidateId || row.threadId !== threadId)
453463
throw new NativeSearchError(
454464
'unavailable',

‎apps/sim/scripts/test-search-google-content-e2e.ts‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ const mailbox: GmailMessage[] = Array.from({ length: 12 }, (_, index) => ({
7979
body: { data: Buffer.from(`Synthetic evidence ${index}.`).toString('base64url') },
8080
},
8181
}))
82+
const messageFailures = new Map<string, { status: number; format: string }>()
8283
const mediaStarted = createDeferred<void>()
8384
const mediaClosed = createDeferred<void>()
8485
const server = http.createServer((request, response) => {
@@ -92,6 +93,14 @@ const server = http.createServer((request, response) => {
9293
const row = mailbox.find((message) => message.id === id)
9394
const isThread = url.pathname.startsWith('/gmail/v1/users/me/threads/')
9495
const isAttachment = url.pathname.includes('/attachments/')
96+
const failure = !isThread ? messageFailures.get(id) : undefined
97+
if (failure && url.searchParams.get('format') === failure.format) {
98+
requests.push({ id, resource: 'message', status: failure.status })
99+
response
100+
.writeHead(failure.status, { 'Content-Type': 'application/json' })
101+
.end(JSON.stringify({ error: { code: failure.status } }))
102+
return
103+
}
95104
const threadMessages = isThread ? mailbox.filter((message) => message.threadId === id) : []
96105
const metadata = (message: (typeof mailbox)[number]) => ({
97106
...message,
@@ -284,6 +293,81 @@ try {
284293
server.listen(0, '127.0.0.1', () => started.resolve())
285294
await started.promise
286295
origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
296+
for (const { name, failedId, status, expectedStatus } of [
297+
{ name: 'healthy conversation', failedId: '', status: 200 },
298+
{ name: 'missing sibling', failedId: 'reply', status: 404 },
299+
{ name: 'missing anchor', failedId: 'anchor', status: 404, expectedStatus: 'unavailable' },
300+
{ name: 'expired credentials', failedId: 'reply', status: 401, expectedStatus: 'reconnect' },
301+
{ name: 'denied credentials', failedId: 'reply', status: 403, expectedStatus: 'reconnect' },
302+
{ name: 'rate limit', failedId: 'reply', status: 429, expectedStatus: 'rate_limited' },
303+
{ name: 'server failure', failedId: 'reply', status: 500, expectedStatus: 'unavailable' },
304+
]) {
305+
for (const format of failedId === 'reply' ? ['metadata', 'full'] : ['full']) {
306+
await check(
307+
`Gmail conversation handles ${name} during ${format} reads without hiding unrelated failures`,
308+
async () => {
309+
const before = mailbox.length
310+
mailbox.push(
311+
...['anchor', 'retained', 'reply'].map((id, index) => ({
312+
id,
313+
threadId: 'failure-conversation',
314+
labelIds: ['INBOX'],
315+
internalDate: String(Date.UTC(2026, 0, 1, 12, index)),
316+
payload: {
317+
mimeType: 'text/plain',
318+
body: { data: Buffer.from(`Synthetic ${id} evidence.`).toString('base64url') },
319+
},
320+
}))
321+
)
322+
if (failedId) messageFailures.set(failedId, { status, format })
323+
try {
324+
const signal = AbortSignal.timeout(5000)
325+
const api = createNativeClient({ origin, accessToken: TOKEN, signal })
326+
const policy = {
327+
...defaultLiveSearchPolicy('gmail'),
328+
mode: 'selected' as const,
329+
included: ['INBOX'],
330+
}
331+
const verify = createPolicyVerifier('gmail', policy, api, origin)
332+
assert.ok(await verify({ id: 'anchor' }))
333+
const reading = readGmail(api, 'anchor', {
334+
policy,
335+
signal,
336+
verify: (reference) =>
337+
verify(reference, format === 'full' ? reference.accessMetadata : undefined),
338+
})
339+
if (expectedStatus) {
340+
await assert.rejects(
341+
reading,
342+
(error: unknown) =>
343+
error instanceof NativeSearchError && error.status === expectedStatus
344+
)
345+
return
346+
}
347+
const document = await reading
348+
assert.equal(document.id, 'anchor')
349+
assert.ok(document.content.includes('Synthetic anchor evidence.'))
350+
assert.ok(document.content.includes('Synthetic retained evidence.'))
351+
assert.equal(document.content.includes('Synthetic reply evidence.'), status === 200)
352+
assert.equal(document.content.includes('Coverage incomplete:'), status === 404)
353+
assert.deepEqual(
354+
document.accessDependencies,
355+
status === 404 ? [{ id: 'retained' }] : [{ id: 'retained' }, { id: 'reply' }]
356+
)
357+
const current = createPolicyVerifier('gmail', policy, api, origin, undefined, {
358+
fresh: true,
359+
})
360+
assert.ok(await current({ id: document.id }))
361+
for (const dependency of document.accessDependencies ?? [])
362+
assert.ok(await current(dependency))
363+
} finally {
364+
mailbox.splice(before)
365+
messageFailures.clear()
366+
}
367+
}
368+
)
369+
}
370+
}
287371
await check(
288372
'Gmail conversation and fresh selected-label checks fit one native request budget',
289373
async () => {

0 commit comments

Comments
 (0)