diff --git a/apps/docs/content/docs/search/gmail.mdx b/apps/docs/content/docs/search/gmail.mdx index 8945c941261..b4ce9e2e53f 100644 --- a/apps/docs/content/docs/search/gmail.mdx +++ b/apps/docs/content/docs/search/gmail.mdx @@ -36,7 +36,7 @@ Open **Settings → Sources** and turn on **Gmail**. This allows personal connec ### Connect your account -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. +Join the Sim organization, then 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. Return to Integrations to see indexing status and your searchable document count. @@ -52,6 +52,8 @@ Configurations are additive: a narrower one does not restrict an existing broade +Admins can request member connections from **Manage → Accounts → Request connections**. These requests do not invite recipients to the Sim organization. See [Connect your account](/search/connect-your-account) for the shared connection and recovery steps. + ## Set up a central service account Open **Settings → Sources**, enable **Gmail**, and select **Manage → Advanced → Add sync configuration**. If personal connections are disabled for your organization, select **Add source** from the provider page instead. @@ -100,16 +102,6 @@ Teammates join the Sim organization with their matching verified primary email. -## Connect your account - -These steps apply to **Member accounts**. A central service-account source does not require a personal Gmail connection. - -1. Join the Sim organization and verify your Sim email address. Open **Integrations** and click **Connect** beside Gmail. -2. Complete the connection in the tab that opens. Choose the Google account whose verified email matches your Sim email, and grant the requested permissions. -3. Return to Integrations. The source shows its indexing status and the number of documents you can search. - -Teammates follow these same steps after joining the organization. Once an admin allows Gmail, the first connection can create its source with default filters. Admins can edit those filters afterward or request connections from **Manage → Accounts → Request connections**. A connection request does not invite the recipient to the Sim organization. - ## Source options An admin opens **Settings → Sources** and selects **Manage** beside **Gmail** to open its configuration list. Each row shows **Member accounts** or **Service account** beside its sync status. Open a configuration's **Settings** tab to edit its filters, then select **Save**. Filters apply separately to each mailbox in the source. **Documents** shows indexed threads and **Sync history** shows recent runs. @@ -121,11 +113,11 @@ An admin opens **Settings → Sources** and selects **Manage** beside **Gmail** | 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 or central setup. | | Directory administrator email | Required for central indexing. An active Workspace administrator who can read Directory users; this does not limit the crawl to the administrator's mailbox. | | Users | Central indexing only. Optional primary Workspace email addresses (up to 100); blank includes all active users in the customer. This selects which mailboxes to crawl. Each mailbox remains searchable only by its owner. | -| 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. | +| Date Range | Last 6 months (180 days) by default. Other options are rolling windows of 7, 30, or 90 days, 1 year (365 days), or all time. | | Exclude Promotions / Exclude Social | Both enabled by default. Choose **No** to include either category. | | 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. Member-account sources with a search filter relist the mailbox on every sync instead of using Gmail's change history. | -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. +In the add-source form, **More options** also contains optional **Metadata tags**. ## What gets indexed @@ -137,13 +129,9 @@ Search schedules syncs hourly. The first sync lists every thread in scope and ca **Member accounts:** later syncs use each mailbox's Gmail change history, unless the configuration has a search filter. A full relisting runs about weekly, or sooner if Gmail no longer retains the saved history. -**Service account:** each sync revisits the selected active mailboxes and resumes unfinished listings. It does not reuse one mailbox's history cursor across the company. Only new or changed threads need their bodies fetched. Failed mailbox reads leave the crawl incomplete; they are not treated as an empty mailbox for deletion reconciliation. - -Updates, removals, and access refresh in the background, rather than being checked live for each search. - -An empty mailbox or filters with no matching threads complete normally with zero documents. +**Service account:** each sync revisits the selected active mailboxes and resumes unfinished listings. A failed mailbox read leaves the crawl incomplete; it does not cause existing indexed mail to be deleted from Search. -Threads that exceed indexing size limits are skipped and reconsidered when the thread changes. +Updates, removals, and access refresh in the background. Empty mailboxes and filters with no matches complete normally with zero documents. Threads exceeding indexing size limits are skipped and reconsidered when they change. ## Troubleshooting @@ -155,7 +143,7 @@ Threads that exceed indexing size limits are skipped and reconsidered when the t | Reconnect | Click **Reconnect** and authorize the same account again. | | Unavailable or needs admin attention | Ask your Sim admin to check source status and the deployment's Google OAuth configuration. | | Directory or delegation error | Check both central crawl scopes, the service-account key, and the Directory administrator's user-read privileges. A normal OAuth account cannot replace the central service account. | -| Gmail access fails for a selected user | Verify Gmail is enabled for that primary Workspace account and delegation is authorized. Narrow **Users** to accounts with Gmail enabled. Aliases and external accounts cannot be selected. | +| Gmail access fails for a selected user | Verify delegation is authorized and [Gmail is enabled](https://knowledge.workspace.google.com/admin/gmail/control-gmail-access-for-your-organizations-users) for that primary Workspace account. Set **Users** to accounts with Gmail enabled; leaving it blank includes all active users and can stop sync on a service-access error. Aliases and external accounts cannot be selected. | | A central source indexes mail but a teammate sees no results | Confirm their verified Sim email is the mailbox's primary email and they belong to the Sim organization. Administrators do not receive other people's mailbox access. | ## Self-hosted operator setup diff --git a/apps/docs/content/docs/search/google-calendar.mdx b/apps/docs/content/docs/search/google-calendar.mdx index 5435e41ebd8..37f427a369e 100644 --- a/apps/docs/content/docs/search/google-calendar.mdx +++ b/apps/docs/content/docs/search/google-calendar.mdx @@ -29,7 +29,7 @@ These are alternative setup paths. When only a central Calendar source is config ### Allow and connect Google Calendar -An admin opens **Settings → Sources** and turns on **Google Calendar**. Then each person opens **Integrations**, selects **Connect** beside Google Calendar, and authorizes their matching Google account. The first connection creates the default member-account sync configuration. +An admin opens **Settings → Sources** and turns on **Google Calendar**. Each person joins the Sim organization, opens **Integrations**, selects **Connect** beside Google Calendar, and authorizes the Google account matching their verified Sim email. The first connection creates the default member-account sync configuration. Return to Integrations to see indexing status and your searchable document count. @@ -50,6 +50,8 @@ The default date range covers the previous and next 30 days. Save any changes to +Admins can request member connections from **Manage → Accounts → Request connections**. These requests do not grant organization membership. Connecting Gmail or Drive does not replace the Calendar connection. See [Connect your account](/search/connect-your-account) for the shared connection and recovery steps. + `primary` means the connected or impersonated person's main calendar. A calendar selected from the list is a specific calendar ID, even when it is your main calendar. That same ID applies to every selected user, and only users with access to it can search its events. @@ -102,16 +104,6 @@ Sim verifies Directory access and selected users, then probes one selected user' -## Connect your account - -These steps apply to **Member accounts**. When only a central Calendar source is configured, teammates use Search or Home directly and are not offered a personal Calendar connection for that source. - -1. Join the Sim organization and verify your Sim email. Open **Integrations** and click **Connect** beside Google Calendar. -2. In the connection tab, choose the Google account whose verified email matches your Sim email. Grant the requested permissions. -3. Return to Integrations to see indexing status and your searchable document count. - -Teammates repeat only these connection steps after joining the organization. They do not need to configure the source. An admin can send connection requests from **Settings → Sources**: select **Manage** beside **Google Calendar**, then **Accounts → Request connections**. These requests do not grant organization membership. Connecting Gmail or Google Drive does not replace the Calendar connection. - ## Source options An admin opens **Settings → Sources** and selects **Manage** beside **Google Calendar** to open its configuration list. Each row shows **Member accounts** or **Service account** beside its sync status. Open a configuration's **Settings** tab to edit its filters, then select **Save**. **Documents** shows indexed events and **Sync history** shows recent runs. @@ -127,13 +119,13 @@ An admin opens **Settings → Sources** and selects **Manage** beside **Google C | Search Query | Optional text filter applied by Google to event titles, descriptions, locations, and organizer or attendee names and emails. Leave empty to include all matching events in the date range. | | Include Attendees | **Yes** by default. **No** omits organizer and attendee identity fields and keeps the attendee count. It does not redact names written into titles or descriptions. | -In the add-source form, **More options** contains optional **Metadata tags**. Search hides sync frequency and the general knowledge-base **Max Events** setting. +In the add-source form, **More options** also contains optional **Metadata tags**. ## What gets indexed -Sim indexes event titles, descriptions, times, locations, and the selected attendee information. All-day events and individual occurrences of recurring meetings are supported. An invitation you declined stays searchable and is marked `Response: declined`. Results link back to Google Calendar. +Sim indexes event titles, descriptions, times, locations, and the selected attendee information. All-day events and individual occurrences of recurring meetings are supported. Declined invitations returned by Google stay searchable and are marked `Response: declined`; hidden invitations are not requested. Results link back to Google Calendar. -Cancelled events, attachment contents, meeting recordings, and transcripts are not indexed. Status entries such as working location, out of office, focus time, and birthdays, and automatically generated reservation events from Gmail are not indexed. A shared calendar where you can see only free or busy times contributes nothing, since those blocks have no title or description. Events outside the selected date window are excluded. Private event details that Google withholds are not available in Search; see [Google's calendar sharing rules](https://developers.google.com/workspace/calendar/api/concepts/sharing). +Cancelled events, attachment contents, meeting recordings, and transcripts are not indexed. Status entries such as working location, out of office, focus time, and birthdays, and automatically generated reservation events from Gmail are not indexed. Events Google returns only as free/busy blocks, without searchable details, are not indexed. Events outside the selected date window are excluded. Private event details that Google withholds are not available in Search; see [Google's calendar sharing rules](https://developers.google.com/workspace/calendar/api/concepts/sharing). Search schedules syncs hourly. Event edits, cancellations, access changes, inactive or removed users, and events moving outside the date window are reconciled during completed background syncs. Central crawls page through each selected user and resume unfinished work before removing documents no longer listed. Authorization, quota, and provider failures stop the sync rather than treating unread calendars as empty. The first sync may take longer, and results appear as indexing progresses; Search is not a live Calendar read. @@ -148,6 +140,7 @@ Search schedules syncs hourly. Event edits, cancellations, access changes, inact | Reconnect | Click **Reconnect** and complete Google authorization again. Allow pop-ups if the connection tab does not open. | | Unavailable or needs admin attention | Ask your Sim admin to check source status and the deployment's Google OAuth configuration. | | Service-account authorization or Directory error | Confirm both delegated scopes, enabled APIs, and the Directory administrator's user-read privilege. Check whether delegation still awaits approval or propagation. | +| Calendar is disabled for a selected user | An active Workspace user may have Calendar turned off. [Enable Calendar](https://knowledge.workspace.google.com/admin/users/access/turn-calendar-on-or-off-for-users) for them, or set **Users** to accounts with Calendar enabled. Leaving **Users** blank includes all active users and can stop sync on a service-access error. | | User not found or inactive | Use an active primary email in the same Workspace customer. Aliases, external or guest accounts, suspended users, and archived users cannot be selected. | | A central source has no results for a teammate | Confirm their primary Workspace email matches their verified Sim email, they belong to the Sim organization, and they are included in **Users**. Check calendar IDs and **Sync history**. | diff --git a/apps/docs/content/docs/search/google-drive.mdx b/apps/docs/content/docs/search/google-drive.mdx index 8cde905b3e9..52ecfc4864b 100644 --- a/apps/docs/content/docs/search/google-drive.mdx +++ b/apps/docs/content/docs/search/google-drive.mdx @@ -21,7 +21,7 @@ Admin setup uses your organization's **Settings → Sources** page. Teammates co These are alternative setup paths. When only a central Drive source is configured, Integrations does not offer a personal Drive **Connect** action. Teammates use Search or Home directly. Existing member-account sources keep their connection actions. - A central crawl reads each selected employee's Drive through domain-wide delegation, including private My Drive files and shared-drive files they can access. Leave **Users** blank to include all active users in your Google Workspace customer, including secondary domains. Files keep their original user and group permissions; indexing a private file does not make it visible to other employees. + A central crawl includes each selected employee's private My Drive files and shared-drive files they can access. Files keep their original user and group permissions; indexing a private file does not make it visible to other employees. ## Connect member accounts @@ -38,7 +38,7 @@ An organization admin opens **Settings → Sources** and turns on **Google Drive ### Connect your account -Open **Integrations** in the main sidebar and select **Connect** beside Google Drive. Use the Google account matching your verified Sim email. The first personal connection can create a source with default filters. Teammates follow the same [connection steps](/search/connect-your-account). +Join the Sim organization, then open **Integrations** and select **Connect** beside Google Drive. Use the Google account matching your verified Sim email. The first personal connection can create a source with default filters. Return to Integrations to see indexing status and your searchable document count. @@ -52,6 +52,8 @@ Keep **Sync documents with → Connected members** unless a dedicated account sh +Admins can request member connections from **Manage → Accounts → Request connections**. These requests do not grant organization membership. See [Connect your account](/search/connect-your-account) for the shared connection and recovery steps. + ## Set up a central service account Open **Settings → Sources** and turn on **Google Drive**. Select **Manage → Advanced → Add sync configuration** to open the central service-account form directly. If personal connections are disabled for your organization, select **Add source** from the provider page instead. Teammates do not need a personal Drive connection for this source. @@ -80,8 +82,6 @@ Open the service account's **Keys** tab and choose **Add key → Create new key In the service account's **Details**, expand **Advanced settings** and copy its numeric **Client ID**. Sign in to the [Workspace Admin Console](https://admin.google.com/ac/owl/domainwidedelegation) as a super administrator. Open **Security → Access and data control → API controls → Manage Domain Wide Delegation → Add new**. -Google Workspace Admin Console Add a new client ID dialog with Client ID and OAuth scopes fields - Paste that Client ID into **Client ID**, then enter these exact scopes as a comma-separated list under **OAuth scopes**: ```text diff --git a/apps/docs/public/static/search/google-domain-delegation.png b/apps/docs/public/static/search/google-domain-delegation.png deleted file mode 100644 index b68680d0b82..00000000000 Binary files a/apps/docs/public/static/search/google-domain-delegation.png and /dev/null differ diff --git a/apps/sim/app/invite/[id]/invite.test.tsx b/apps/sim/app/invite/[id]/invite.test.tsx index 1a966c3f386..b04b3123473 100644 --- a/apps/sim/app/invite/[id]/invite.test.tsx +++ b/apps/sim/app/invite/[id]/invite.test.tsx @@ -276,6 +276,104 @@ afterEach(() => { }) describe('Invite', () => { + it.each([ + { status: 401, body: { error: 'Unauthorized' }, authRequired: true }, + { status: 403, body: { error: 'Forbidden' }, authRequired: true }, + { + status: 401, + code: 'UNRECOGNIZED_AUTH_CODE', + body: { error: 'Please authenticate' }, + authRequired: true, + }, + { status: 404, body: { error: 'Invitation not found' }, authRequired: false }, + ])('uses HTTP $status when the response has no known invitation code', async (response) => { + mockRequestJson.mockRejectedValue( + new ApiClientError({ ...response, message: 'Request failed' }) + ) + await renderInvite() + + if (response.authRequired) { + expect(container.textContent).toContain('Authentication Required') + expect(container.textContent).not.toContain('Invitation Error') + await clickAction('Sign in to continue') + expect(mockPush).toHaveBeenCalledWith( + `/login?invite_flow=true&callbackUrl=${encodeURIComponent('/invite/invitation-1?token=token-1')}` + ) + } else { + expect(container.textContent).toContain('This invitation is invalid or no longer exists.') + expect(actionLabels()).not.toContain('Try Again') + } + }) + + it.each([ + { code: 'disclosure-outdated', body: { error: 'Your workspaces changed' } }, + { code: undefined, body: { error: 'disclosure-outdated' } }, + ])('preserves a known invitation error before the HTTP fallback: %j', async (response) => { + mockRequestJson.mockRejectedValue( + new ApiClientError({ ...response, status: 409, message: 'Review the updated disclosure' }) + ) + await renderInvite() + + expect(container.textContent).toContain('Review the updated notice and accept again.') + expect(container.textContent).not.toContain('Already Part of a Team') + expect(actionLabels()).toContain('Try Again') + }) + + it('offers sign-in if the session expires while accepting an invitation', async () => { + await renderInvite() + mockRequestJson.mockRejectedValueOnce( + new ApiClientError({ status: 401, body: { error: 'Unauthorized' }, message: 'Unauthorized' }) + ) + await clickAction('Accept Invitation') + + expect(container.textContent).toContain('Authentication Required') + expect(container.textContent).not.toContain('Welcome!') + expect(actionLabels()).not.toContain('Accept Invitation') + await clickAction('Sign in to continue') + expect(mockPush).toHaveBeenCalledWith( + `/login?invite_flow=true&callbackUrl=${encodeURIComponent('/invite/invitation-1?token=token-1')}` + ) + }) + + it.each(['internal', 'external'] as const)( + 'offers an account switch to a token holder who is not the %s invitee', + async (intent) => { + membershipIntent = intent + joinPreview = null + mockUseSession.mockReturnValue({ + data: { user: { id: 'other-user', email: 'other@example.com' } }, + isPending: false, + }) + await renderInvite() + + expect(container.textContent).toContain('Wrong Account') + expect(container.textContent).not.toContain('We could not load how this invitation affects') + expect(actionLabels()).not.toContain('Accept Invitation') + expect(actionLabels()).not.toContain('Refresh invitation') + expect(mockRequestJson).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'POST' }), + expect.anything() + ) + await clickAction('Sign in with a different account') + expect(mockSignOut).toHaveBeenCalledOnce() + expect(mockClearUserData).toHaveBeenCalledOnce() + expect(mockPush).toHaveBeenCalledWith( + `/login?invite_flow=true&callbackUrl=${encodeURIComponent('/invite/invitation-1?token=token-1')}` + ) + } + ) + + it('matches the invitation email with the same normalization as the server', async () => { + mockUseSession.mockReturnValue({ + data: { user: { id: 'user-1', email: ' INVITEE@EXAMPLE.COM ' } }, + isPending: false, + }) + await renderInvite() + + expect(container.textContent).not.toContain('Wrong Account') + expect(actionLabels()).toContain('Accept Invitation') + }) + it('clears the previous account cache before navigating to the invitation sign-in', async () => { mockRequestJson.mockRejectedValue( new ApiClientError({ diff --git a/apps/sim/app/invite/[id]/invite.tsx b/apps/sim/app/invite/[id]/invite.tsx index 09fe4ed825d..288c3f19eec 100644 --- a/apps/sim/app/invite/[id]/invite.tsx +++ b/apps/sim/app/invite/[id]/invite.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { formatQuotedNameList } from '@sim/utils/string' +import { formatQuotedNameList, normalizeEmail } from '@sim/utils/string' import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter, useSearchParams } from 'next/navigation' import { ApiClientError } from '@/lib/api/client/errors' @@ -263,9 +263,11 @@ function codeFromStatus(status: number): InviteErrorCode { } function codeFromApiClientError(error: ApiClientError): string { + if (error.code && getInviteError(error.code).code !== 'unknown') return error.code + if (error.body && typeof error.body === 'object') { const code = (error.body as { error?: unknown }).error - if (typeof code === 'string' && code.length > 0) return code + if (typeof code === 'string' && getInviteError(code).code !== 'unknown') return code } return codeFromStatus(error.status) @@ -316,6 +318,11 @@ export default function Invite({ registrationDisabled }: InviteProps) { }) const invitation = invitationQuery.data?.invitation ?? null const joinPreview = invitationQuery.data?.joinPreview ?? null + const isWrongAccount = Boolean( + invitation && + session?.user && + normalizeEmail(session.user.email || '') !== normalizeEmail(invitation.email) + ) const isDisclosureMissing = invitation?.membershipIntent === 'internal' && !joinPreview const isLoading = Boolean(session?.user) && (!isTokenResolved || invitationQuery.isPending) @@ -330,10 +337,15 @@ export default function Invite({ registrationDisabled }: InviteProps) { * Action errors (accept failures) outrank fetch errors; the URL error param * only shows until the invitation loads successfully. */ - const error = actionError ?? fetchError ?? (invitationQuery.data ? null : urlError) + const error = + actionError ?? + fetchError ?? + (isWrongAccount ? getInviteError('email-mismatch') : null) ?? + (invitationQuery.data ? null : urlError) const handleAcceptInvitation = async () => { - if (!session?.user || !invitation || isDisclosureMissing || isAccepting) return + if (!session?.user || !invitation || isWrongAccount || isDisclosureMissing || isAccepting) + return setIsAccepting(true) try { diff --git a/apps/sim/connectors/gmail/gmail.test.ts b/apps/sim/connectors/gmail/gmail.test.ts index 0623b164f61..346021bb07b 100644 --- a/apps/sim/connectors/gmail/gmail.test.ts +++ b/apps/sim/connectors/gmail/gmail.test.ts @@ -110,7 +110,7 @@ describe('gmail listDocuments with maxThreads 0 (unlimited, a per-member sync)', describe('Gmail listing checkpoints', () => { it('keeps a resumed query fixed when a relative date range crosses midnight', async () => { vi.useFakeTimers() - vi.setSystemTime(new Date(2026, 8, 1, 23, 59, 59)) + vi.setSystemTime(new Date('2026-09-01T23:59:59Z')) const urls = mockPages([ { threads: [{ id: 'thread-1', historyId: '10' }], nextPageToken: 'page-2' }, { threads: [{ id: 'thread-2', historyId: '20' }], nextPageToken: 'page-3' }, @@ -124,9 +124,9 @@ describe('Gmail listing checkpoints', () => { memberContext('alice') ) const initialQuery = new URL(urls[0]).searchParams.get('q') - expect(initialQuery).toContain('after:2026/08/25') + expect(initialQuery).toContain(`after:${Date.parse('2026-08-25T23:59:59Z') / 1000}`) - vi.setSystemTime(new Date(2026, 8, 2, 0, 0, 1)) + vi.setSystemTime(new Date('2026-09-02T00:00:01Z')) const resumed = await gmailConnector.listDocuments( 'token', sourceConfig, @@ -141,7 +141,9 @@ describe('Gmail listing checkpoints', () => { }) await gmailConnector.listDocuments('token', sourceConfig, undefined, memberContext('alice')) - expect(new URL(urls[2]).searchParams.get('q')).toContain('after:2026/08/26') + expect(new URL(urls[2]).searchParams.get('q')).toContain( + `after:${Date.parse('2026-08-26T00:00:01Z') / 1000}` + ) }) it('resumes with the saved label query rather than resolving a renamed label again', async () => { @@ -181,6 +183,19 @@ describe('Gmail listing checkpoints', () => { expect(new URL(urls[1]).searchParams.get('pageToken')).toBe('page-2') }) + it('replays an existing date-based checkpoint without changing its provider query', async () => { + const urls = mockPages([{ threads: [] }]) + const searchQuery = 'after:2026/08/25 -category:promotions -category:social' + await gmailConnector.listDocuments( + 'token', + { dateRange: '7d' }, + JSON.stringify({ pageToken: 'saved-page', searchQuery }), + memberContext('alice') + ) + expect(new URL(urls[0]).searchParams.get('q')).toBe(searchQuery) + expect(new URL(urls[0]).searchParams.get('pageToken')).toBe('saved-page') + }) + it('still accepts a legacy raw page token and upgrades the next checkpoint', async () => { const urls = mockPages([{ threads: [], nextPageToken: 'page-3' }]) const result = await gmailConnector.listDocuments('token', {}, 'page-2') @@ -1307,6 +1322,107 @@ describe('Gmail change feed', () => { }) }) + it('uses the same timezone-independent cutoff for full listings and history', async () => { + vi.setSystemTime(new Date('2026-09-10T12:00:00.987Z')) + const cutoff = new Date('2026-09-03T12:00:00Z').getTime() + const config = { dateRange: '7d', maxThreads: 0 } + const urls = mockPages([{ threads: [{ id: 'recent', historyId: '77' }] }]) + const listing = await gmailConnector.listDocuments( + 'token', + config, + undefined, + memberContext('member-a') + ) + expect(new URL(urls[0]).searchParams.get('q')).toContain(`after:${cutoff / 1000}`) + + mockFeed([historyPage(['earlier-that-day', 'recent'])], { + 'earlier-that-day': metadataThread('earlier-that-day', [ + { labelIds: ['INBOX'], internalDate: String(cutoff - 2 * 60 * 60 * 1000) }, + ]), + recent: metadataThread('recent', [ + { labelIds: ['INBOX'], internalDate: String(cutoff + 1000) }, + ]), + }) + const history = await gmailConnector.listChanges!( + 'token', + config, + '500', + memberContext('member-a') + ) + expect( + history.changes.filter(({ kind }) => kind === 'upsert').map(({ externalId }) => externalId) + ).toEqual(listing.documents.map(({ externalId }) => externalId)) + expect(history.changes).toContainEqual({ + kind: 'removed', + externalId: 'member:member-a:earlier-that-day', + }) + }) + + it('keeps a missing member label empty when moving from a full listing to history', async () => { + const config = { label: 'Engineering', maxThreads: 0 } + mockFetchWithRetry + .mockResolvedValueOnce(Response.json({ labels: [{ id: 'INBOX', name: 'INBOX' }] })) + .mockResolvedValueOnce(Response.json({ threads: [] })) + const listing = await gmailConnector.listDocuments( + 'token', + config, + undefined, + memberContext('member-a') + ) + expect(listing.documents).toEqual([]) + expect(listing.hasMore).toBe(false) + + mockFeed([historyPage(['unlabelled'])], { + unlabelled: metadataThread('unlabelled', [{ labelIds: ['INBOX'] }]), + }) + const page = await gmailConnector.listChanges!( + 'token', + config, + '500', + memberContext('member-a') + ) + expect(page.changes).toEqual([{ kind: 'removed', externalId: 'member:member-a:unlabelled' }]) + expect(JSON.parse(page.nextCursor)).toEqual({ historyId: '900' }) + expect(page.hasMore).toBe(false) + }) + + it('matches existing labels in an OR filter even when another label is absent', async () => { + mockFeed( + [historyPage(['match', 'unlabelled'])], + { + match: metadataThread('match', [{ labelIds: ['Label_7'] }]), + unlabelled: metadataThread('unlabelled', [{ labelIds: ['INBOX'] }]), + }, + [{ id: 'Label_7', name: 'Engineering' }] + ) + const page = await gmailConnector.listChanges!( + 'token', + { label: ['Engineering', 'Missing label'] }, + '500', + memberContext('member-a') + ) + expect(page.changes.map(({ kind, externalId }) => ({ kind, externalId }))).toEqual([ + { kind: 'upsert', externalId: 'member:member-a:match' }, + { kind: 'removed', externalId: 'member:member-a:unlabelled' }, + ]) + }) + + it.each([403, 503])( + 'does not treat a failed label lookup (%i) as an empty history scope', + async (status) => { + mockFetchWithRetry.mockResolvedValueOnce(new Response(null, { status })) + await expect( + gmailConnector.listChanges!( + 'token', + { label: 'Engineering' }, + '500', + memberContext('member-a') + ) + ).rejects.toThrow('cannot resolve the configured label filter') + expect(mockFetchWithRetry).toHaveBeenCalledOnce() + } + ) + it('keeps the start history id while paging and advances it once the feed drains', async () => { const requests = mockFeed( [ diff --git a/apps/sim/connectors/gmail/gmail.ts b/apps/sim/connectors/gmail/gmail.ts index 1f65f4354fe..c85f7df81c0 100644 --- a/apps/sim/connectors/gmail/gmail.ts +++ b/apps/sim/connectors/gmail/gmail.ts @@ -308,7 +308,7 @@ function buildSearchQuery( } const after = dateRangeStart(sourceConfig, new Date()) - if (after) parts.push(`after:${formatGmailDate(after)}`) + if (after) parts.push(`after:${after.getTime() / 1000}`) const excludePromotions = sourceConfig.excludePromotions !== 'false' if (excludePromotions) { @@ -350,21 +350,12 @@ function isBoundedDateRange(value: unknown): value is keyof typeof DATE_RANGE_DA return typeof value === 'string' && Object.hasOwn(DATE_RANGE_DAYS, value) } -/** The earliest message date the configured range admits, or undefined for all time. */ +/** Uses second precision so Gmail's epoch query and local history filtering share one cutoff. */ function dateRangeStart(sourceConfig: Record, now: Date): Date | undefined { const range = sourceConfig.dateRange - return isBoundedDateRange(range) ? daysAgo(now, DATE_RANGE_DAYS[range]) : undefined -} - -function daysAgo(now: Date, days: number): Date { - return new Date(now.getTime() - days * 24 * 60 * 60 * 1000) -} - -function formatGmailDate(date: Date): string { - const y = date.getFullYear() - const m = String(date.getMonth() + 1).padStart(2, '0') - const d = String(date.getDate()).padStart(2, '0') - return `${y}/${m}/${d}` + if (!isBoundedDateRange(range)) return undefined + const cutoffSeconds = Math.floor(now.getTime() / 1000) - DATE_RANGE_DAYS[range] * 24 * 60 * 60 + return new Date(cutoffSeconds * 1000) } /** @@ -766,8 +757,7 @@ function buildChangeScope( const labelIds = new Set() for (const value of configuredLabels) { const id = labelIndex.byId[value] ? value : labelIndex.idByLowerName[value.toLowerCase()] - if (!id) throw new Error(`Gmail label "${value}" does not exist in this mailbox`) - labelIds.add(id) + if (id) labelIds.add(id) } scope.labelIds = labelIds } diff --git a/apps/sim/hooks/queries/kb/connectors-cache.test.tsx b/apps/sim/hooks/queries/kb/connectors-cache.test.tsx index 7bce795ad62..89ee868601c 100644 --- a/apps/sim/hooks/queries/kb/connectors-cache.test.tsx +++ b/apps/sim/hooks/queries/kb/connectors-cache.test.tsx @@ -13,6 +13,16 @@ const mocks = vi.hoisted(() => ({ requestJson: vi.fn() })) vi.mock('@/lib/api/client/request', () => ({ requestJson: mocks.requestJson })) import { + type ConnectorDetailData, + getKnowledgeConnectorContract, + listKnowledgeConnectorsContract, + triggerKnowledgeConnectorSyncContract, +} from '@/lib/api/contracts/knowledge/connectors' +import { + CONNECTOR_SYNC_POLL_INTERVAL_MS, + connectorKeys, + useConnectorDetail, + useConnectorList, useConnectSimSearchConnector, useCreateConnector, useDeleteConnector, @@ -20,6 +30,7 @@ import { usePrepareSearchSource, useRestoreConnectorDocument, useStartConnectorMemberEnrollment, + useTriggerSync, useUpdateConnector, useUpdateConnectorAccess, } from '@/hooks/queries/kb/connectors' @@ -123,6 +134,117 @@ afterEach(() => { for (const root of mountedRoots.splice(0)) root.unmount() }) for (const queryClient of queryClients.splice(0)) queryClient.clear() + vi.useRealTimers() +}) + +describe('manual sync history reconciliation', () => { + it.each([false, true])( + 'resumes polling after an early idle response with connector list mounted=%s', + async (showList) => { + vi.useFakeTimers() + const client = createQueryClient() + const detailKey = connectorKeys.detail(KNOWLEDGE_BASE_ID, CONNECTOR_ID) + let serverDetail: ConnectorDetailData = { + id: CONNECTOR_ID, + knowledgeBaseId: KNOWLEDGE_BASE_ID, + connectorType: 'google_drive', + credentialId: 'credential-1', + sourceConfig: {}, + syncMode: 'full', + syncIntervalMinutes: 60, + status: 'active', + lastSyncAt: null, + lastSyncError: null, + lastSyncDocCount: 0, + nextSyncAt: null, + consecutiveFailures: 0, + accessMode: 'admin', + viewerMembership: null, + credentialGroupId: null, + credentialGroupOptionId: null, + memberSyncStatus: 'idle', + lastMemberSyncAt: null, + nextMemberSyncAt: null, + lastMemberSyncError: null, + memberSyncConsecutiveFailures: 0, + accessRewritePending: false, + createdAt: '2026-09-09T00:00:00Z', + updatedAt: '2026-09-09T00:00:00Z', + syncLogs: [], + memberSyncLogs: [], + members: { active: 0, suspended: 0, stale: 0 }, + } + client.setQueryData(detailKey, serverDetail) + client.setQueryData(connectorKeys.lists(KNOWLEDGE_BASE_ID), [serverDetail]) + const trigger = Promise.withResolvers() + mocks.requestJson.mockImplementation(async (contract) => { + if (contract === triggerKnowledgeConnectorSyncContract) return trigger.promise + if (contract === getKnowledgeConnectorContract) return { data: serverDetail } + if (contract === listKnowledgeConnectorsContract) return { data: [serverDetail] } + throw new Error('Unexpected request') + }) + const current = renderMutation(client, () => ({ + detail: useConnectorDetail(KNOWLEDGE_BASE_ID, CONNECTOR_ID), + list: useConnectorList(showList ? KNOWLEDGE_BASE_ID : undefined), + sync: useTriggerSync(), + })) + let mutation: Promise | undefined + await act(async () => { + mutation = current().sync.mutateAsync({ + knowledgeBaseId: KNOWLEDGE_BASE_ID, + connectorId: CONNECTOR_ID, + }) + await vi.advanceTimersByTimeAsync(0) + }) + expect(client.getQueryData(detailKey)?.status).toBe('pending') + + await act(async () => { + await vi.advanceTimersByTimeAsync(CONNECTOR_SYNC_POLL_INTERVAL_MS + 1) + }) + expect(current().detail.data?.status).toBe('active') + expect(current().sync.isPending).toBe(true) + serverDetail = { ...serverDetail, status: 'pending' } + await act(async () => { + trigger.resolve({ success: true }) + await mutation + await vi.advanceTimersByTimeAsync(1) + }) + expect(current().detail.data?.status).toBe('pending') + if (showList) expect(current().list.data?.[0].status).toBe('pending') + + serverDetail = { + ...serverDetail, + status: 'active', + syncLogs: [ + { + id: 'new-run', + connectorId: CONNECTOR_ID, + status: 'completed', + startedAt: '2026-09-10T00:00:00Z', + completedAt: '2026-09-10T00:01:00Z', + docsAdded: 0, + docsUpdated: 0, + docsDeleted: 0, + docsUnchanged: 3, + docsSkipped: 0, + docsFailed: 0, + errorMessage: null, + }, + ], + } + await act(async () => { + await vi.advanceTimersByTimeAsync(CONNECTOR_SYNC_POLL_INTERVAL_MS + 1) + }) + expect(current().detail.data?.syncLogs.map((log) => log.id)).toEqual(['new-run']) + expect(current().detail.data?.status).toBe('active') + const requestsAtCompletion = mocks.requestJson.mock.calls.length + await act(async () => { + await vi.advanceTimersByTimeAsync(CONNECTOR_SYNC_POLL_INTERVAL_MS * 2) + }) + expect(mocks.requestJson).toHaveBeenCalledTimes(requestsAtCompletion) + expectInvalidated(client, UNRELATED_KEY, false) + } + ) }) describe('connector account cache reconciliation', () => { diff --git a/apps/sim/hooks/queries/kb/connectors.test.ts b/apps/sim/hooks/queries/kb/connectors.test.ts index dac82a534f6..457a1c648fe 100644 --- a/apps/sim/hooks/queries/kb/connectors.test.ts +++ b/apps/sim/hooks/queries/kb/connectors.test.ts @@ -211,7 +211,11 @@ describe('useTriggerSync optimistic state', () => { function capturedMutationOptions() { return mocks.useMutation.mock.calls.at(-1)?.[0] as { onMutate: (vars: { knowledgeBaseId: string; connectorId: string }) => Promise - onSettled: () => Promise + onSettled: ( + data: undefined, + error: Error | null, + vars: { knowledgeBaseId: string; connectorId: string } + ) => Promise onSuccess: (data: undefined, vars: { knowledgeBaseId: string; connectorId: string }) => void onError: ( error: unknown, @@ -297,7 +301,10 @@ describe('useTriggerSync optimistic state', () => { it('reconciles server source summaries after either sync outcome', async () => { useTriggerSync() - await capturedMutationOptions().onSettled() + await capturedMutationOptions().onSettled(undefined, null, { + knowledgeBaseId: KB_ID, + connectorId: 'connector-1', + }) expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: searchSourceKeys.lists() }) }) diff --git a/apps/sim/hooks/queries/kb/connectors.ts b/apps/sim/hooks/queries/kb/connectors.ts index 675d6e3e4f1..8c524b253fb 100644 --- a/apps/sim/hooks/queries/kb/connectors.ts +++ b/apps/sim/hooks/queries/kb/connectors.ts @@ -807,7 +807,18 @@ export function useTriggerSync() { queryKey: connectorKeys.progresses(knowledgeBaseId, connectorId), }) }, - onSettled: () => queryClient.invalidateQueries({ queryKey: searchSourceKeys.lists() }), + /** An early poll can read idle before dispatch marks pending; reconcile after the request settles. */ + onSettled: (_data, error, { knowledgeBaseId, connectorId }) => + Promise.all([ + queryClient.invalidateQueries({ queryKey: searchSourceKeys.lists() }), + queryClient.invalidateQueries({ + queryKey: connectorKeys.detail(knowledgeBaseId, connectorId), + exact: true, + }), + ...(!error + ? [queryClient.invalidateQueries({ queryKey: connectorKeys.lists(knowledgeBaseId) })] + : []), + ]), }) } diff --git a/apps/sim/lib/selectors/server/providers/google.test.ts b/apps/sim/lib/selectors/server/providers/google.test.ts index 318a68b1136..0f737ec42fa 100644 --- a/apps/sim/lib/selectors/server/providers/google.test.ts +++ b/apps/sim/lib/selectors/server/providers/google.test.ts @@ -150,6 +150,28 @@ describe('Google server selector adapters', () => { expect(mockFetch).toHaveBeenCalledTimes(2) }) + it.each([ + { files: [{ id: 'folder-1', name: 'Notes' }], nextPageToken: undefined }, + { files: [], nextPageToken: undefined }, + { files: [{ id: 'folder-1', name: 'Notes' }], nextPageToken: 'next' }, + ])('reports incomplete Drive searches without inventing pagination: %j', async (page) => { + mockFetch + .mockResolvedValueOnce(new Response(JSON.stringify({ drives: [] }))) + .mockResolvedValueOnce(new Response(JSON.stringify({ ...page, incompleteSearch: true }))) + const args = listArgs('google.drive') + args.context.mimeType = 'application/vnd.google-apps.folder' + + await expect(googleSelectorAttachments['google.drive'].execute(args)).resolves.toEqual({ + kind: 'list', + items: page.files.map((file) => ({ id: file.id, label: file.name })), + ...(page.nextPageToken ? { nextCursor: `f:${page.nextPageToken}` } : {}), + diagnostics: { truncated: { reason: 'provider-cap' } }, + }) + expect(mockFetch).toHaveBeenCalledTimes(2) + const fileUrl = new URL(String(mockFetch.mock.calls[1]?.[0])) + expect(fileUrl.searchParams.get('fields')).toContain('incompleteSearch') + }) + it('continues real folder pages after the final shared-drive page', async () => { mockFetch .mockResolvedValueOnce( diff --git a/apps/sim/lib/selectors/server/providers/google.ts b/apps/sim/lib/selectors/server/providers/google.ts index 8784433ffaa..37c92ecef0d 100644 --- a/apps/sim/lib/selectors/server/providers/google.ts +++ b/apps/sim/lib/selectors/server/providers/google.ts @@ -68,6 +68,7 @@ interface Sheet { interface GooglePage { items: T[] nextCursor?: string + truncated?: boolean } async function googleAccessToken(args: ExecuteServerSelectorArgs, serviceId: string) { @@ -297,9 +298,13 @@ async function listDriveFiles( url.searchParams.set('supportsAllDrives', 'true') url.searchParams.set('includeItemsFromAllDrives', 'true') url.searchParams.set('pageSize', '100') - url.searchParams.set('fields', 'nextPageToken,files(id,name,mimeType)') + url.searchParams.set('fields', 'nextPageToken,incompleteSearch,files(id,name,mimeType)') if (pageToken) url.searchParams.set('pageToken', pageToken) - const data = await fetchProviderJson<{ files?: DriveFile[]; nextPageToken?: string }>(url, { + const data = await fetchProviderJson<{ + files?: DriveFile[] + nextPageToken?: string + incompleteSearch?: boolean + }>(url, { headers: { Authorization: `Bearer ${accessToken}` }, signal: args.signal, }) @@ -308,6 +313,7 @@ async function listDriveFiles( return { items: [...sharedDrives, ...(data.files ?? [])], ...(nextPageToken ? { nextCursor: driveCursor('files', nextPageToken) } : {}), + ...(data.incompleteSearch === true ? { truncated: true } : {}), } } @@ -371,7 +377,8 @@ async function executeDrive(args: ExecuteServerSelectorArgs) { id: file.id, label: file.name, })), - result.nextCursor + result.nextCursor, + result.truncated ? { truncated: { reason: 'provider-cap' } } : undefined ) }