Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 23 additions & 13 deletions apps/sim/lib/sim-search/live/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,20 +435,30 @@ export async function readGmail(
for (const candidate of latest) {
options.signal.throwIfAborted()
const candidateId = string(candidate.id)
if (
!(await options.verify({
id: candidateId,
accessMetadata: { id: candidateId, labelIds: candidate.labelIds },
}))
) {
warnings.add('messages outside the source search scope were omitted')
continue
let row: Record<string, unknown>
try {
if (
!(await options.verify({
id: candidateId,
accessMetadata: { id: candidateId, labelIds: candidate.labelIds },
}))
) {
warnings.add('messages outside the source search scope were omitted')
continue
}
row = object(
await client.json(`/gmail/v1/users/me/messages/${segment(candidateId)}`, {
query: { format: 'full' },
})
)
} catch (error) {
options.signal.throwIfAborted()
if (error instanceof NativeSearchError && error.httpStatus === 404) {
Comment thread
waleedlatif1 marked this conversation as resolved.
warnings.add('some messages are no longer available')
continue
}
throw error
}
const row = object(
await client.json(`/gmail/v1/users/me/messages/${segment(candidateId)}`, {
query: { format: 'full' },
})
)
if (row.id !== candidateId || row.threadId !== threadId)
throw new NativeSearchError(
'unavailable',
Expand Down
84 changes: 84 additions & 0 deletions apps/sim/scripts/test-search-google-content-e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const mailbox: GmailMessage[] = Array.from({ length: 12 }, (_, index) => ({
body: { data: Buffer.from(`Synthetic evidence ${index}.`).toString('base64url') },
},
}))
const messageFailures = new Map<string, { status: number; format: string }>()
const mediaStarted = createDeferred<void>()
const mediaClosed = createDeferred<void>()
const server = http.createServer((request, response) => {
Expand All @@ -92,6 +93,14 @@ const server = http.createServer((request, response) => {
const row = mailbox.find((message) => message.id === id)
const isThread = url.pathname.startsWith('/gmail/v1/users/me/threads/')
const isAttachment = url.pathname.includes('/attachments/')
const failure = !isThread ? messageFailures.get(id) : undefined
if (failure && url.searchParams.get('format') === failure.format) {
requests.push({ id, resource: 'message', status: failure.status })
response
.writeHead(failure.status, { 'Content-Type': 'application/json' })
.end(JSON.stringify({ error: { code: failure.status } }))
return
}
const threadMessages = isThread ? mailbox.filter((message) => message.threadId === id) : []
const metadata = (message: (typeof mailbox)[number]) => ({
...message,
Expand Down Expand Up @@ -284,6 +293,81 @@ try {
server.listen(0, '127.0.0.1', () => started.resolve())
await started.promise
origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
for (const { name, failedId, status, expectedStatus } of [
{ name: 'healthy conversation', failedId: '', status: 200 },
{ name: 'missing sibling', failedId: 'reply', status: 404 },
{ name: 'missing anchor', failedId: 'anchor', status: 404, expectedStatus: 'unavailable' },
{ name: 'expired credentials', failedId: 'reply', status: 401, expectedStatus: 'reconnect' },
{ name: 'denied credentials', failedId: 'reply', status: 403, expectedStatus: 'reconnect' },
{ name: 'rate limit', failedId: 'reply', status: 429, expectedStatus: 'rate_limited' },
{ name: 'server failure', failedId: 'reply', status: 500, expectedStatus: 'unavailable' },
]) {
for (const format of failedId === 'reply' ? ['metadata', 'full'] : ['full']) {
await check(
`Gmail conversation handles ${name} during ${format} reads without hiding unrelated failures`,
async () => {
const before = mailbox.length
mailbox.push(
...['anchor', 'retained', 'reply'].map((id, index) => ({
id,
threadId: 'failure-conversation',
labelIds: ['INBOX'],
internalDate: String(Date.UTC(2026, 0, 1, 12, index)),
payload: {
mimeType: 'text/plain',
body: { data: Buffer.from(`Synthetic ${id} evidence.`).toString('base64url') },
},
}))
)
if (failedId) messageFailures.set(failedId, { status, format })
try {
const signal = AbortSignal.timeout(5000)
const api = createNativeClient({ origin, accessToken: TOKEN, signal })
const policy = {
...defaultLiveSearchPolicy('gmail'),
mode: 'selected' as const,
included: ['INBOX'],
}
const verify = createPolicyVerifier('gmail', policy, api, origin)
assert.ok(await verify({ id: 'anchor' }))
const reading = readGmail(api, 'anchor', {
policy,
signal,
verify: (reference) =>
verify(reference, format === 'full' ? reference.accessMetadata : undefined),
})
if (expectedStatus) {
await assert.rejects(
reading,
(error: unknown) =>
error instanceof NativeSearchError && error.status === expectedStatus
)
return
}
const document = await reading
assert.equal(document.id, 'anchor')
assert.ok(document.content.includes('Synthetic anchor evidence.'))
assert.ok(document.content.includes('Synthetic retained evidence.'))
assert.equal(document.content.includes('Synthetic reply evidence.'), status === 200)
assert.equal(document.content.includes('Coverage incomplete:'), status === 404)
assert.deepEqual(
document.accessDependencies,
status === 404 ? [{ id: 'retained' }] : [{ id: 'retained' }, { id: 'reply' }]
)
const current = createPolicyVerifier('gmail', policy, api, origin, undefined, {
fresh: true,
})
assert.ok(await current({ id: document.id }))
for (const dependency of document.accessDependencies ?? [])
assert.ok(await current(dependency))
} finally {
mailbox.splice(before)
messageFailures.clear()
}
}
)
}
}
await check(
'Gmail conversation and fresh selected-label checks fit one native request budget',
async () => {
Expand Down
Loading