Skip to content

Commit 1da2820

Browse files
waleedlatif1claude
andauthored
fix(search): bound personal Gmail sources and sync them from Gmail history (#7717)
* fix(search): bound personal Gmail sources and sync them from Gmail history A personal Gmail connection indexed the whole mailbox from all time, and members mode removes the thread cap by design (a capped listing cannot tell a thread that fell out of the window from one the person lost access to). Search sources now start from the connector's declared defaults, so Gmail indexes the last six months unless the source says otherwise. Explicit settings still win and knowledge-base connectors are unchanged. Every hourly Gmail sync relisted the entire mailbox because the connector had no change feed. It now opens a cursor at the mailbox history id and reads users.history.list, re-reading only threads that gained a message, were relabelled, or were deleted, and evaluates the configured labels, date range and category exclusions locally. A free-form search filter cannot be evaluated locally, so such a source keeps relisting. An expired history id reopens the feed from a full listing through the engine's existing cursor-invalid path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(search): assert the Gmail date-range table as const The lookup narrows an arbitrary config string through a type guard instead of indexing a widened record. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent dababea commit 1da2820

11 files changed

Lines changed: 639 additions & 31 deletions

File tree

apps/docs/content/docs/search/gmail.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Open **Settings → Sources** and turn on **Gmail**. Gmail uses member accounts;
2525

2626
### Connect your account
2727

28-
Open **Integrations** and select **Connect** beside Gmail. Authorize the Google account matching your verified Sim email. The first connection creates the default sync configuration: all dates and labels, excluding Promotions, Social, Spam, and Trash.
28+
Open **Integrations** and select **Connect** beside Gmail. Authorize the Google account matching your verified Sim email. The first connection creates the default sync configuration: the last 6 months across all labels, excluding Promotions, Social, Spam, and Trash.
2929

3030
</Step>
3131
<Step>
@@ -60,9 +60,9 @@ An admin opens **Settings → Sources**, selects **Manage** beside **Gmail**, op
6060
| Option | Behavior |
6161
| --- | --- |
6262
| Labels | Optional comma-separated names or system IDs, such as `Engineering, INBOX`. A thread matching any listed label is included. Leave empty for all labels. Custom IDs such as `Label_7` belong to one mailbox and cannot be used for member setup. |
63-
| Date Range | All time by default. Choose the last 7, 30, or 90 days, 6 months, or year. |
63+
| Date Range | Last 6 months by default for Search sources. Choose the last 7, 30, or 90 days, a year, or all time. A knowledge-base connector outside Search defaults to all time. |
6464
| Exclude Promotions / Exclude Social | Both enabled by default. Choose **No** to include either category. |
65-
| Search Filter | Optional [Gmail query](https://developers.google.com/workspace/gmail/api/guides/filtering), such as `from:team@example.com subject:release`. This filters what is indexed; it is not a Sim Search query. |
65+
| Search Filter | Optional [Gmail query](https://developers.google.com/workspace/gmail/api/guides/filtering), such as `from:team@example.com subject:release`. This filters what is indexed; it is not a Sim Search query. A source with a search filter cannot use Gmail's change history and relists the mailbox on every sync. |
6666

6767
In the add-source form, **More options** contains optional **Metadata tags**. Sync frequency and the general knowledge-base **Max Threads** setting are hidden in Search.
6868

@@ -72,7 +72,7 @@ Sim indexes the message text Gmail returns for each matching thread, plus subjec
7272

7373
File attachments and image contents are not indexed. Thread discovery uses Gmail's default exclusion of Spam and Trash. A filter such as `has:attachment` selects the email thread; it does not index the attachment. Gmail API filtering also differs from Gmail's interface for aliases and thread-wide searches. See Google's [thread listing reference](https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.threads/list) and [filtering guide](https://developers.google.com/workspace/gmail/api/guides/filtering).
7474

75-
Search schedules syncs hourly. The first sync and large mailboxes can take longer; results appear as documents are indexed. Updates and removals are reconciled during background sync, rather than fetched live for each search.
75+
Search schedules syncs hourly. The first sync lists every thread in scope and can take several runs for a large mailbox; results appear as documents are indexed. Later syncs read Gmail's change history instead of relisting the mailbox, so only threads that gained a message, were relabelled, or were deleted since the previous run are fetched. A full relisting runs about weekly, or sooner if Gmail no longer retains the history the source last read. Updates and removals are reconciled during background sync, rather than fetched live for each search.
7676

7777
An empty mailbox or filters with no matching threads complete normally with zero documents.
7878

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

Lines changed: 213 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ import {
2828
} from '@/lib/knowledge/connectors/sync-primitives'
2929
import { gmailConnector } from '@/connectors/gmail/gmail'
3030
import { DEFAULT_MAX_THREADS, gmailConnectorMeta } from '@/connectors/gmail/meta'
31-
import { CONNECTOR_TEXT_DOCUMENT_MAX_BYTES, PER_MEMBER_LISTING_CONTEXT } from '@/connectors/utils'
31+
import {
32+
CONNECTOR_TEXT_DOCUMENT_MAX_BYTES,
33+
memberDocumentId,
34+
PER_MEMBER_LISTING_CONTEXT,
35+
} from '@/connectors/utils'
3236

3337
function threads(count: number, prefix: string) {
3438
return Array.from({ length: count }, (_, i) => ({ id: `${prefix}-${i}`, historyId: '1' }))
@@ -1145,3 +1149,211 @@ describe('Gmail body and label extraction', () => {
11451149
).toHaveLength(1)
11461150
})
11471151
})
1152+
1153+
describe('Gmail change feed', () => {
1154+
function historyPage(
1155+
threadIds: string[],
1156+
options: { nextPageToken?: string; historyId?: string } = {}
1157+
) {
1158+
return {
1159+
historyId: options.historyId ?? '900',
1160+
nextPageToken: options.nextPageToken,
1161+
history: threadIds.map((threadId, i) => ({
1162+
id: String(100 + i),
1163+
messages: [{ id: `m-${threadId}`, threadId }],
1164+
})),
1165+
}
1166+
}
1167+
1168+
function metadataThread(
1169+
id: string,
1170+
messages: Array<{ labelIds: string[]; internalDate?: string }>,
1171+
historyId = '77'
1172+
) {
1173+
return {
1174+
id,
1175+
historyId,
1176+
snippet: `Snippet ${id}`,
1177+
messages: messages.map((message, i) => ({
1178+
id: `${id}-m${i}`,
1179+
threadId: id,
1180+
internalDate: message.internalDate ?? String(Date.now()),
1181+
labelIds: message.labelIds,
1182+
})),
1183+
}
1184+
}
1185+
1186+
/** Routes history, label and thread reads; a thread id missing from `threads` answers 404. */
1187+
function mockFeed(
1188+
pages: ReturnType<typeof historyPage>[],
1189+
threads: Record<string, ReturnType<typeof metadataThread>>,
1190+
labels: Array<{ id: string; name: string }> = [{ id: 'INBOX', name: 'INBOX' }]
1191+
) {
1192+
const requests: URL[] = []
1193+
let historyCall = 0
1194+
mockFetchWithRetry.mockImplementation(async (url: string) => {
1195+
const parsed = new URL(url)
1196+
requests.push(parsed)
1197+
if (parsed.pathname.endsWith('/profile')) return Response.json({ historyId: '500' })
1198+
if (parsed.pathname.endsWith('/labels')) return Response.json({ labels })
1199+
if (parsed.pathname.endsWith('/history')) {
1200+
return Response.json(pages[historyCall++] ?? historyPage([]))
1201+
}
1202+
const threadId = decodeURIComponent(parsed.pathname.split('/').at(-1) ?? '')
1203+
const thread = threads[threadId]
1204+
return thread ? Response.json(thread) : new Response('not found', { status: 404 })
1205+
})
1206+
return requests
1207+
}
1208+
1209+
beforeEach(() => {
1210+
vi.useFakeTimers()
1211+
vi.setSystemTime(new Date('2026-09-10T12:00:00Z'))
1212+
})
1213+
1214+
it('opens the feed at the mailbox history id from the profile', async () => {
1215+
const requests = mockFeed([], {})
1216+
const cursor = await gmailConnector.getChangeCursor!('token', {})
1217+
expect(JSON.parse(cursor)).toEqual({ historyId: '500' })
1218+
expect(requests.map((url) => url.pathname.split('/').at(-1))).toEqual(['profile'])
1219+
})
1220+
1221+
it('refuses the feed only when a free-form search filter is configured', () => {
1222+
expect(gmailConnector.supportsChangeFeed!({})).toBe(true)
1223+
expect(gmailConnector.supportsChangeFeed!({ label: 'INBOX', dateRange: '30d' })).toBe(true)
1224+
expect(gmailConnector.supportsChangeFeed!({ query: ' ' })).toBe(true)
1225+
expect(gmailConnector.supportsChangeFeed!({ query: 'from:boss@example.com' })).toBe(false)
1226+
})
1227+
1228+
it('upserts changed threads still in scope and removes trashed or deleted ones', async () => {
1229+
const requests = mockFeed([historyPage(['kept', 'trashed', 'gone'])], {
1230+
kept: metadataThread('kept', [{ labelIds: ['INBOX'] }]),
1231+
trashed: metadataThread('trashed', [{ labelIds: ['TRASH'] }, { labelIds: ['SPAM'] }]),
1232+
})
1233+
const syncContext = memberContext('member-a')
1234+
const page = await gmailConnector.listChanges!(
1235+
'token',
1236+
{},
1237+
JSON.stringify({ historyId: '500' }),
1238+
syncContext
1239+
)
1240+
1241+
expect(page.hasMore).toBe(false)
1242+
expect(JSON.parse(page.nextCursor)).toEqual({ historyId: '900' })
1243+
expect(page.changes).toHaveLength(3)
1244+
const kept = page.changes.find((change) => change.externalId.endsWith('kept'))
1245+
expect(kept?.kind).toBe('upsert')
1246+
if (kept?.kind !== 'upsert') throw new Error('expected an upsert')
1247+
expect(kept.document.externalId).toBe(memberDocumentId('kept', syncContext))
1248+
expect(kept.document.contentDeferred).toBe(true)
1249+
expect(kept.document.contentHash).toBe('gmail:kept:77:body-v2')
1250+
expect(
1251+
page.changes.filter((change) => change.kind === 'removed').map((c) => c.externalId)
1252+
).toEqual(
1253+
expect.arrayContaining([
1254+
memberDocumentId('trashed', syncContext),
1255+
memberDocumentId('gone', syncContext),
1256+
])
1257+
)
1258+
1259+
const history = requests.find((url) => url.pathname.endsWith('/history'))!
1260+
expect(history.searchParams.get('startHistoryId')).toBe('500')
1261+
expect(history.searchParams.getAll('historyTypes')).toEqual([
1262+
'messageAdded',
1263+
'messageDeleted',
1264+
'labelAdded',
1265+
'labelRemoved',
1266+
])
1267+
const threadReads = requests.filter((url) => /\/threads\/[^/]+$/.test(url.pathname))
1268+
expect(threadReads).toHaveLength(3)
1269+
for (const read of threadReads) {
1270+
expect(read.searchParams.get('format')).toBe('metadata')
1271+
expect(read.searchParams.get('fields')).not.toContain('payload')
1272+
}
1273+
})
1274+
1275+
it('applies the date range, label and category filters to each message', async () => {
1276+
const dayMs = 24 * 60 * 60 * 1000
1277+
const recent = String(Date.now() - 2 * dayMs)
1278+
const stale = String(Date.now() - 40 * dayMs)
1279+
mockFeed(
1280+
[historyPage(['old', 'promo', 'unlabelled', 'match'])],
1281+
{
1282+
old: metadataThread('old', [{ labelIds: ['Label_7'], internalDate: stale }]),
1283+
promo: metadataThread('promo', [
1284+
{ labelIds: ['Label_7', 'CATEGORY_PROMOTIONS'], internalDate: recent },
1285+
]),
1286+
unlabelled: metadataThread('unlabelled', [{ labelIds: ['INBOX'], internalDate: recent }]),
1287+
match: metadataThread('match', [
1288+
{ labelIds: ['INBOX'], internalDate: stale },
1289+
{ labelIds: ['Label_7'], internalDate: recent },
1290+
]),
1291+
},
1292+
[{ id: 'Label_7', name: 'Engineering' }]
1293+
)
1294+
const page = await gmailConnector.listChanges!(
1295+
'token',
1296+
{ label: 'Engineering', dateRange: '30d' },
1297+
JSON.stringify({ historyId: '500' })
1298+
)
1299+
const byId = Object.fromEntries(page.changes.map((change) => [change.externalId, change.kind]))
1300+
expect(byId).toEqual({
1301+
old: 'removed',
1302+
promo: 'removed',
1303+
unlabelled: 'removed',
1304+
match: 'upsert',
1305+
})
1306+
})
1307+
1308+
it('keeps the start history id while paging and advances it once the feed drains', async () => {
1309+
const requests = mockFeed(
1310+
[
1311+
historyPage(['a'], { nextPageToken: 'hp-2', historyId: '901' }),
1312+
historyPage(['b'], { historyId: '902' }),
1313+
],
1314+
{
1315+
a: metadataThread('a', [{ labelIds: ['INBOX'] }]),
1316+
b: metadataThread('b', [{ labelIds: ['INBOX'] }]),
1317+
}
1318+
)
1319+
const first = await gmailConnector.listChanges!('token', {}, '500')
1320+
expect(first.hasMore).toBe(true)
1321+
expect(JSON.parse(first.nextCursor)).toEqual({ historyId: '500', pageToken: 'hp-2' })
1322+
1323+
const second = await gmailConnector.listChanges!('token', {}, first.nextCursor)
1324+
expect(second.hasMore).toBe(false)
1325+
expect(JSON.parse(second.nextCursor)).toEqual({ historyId: '902' })
1326+
1327+
const historyReads = requests.filter((url) => url.pathname.endsWith('/history'))
1328+
expect(historyReads.map((url) => url.searchParams.get('startHistoryId'))).toEqual([
1329+
'500',
1330+
'500',
1331+
])
1332+
expect(historyReads.map((url) => url.searchParams.get('pageToken'))).toEqual([null, 'hp-2'])
1333+
})
1334+
1335+
it('reports an expired or malformed cursor so the engine reopens from a full listing', async () => {
1336+
mockFetchWithRetry.mockImplementation(
1337+
async () => new Response('history expired', { status: 404 })
1338+
)
1339+
const expired = await gmailConnector.listChanges!(
1340+
'token',
1341+
{},
1342+
JSON.stringify({ historyId: '1' })
1343+
).catch((error: unknown) => error)
1344+
expect(gmailConnector.isChangeCursorInvalidError!(expired)).toBe(true)
1345+
1346+
const malformed = await gmailConnector.listChanges!('token', {}, 'not-a-cursor').catch(
1347+
(error: unknown) => error
1348+
)
1349+
expect(gmailConnector.isChangeCursorInvalidError!(malformed)).toBe(true)
1350+
1351+
mockFetchWithRetry.mockImplementation(async () => new Response('boom', { status: 500 }))
1352+
const outage = await gmailConnector.listChanges!(
1353+
'token',
1354+
{},
1355+
JSON.stringify({ historyId: '1' })
1356+
).catch((error: unknown) => error)
1357+
expect(gmailConnector.isChangeCursorInvalidError!(outage)).toBe(false)
1358+
})
1359+
})

0 commit comments

Comments
 (0)