Skip to content

Commit 32d98f4

Browse files
authored
fix(gmail): open search results in the indexed mailbox (#7910)
1 parent 25a2136 commit 32d98f4

7 files changed

Lines changed: 255 additions & 41 deletions

File tree

‎apps/sim/connectors/gmail/company-crawl.test.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,11 @@ function providerResponse(url: string, init?: RequestInit): Response {
6464
const parsed = new URL(url)
6565
const token = new Headers(init?.headers).get('Authorization')
6666
const mailbox = token?.includes(BOB.email) ? 'Bob' : 'Alice'
67-
if (parsed.pathname.endsWith('/profile')) return Response.json({ emailAddress: ALICE.email })
67+
if (parsed.pathname.endsWith('/profile'))
68+
return Response.json({
69+
emailAddress: mailbox === 'Bob' ? BOB.email : ALICE.email,
70+
historyId: '100',
71+
})
6872
if (parsed.pathname.endsWith('/labels')) {
6973
return Response.json({ labels: [{ id: 'Label_7', name: `${mailbox} label` }] })
7074
}
@@ -234,6 +238,7 @@ describe('company-wide Gmail indexing', () => {
234238
const context = centralContext()
235239
const first = await gmailConnector.listDocuments('directory-token', CONFIG, undefined, context)
236240
const alice = first.documents[0]
241+
expect(new URL(alice.sourceUrl!).searchParams.get('Email')).toBe(ALICE.email)
237242
const aliceBody = await gmailConnector.getDocument(
238243
'directory-token',
239244
CONFIG,
@@ -247,6 +252,7 @@ describe('company-wide Gmail indexing', () => {
247252
context
248253
)
249254
const bob = second.documents[0]
255+
expect(new URL(bob.sourceUrl!).searchParams.get('Email')).toBe(BOB.email)
250256
const bobBody = await gmailConnector.getDocument(
251257
'directory-token',
252258
CONFIG,
@@ -322,7 +328,10 @@ describe('company-wide Gmail indexing', () => {
322328
resumedContext
323329
)
324330
expect(resumed.documents).toEqual(first.documents)
325-
expect(new URL(fetchProvider.mock.calls[1][0]).searchParams.get('q')).toBe(firstQuery)
331+
const listings = fetchProvider.mock.calls.filter(([url]) =>
332+
new URL(url).pathname.endsWith('/threads')
333+
)
334+
expect(new URL(listings[1][0]).searchParams.get('q')).toBe(firstQuery)
326335
const body = await gmailConnector.getDocument(
327336
'directory-token',
328337
CONFIG,

‎apps/sim/connectors/gmail/gmail.test.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ vi.mock('@/lib/knowledge/documents/secure-fetch.server', () => ({
1717
? options.fetcher(url, init, mockFetchWithRetry)
1818
: mockFetchWithRetry(url, init),
1919
}))
20+
vi.mock('@/connectors/gmail/mailbox', async (importOriginal) => ({
21+
...(await importOriginal<typeof import('@/connectors/gmail/mailbox')>()),
22+
getGmailMailboxEmail: vi.fn(async (token: string) =>
23+
token === 'bob-token' ? 'bob@example.com' : 'alice@example.com'
24+
),
25+
}))
2026
vi.mock('@/components/icons', () => ({ GmailIcon: () => null }))
2127
vi.mock('@/lib/knowledge/documents/service', () => ({
2228
isTriggerAvailable: () => false,
@@ -789,7 +795,8 @@ describe('Gmail Search member isolation', () => {
789795
externalId: 'member:alice:thread-1',
790796
contentHash: 'gmail:thread-1:10:body-v2',
791797
contentDeferred: false,
792-
sourceUrl: 'https://mail.google.com/mail/u/0/#all/thread-1',
798+
sourceUrl:
799+
'https://accounts.google.com/AccountChooser?Email=alice%40example.com&continue=https%3A%2F%2Fmail.google.com%2Fmail%2F%3Fauthuser%3Dalice%2540example.com%23all%2Fthread-1',
793800
})
794801
expect(document?.content).toContain('Private mailbox content')
795802
expect(mockFetchWithRetry.mock.calls[0][0]).toContain('/threads/thread-1?format=full')
@@ -1221,7 +1228,8 @@ describe('Gmail change feed', () => {
12211228
mockFetchWithRetry.mockImplementation(async (url: string) => {
12221229
const parsed = new URL(url)
12231230
requests.push(parsed)
1224-
if (parsed.pathname.endsWith('/profile')) return Response.json({ historyId: '500' })
1231+
if (parsed.pathname.endsWith('/profile'))
1232+
return Response.json({ emailAddress: 'alice@example.com', historyId: '500' })
12251233
if (parsed.pathname.endsWith('/labels')) return Response.json({ labels })
12261234
if (parsed.pathname.endsWith('/history')) {
12271235
return Response.json(pages[historyCall++] ?? historyPage([]))

‎apps/sim/connectors/gmail/gmail.ts‎

Lines changed: 22 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { isPlainRecord } from '@sim/utils/object'
44
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
55
import { isPayloadSizeLimitError, readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
66
import { VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
7+
import { getGmailMailboxEmail, getGmailProfile, gmailThreadUrl } from '@/connectors/gmail/mailbox'
78
import { DEFAULT_MAX_THREADS, gmailConnectorMeta } from '@/connectors/gmail/meta'
89
import { fetchGoogleApiWithRetry, GoogleApiError } from '@/connectors/google-workspace/api-errors'
910
import {
@@ -621,34 +622,27 @@ async function resolveLabelNames(
621622
* Creates a lightweight document stub from a thread list entry.
622623
* Uses metadata-based contentHash for change detection without downloading content.
623624
*/
624-
function threadToStub(
625+
async function threadToStub(
625626
thread: GmailThread,
627+
accessToken: string,
626628
syncContext?: Record<string, unknown>
627-
): ExternalDocument {
629+
): Promise<ExternalDocument> {
630+
const mailboxEmail = await getGmailMailboxEmail(accessToken, syncContext)
628631
return {
629632
externalId: memberDocumentId(thread.id, syncContext),
630633
title: thread.snippet || 'Untitled Thread',
631634
content: '',
632635
contentDeferred: true,
633636
estimatedBytes: CONNECTOR_TEXT_DOCUMENT_MAX_BYTES,
634637
mimeType: 'text/plain',
635-
sourceUrl: threadUrl(thread.id),
638+
sourceUrl: gmailThreadUrl(thread.id, mailboxEmail),
636639
/** Rehydrate older rows that omitted separately stored message bodies. */
637640
contentHash: `gmail:${thread.id}:${thread.historyId}:body-v2`,
638641
skippedRetryPolicy: 'source-change',
639642
metadata: {},
640643
}
641644
}
642645

643-
/**
644-
* Deep link to a thread. `#all` is used rather than `#inbox` because a synced
645-
* thread may be archived or live only under a user label, where an `#inbox`
646-
* fragment resolves to nothing.
647-
*/
648-
function threadUrl(threadId: string): string {
649-
return `https://mail.google.com/mail/u/0/#all/${threadId}`
650-
}
651-
652646
/** A feed position: the mailbox history id the next read starts from, mid-page when paging. */
653647
interface GmailChangeCursor {
654648
historyId: string
@@ -780,22 +774,7 @@ const gmailMailboxConnector: ConnectorConfig = {
780774
if (syncContext?.mirrorsSourceAcls === true) {
781775
throw new Error('Company-wide Gmail indexing uses complete mailbox listings')
782776
}
783-
const response = await fetchGoogleApiWithRetry(
784-
'gmail.users.getProfile',
785-
`${GMAIL_API_BASE}/profile?fields=historyId`,
786-
{
787-
method: 'GET',
788-
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
789-
}
790-
)
791-
const data: unknown = await response.json()
792-
if (
793-
!isPlainRecord(data) ||
794-
typeof data.historyId !== 'string' ||
795-
!/^\d+$/.test(data.historyId)
796-
) {
797-
throw new Error('Gmail returned malformed profile metadata')
798-
}
777+
const data = await getGmailProfile(accessToken, syncContext)
799778
return JSON.stringify({ historyId: data.historyId })
800779
},
801780

@@ -813,7 +792,7 @@ const gmailMailboxConnector: ConnectorConfig = {
813792
accessToken: string,
814793
sourceConfig: Record<string, unknown>,
815794
cursor: string,
816-
syncContext?: Record<string, unknown>
795+
syncContext: Record<string, unknown> = {}
817796
): Promise<ExternalChangeList> => {
818797
if (syncContext?.mirrorsSourceAcls === true) {
819798
throw new Error('Company-wide Gmail indexing uses complete mailbox listings')
@@ -854,7 +833,11 @@ const gmailMailboxConnector: ConnectorConfig = {
854833
const externalId = memberDocumentId(threadId, syncContext)
855834
const thread = await fetchThread(accessToken, threadId, 'metadata')
856835
if (!thread || !threadInScope(thread, scope)) return { kind: 'removed', externalId }
857-
return { kind: 'upsert', externalId, document: threadToStub(thread, syncContext) }
836+
return {
837+
kind: 'upsert',
838+
externalId,
839+
document: await threadToStub(thread, accessToken, syncContext),
840+
}
858841
}
859842
)
860843

@@ -873,7 +856,7 @@ const gmailMailboxConnector: ConnectorConfig = {
873856
accessToken: string,
874857
sourceConfig: Record<string, unknown>,
875858
cursor?: string,
876-
syncContext?: Record<string, unknown>
859+
syncContext: Record<string, unknown> = {}
877860
): Promise<ExternalDocumentList> => {
878861
const signal = syncContext?.signal instanceof AbortSignal ? syncContext.signal : undefined
879862
signal?.throwIfAborted()
@@ -962,7 +945,7 @@ const gmailMailboxConnector: ConnectorConfig = {
962945
const metadata = thread.historyId
963946
? thread
964947
: await fetchThread(accessToken, thread.id, 'minimal', signal)
965-
return metadata ? threadToStub(metadata, syncContext) : null
948+
return metadata ? threadToStub(metadata, accessToken, syncContext) : null
966949
})
967950
const documents = stubs.filter((stub): stub is ExternalDocument => stub !== null)
968951

@@ -1002,7 +985,7 @@ const gmailMailboxConnector: ConnectorConfig = {
1002985
accessToken: string,
1003986
_sourceConfig: Record<string, unknown>,
1004987
externalId: string,
1005-
syncContext?: Record<string, unknown>
988+
syncContext: Record<string, unknown> = {}
1006989
): Promise<ExternalDocument | null> => {
1007990
const signal = syncContext?.signal instanceof AbortSignal ? syncContext.signal : undefined
1008991
signal?.throwIfAborted()
@@ -1028,7 +1011,7 @@ const gmailMailboxConnector: ConnectorConfig = {
10281011
}
10291012
return {
10301013
...markSkipped(
1031-
threadToStub(after, syncContext),
1014+
await threadToStub(after, accessToken, syncContext),
10321015
sizeLimitSkipReason(MAX_THREAD_RESPONSE_BYTES)
10331016
),
10341017
skippedExistingDisposition: 'replace',
@@ -1043,7 +1026,10 @@ const gmailMailboxConnector: ConnectorConfig = {
10431026
} catch (error) {
10441027
if (error instanceof ConnectorFileTooLargeError) {
10451028
return {
1046-
...markSkipped(threadToStub(thread, syncContext), sizeLimitSkipReason(error.limitBytes)),
1029+
...markSkipped(
1030+
await threadToStub(thread, accessToken, syncContext),
1031+
sizeLimitSkipReason(error.limitBytes)
1032+
),
10471033
skippedExistingDisposition: 'replace',
10481034
}
10491035
}
@@ -1056,7 +1042,7 @@ const gmailMailboxConnector: ConnectorConfig = {
10561042
metadata.labels = await resolveLabelNames(accessToken, labelIds, syncContext)
10571043

10581044
return {
1059-
...threadToStub(thread, syncContext),
1045+
...(await threadToStub(thread, accessToken, syncContext)),
10601046
title: subject,
10611047
content,
10621048
contentDeferred: false,
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/** @vitest-environment node */
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
import { getGmailMailboxEmail, getGmailProfile, gmailThreadUrl } from '@/connectors/gmail/mailbox'
4+
5+
const { transport } = vi.hoisted(() => ({ transport: vi.fn<typeof fetch>() }))
6+
vi.mock('@/lib/knowledge/documents/secure-fetch.server', () => ({
7+
fetchWithRetry: (
8+
url: string,
9+
init: RequestInit,
10+
options: {
11+
fetcher: (url: string, init: RequestInit, transport: typeof fetch) => Promise<Response>
12+
}
13+
) => options.fetcher(url, init, transport),
14+
}))
15+
16+
beforeEach(() => {
17+
transport.mockReset()
18+
transport.mockResolvedValue(
19+
Response.json({ emailAddress: 'Alice+work@example.com', historyId: '123' })
20+
)
21+
})
22+
23+
describe('Gmail mailbox identity', () => {
24+
it('selects the authenticated mailbox before an archived-thread fragment', async () => {
25+
const email = await getGmailMailboxEmail('mailbox-token', {})
26+
const url = new URL(gmailThreadUrl('19a3f0123456789', email))
27+
expect(url.origin).toBe('https://accounts.google.com')
28+
expect(url.pathname).toBe('/AccountChooser')
29+
expect(url.searchParams.get('Email')).toBe('alice+work@example.com')
30+
expect(url.hash).toBe('')
31+
const destination = new URL(url.searchParams.get('continue')!)
32+
expect(destination.origin).toBe('https://mail.google.com')
33+
expect(destination.pathname).toBe('/mail/')
34+
expect(destination.searchParams.get('authuser')).toBe('alice+work@example.com')
35+
expect(destination.hash).toBe('#all/19a3f0123456789')
36+
expect(transport).toHaveBeenCalledWith(
37+
'https://gmail.googleapis.com/gmail/v1/users/me/profile?fields=emailAddress,historyId',
38+
expect.objectContaining({
39+
headers: expect.objectContaining({ Authorization: 'Bearer mailbox-token' }),
40+
})
41+
)
42+
})
43+
44+
it('deduplicates concurrent lookups and reuses the profile read for the history watermark', async () => {
45+
const context = {}
46+
expect(
47+
await Promise.all(Array.from({ length: 100 }, () => getGmailMailboxEmail('token', context)))
48+
).toEqual(Array(100).fill('alice+work@example.com'))
49+
expect(transport).toHaveBeenCalledTimes(1)
50+
transport.mockResolvedValue(
51+
Response.json({ emailAddress: 'alice+work@example.com', historyId: '456' })
52+
)
53+
expect((await getGmailProfile('token', context)).historyId).toBe('456')
54+
expect(await getGmailMailboxEmail('token', context)).toBe('alice+work@example.com')
55+
expect(transport).toHaveBeenCalledTimes(2)
56+
})
57+
58+
it('seeds mailbox identity from a fresh watermark without a second request', async () => {
59+
const context = {}
60+
await getGmailProfile('token', context)
61+
await getGmailMailboxEmail('token', context)
62+
expect(transport).toHaveBeenCalledTimes(1)
63+
})
64+
65+
it('isolates credentials even if the same context is mistakenly reused', async () => {
66+
const context = {}
67+
await getGmailMailboxEmail('alice-token', context)
68+
transport.mockImplementation(async () =>
69+
Response.json({ emailAddress: 'bob@example.com', historyId: '321' })
70+
)
71+
expect(await getGmailMailboxEmail('bob-token', context)).toBe('bob@example.com')
72+
await getGmailMailboxEmail('bob-token', {})
73+
expect(transport).toHaveBeenCalledTimes(3)
74+
})
75+
76+
it.each([
77+
{},
78+
{ emailAddress: '', historyId: '1' },
79+
{ emailAddress: 'not-an-email', historyId: '1' },
80+
{ emailAddress: 'alice@example.com', historyId: 123 },
81+
{ emailAddress: 'alice@example.com', historyId: 'invalid' },
82+
])('rejects malformed profiles instead of falling back to account zero: %j', async (body) => {
83+
transport.mockResolvedValue(Response.json(body))
84+
await expect(getGmailMailboxEmail('token', {})).rejects.toThrow('malformed profile')
85+
})
86+
87+
it('does not cache a failed lookup', async () => {
88+
transport.mockResolvedValueOnce(Response.json({}))
89+
const context = {}
90+
await expect(getGmailMailboxEmail('token', context)).rejects.toThrow()
91+
expect(await getGmailMailboxEmail('token', context)).toBe('alice+work@example.com')
92+
})
93+
94+
it('preserves provider authentication errors and cancellation', async () => {
95+
transport.mockResolvedValueOnce(new Response(null, { status: 401 }))
96+
await expect(getGmailMailboxEmail('invalid', {})).rejects.toMatchObject({ status: 401 })
97+
const controller = new AbortController()
98+
controller.abort()
99+
await expect(getGmailProfile('token', { signal: controller.signal })).rejects.toThrow()
100+
expect(transport).toHaveBeenCalledTimes(1)
101+
})
102+
103+
it('bounds profile responses', async () => {
104+
transport.mockResolvedValue(Response.json({ padding: 'x'.repeat(17 * 1024) }))
105+
await expect(getGmailMailboxEmail('token', {})).rejects.toThrow()
106+
})
107+
108+
it('encodes fragment delimiters rather than allowing them to change the link target', () => {
109+
const chooser = new URL(gmailThreadUrl('thread/?#id', 'alice@example.com'))
110+
expect(new URL(chooser.searchParams.get('continue')!).hash).toBe('#all/thread%2F%3F%23id')
111+
})
112+
})

0 commit comments

Comments
 (0)