Skip to content

Commit a1e7e8e

Browse files
committed
fix(search): preserve Gmail reads when replies disappear
1 parent 35efc8a commit a1e7e8e

2 files changed

Lines changed: 97 additions & 5 deletions

File tree

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

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -444,11 +444,21 @@ export async function readGmail(
444444
warnings.add('messages outside the source search scope were omitted')
445445
continue
446446
}
447-
const row = object(
448-
await client.json(`/gmail/v1/users/me/messages/${segment(candidateId)}`, {
449-
query: { format: 'full' },
450-
})
451-
)
447+
let row: Record<string, unknown>
448+
try {
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
461+
}
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: 82 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, number>()
8283
const mediaStarted = createDeferred<void>()
8384
const mediaClosed = createDeferred<void>()
8485
const server = http.createServer((request, response) => {
@@ -92,6 +93,15 @@ 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 =
97+
!isThread && url.searchParams.get('format') === 'full' ? messageFailures.get(id) : undefined
98+
if (failure) {
99+
requests.push({ id, resource: 'message', status: failure })
100+
response
101+
.writeHead(failure, { 'Content-Type': 'application/json' })
102+
.end(JSON.stringify({ error: { code: failure } }))
103+
return
104+
}
95105
const threadMessages = isThread ? mailbox.filter((message) => message.threadId === id) : []
96106
const metadata = (message: (typeof mailbox)[number]) => ({
97107
...message,
@@ -284,6 +294,78 @@ try {
284294
server.listen(0, '127.0.0.1', () => started.resolve())
285295
await started.promise
286296
origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
297+
for (const { name, failedId, status, expectedStatus } of [
298+
{ name: 'healthy conversation', failedId: '', status: 200 },
299+
{ name: 'missing sibling', failedId: 'reply', status: 404 },
300+
{ name: 'missing anchor', failedId: 'anchor', status: 404, expectedStatus: 'unavailable' },
301+
{ name: 'expired credentials', failedId: 'reply', status: 401, expectedStatus: 'reconnect' },
302+
{ name: 'denied credentials', failedId: 'reply', status: 403, expectedStatus: 'reconnect' },
303+
{ name: 'rate limit', failedId: 'reply', status: 429, expectedStatus: 'rate_limited' },
304+
{ name: 'server failure', failedId: 'reply', status: 500, expectedStatus: 'unavailable' },
305+
]) {
306+
await check(
307+
`Gmail conversation handles ${name} 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)
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) => verify(reference, reference.accessMetadata),
337+
})
338+
if (expectedStatus) {
339+
await assert.rejects(
340+
reading,
341+
(error: unknown) =>
342+
error instanceof NativeSearchError && error.status === expectedStatus
343+
)
344+
return
345+
}
346+
const document = await reading
347+
assert.equal(document.id, 'anchor')
348+
assert.ok(document.content.includes('Synthetic anchor evidence.'))
349+
assert.ok(document.content.includes('Synthetic retained evidence.'))
350+
assert.equal(document.content.includes('Synthetic reply evidence.'), status === 200)
351+
assert.equal(document.content.includes('Coverage incomplete:'), status === 404)
352+
assert.deepEqual(
353+
document.accessDependencies,
354+
status === 404 ? [{ id: 'retained' }] : [{ id: 'retained' }, { id: 'reply' }]
355+
)
356+
const current = createPolicyVerifier('gmail', policy, api, origin, undefined, {
357+
fresh: true,
358+
})
359+
assert.ok(await current({ id: document.id }))
360+
for (const dependency of document.accessDependencies ?? [])
361+
assert.ok(await current(dependency))
362+
} finally {
363+
mailbox.splice(before)
364+
messageFailures.clear()
365+
}
366+
}
367+
)
368+
}
287369
await check(
288370
'Gmail conversation and fresh selected-label checks fit one native request budget',
289371
async () => {

0 commit comments

Comments
 (0)