From 1ffd23764c4180d56ef6e46c48640f8eaae13e71 Mon Sep 17 00:00:00 2001 From: neo773 <62795688+neo773@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:30:45 +0530 Subject: [PATCH] Fix Gmail sync edge case with multi-label emails (#17318) Gmail emails often have multiple labels - an email in "XYZ" label typically also has "INBOX". The previous negative filter approach (-label:inbox -label:sent...) excluded these emails entirely, even when they had a selected label. Switched to positive OR filtering: (label:cyz OR label:xyz-visible) -label:spam... which correctly fetches emails with any of the selected labels regardless of other labels they have. This edge case wasn't caught earlier because manual testing with INBOX selected worked fine - it only surfaced when selecting custom labels without INBOX. --- .../gmail-get-message-list.service.spec.ts | 76 +++-- .../gmail-get-message-list.service.ts | 9 +- ...ompute-gmail-exclude-search-filter.spec.ts | 259 +++++++++--------- ...default-not-synced-labels-search-filter.ts | 13 + ...ompute-gmail-exclude-search-filter.util.ts | 42 ++- 5 files changed, 213 insertions(+), 186 deletions(-) create mode 100644 packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-gmail-default-not-synced-labels-search-filter.ts diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-message-list.service.spec.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-message-list.service.spec.ts index 50eb76521d..b64640e80a 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-message-list.service.spec.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-message-list.service.spec.ts @@ -338,7 +338,7 @@ describe('GmailGetMessageListService', () => { }); describe('initial sync folder filtering', () => { - it('should build Gmail query with -label exclusions for non-synced folders', async () => { + it('should build Gmail query with positive OR filter for synced folders', async () => { const mockGmailClient = { users: { messages: { @@ -357,6 +357,29 @@ describe('GmailGetMessageListService', () => { oAuth2ClientManagerService.getGoogleOAuth2Client as jest.Mock ).mockResolvedValue({}); + const messageFolders = [ + createMockFolder({ + name: 'INBOX', + externalId: 'INBOX', + isSynced: true, + }), + createMockFolder({ + name: 'Work', + externalId: 'Label_work', + isSynced: true, + }), + createMockFolder({ + name: 'Personal', + externalId: 'Label_personal', + isSynced: false, + }), + createMockFolder({ + name: 'Newsletters', + externalId: 'Label_newsletters', + isSynced: false, + }), + ]; + await service.getMessageLists({ messageChannel: { syncCursor: '', @@ -364,44 +387,13 @@ describe('GmailGetMessageListService', () => { messageFolderImportPolicy: MessageFolderImportPolicy.SELECTED_FOLDERS, }, connectedAccount: mockConnectedAccount, - messageFolders: [ - createMockFolder({ - name: 'INBOX', - externalId: 'INBOX', - isSynced: true, - }), - createMockFolder({ - name: 'Work', - externalId: 'Label_work', - isSynced: true, - }), - createMockFolder({ - name: 'Personal', - externalId: 'Label_personal', - isSynced: false, - }), - createMockFolder({ - name: 'Newsletters', - externalId: 'Label_newsletters', - isSynced: false, - }), - ], + messageFolders, }); - const expectedQuery = computeGmailExcludeSearchFilter([ - { - externalId: 'Label_personal', - name: 'Personal', - isSynced: false, - parentFolderId: null, - }, - { - externalId: 'Label_newsletters', - name: 'Newsletters', - isSynced: false, - parentFolderId: null, - }, - ]); + const expectedQuery = computeGmailExcludeSearchFilter( + messageFolders, + MessageFolderImportPolicy.SELECTED_FOLDERS, + ); expect(mockGmailClient.users.messages.list).toHaveBeenCalledWith( expect.objectContaining({ @@ -410,7 +402,7 @@ describe('GmailGetMessageListService', () => { ); }); - it('should not include exclusion filter when ALL_FOLDERS policy is set', async () => { + it('should only include default exclusions when ALL_FOLDERS policy is set', async () => { const mockGmailClient = { users: { messages: { @@ -450,9 +442,11 @@ describe('GmailGetMessageListService', () => { ], }); - expect(mockGmailClient.users.messages.list).toHaveBeenCalledWith( - expect.objectContaining({ q: '' }), - ); + const callArgs = mockGmailClient.users.messages.list.mock.calls[0][0]; + + expect(callArgs.q).toContain('-label:spam'); + expect(callArgs.q).toContain('-category:promotions'); + expect(callArgs.q).not.toContain('label:inbox'); }); }); diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-message-list.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-message-list.service.ts index bb5c6c6d4f..0764021a74 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-message-list.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-message-list.service.ts @@ -63,11 +63,10 @@ export class GmailGetMessageListService { const messageExternalIds: string[] = []; - const excludedSearchFilter = - messageChannel.messageFolderImportPolicy === - MessageFolderImportPolicy.SELECTED_FOLDERS - ? computeGmailExcludeSearchFilter(messageFolders) - : ''; + const excludedSearchFilter = computeGmailExcludeSearchFilter( + messageFolders, + messageChannel.messageFolderImportPolicy, + ); while (hasMoreMessages) { const messageList = await gmailClient.users.messages diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/utils/__tests__/compute-gmail-exclude-search-filter.spec.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/utils/__tests__/compute-gmail-exclude-search-filter.spec.ts index 81713df200..3f18b7f898 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/utils/__tests__/compute-gmail-exclude-search-filter.spec.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/utils/__tests__/compute-gmail-exclude-search-filter.spec.ts @@ -1,146 +1,137 @@ +import { MessageFolderImportPolicy } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity'; import { computeGmailExcludeSearchFilter } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-gmail-exclude-search-filter.util'; describe('computeGmailExcludeSearchFilter', () => { - it('should return empty string with empty folder array', () => { - const result = computeGmailExcludeSearchFilter([]); + describe('SELECTED_FOLDERS policy', () => { + it('returns empty string when no folders are synced', () => { + const result = computeGmailExcludeSearchFilter( + [ + { + externalId: 'INBOX', + name: 'INBOX', + isSynced: false, + parentFolderId: null, + }, + ], + MessageFolderImportPolicy.SELECTED_FOLDERS, + ); - expect(result).toBe(''); + expect(result).toBe(''); + }); + + it('builds positive OR query for selected folders', () => { + const result = computeGmailExcludeSearchFilter( + [ + { + externalId: 'Label_1', + name: 'CRM', + isSynced: true, + parentFolderId: null, + }, + { + externalId: 'Label_2', + name: 'Twenty visible', + isSynced: true, + parentFolderId: null, + }, + { + externalId: 'INBOX', + name: 'INBOX', + isSynced: false, + parentFolderId: null, + }, + ], + MessageFolderImportPolicy.SELECTED_FOLDERS, + ); + + expect(result).toContain('(label:crm OR label:twenty-visible)'); + expect(result).not.toContain('-label:inbox'); + }); + + it('returns only default exclusions when all folders are synced', () => { + const result = computeGmailExcludeSearchFilter( + [ + { + externalId: 'INBOX', + name: 'INBOX', + isSynced: true, + parentFolderId: null, + }, + { + externalId: 'Label_1', + name: 'CRM', + isSynced: true, + parentFolderId: null, + }, + ], + MessageFolderImportPolicy.SELECTED_FOLDERS, + ); + + expect(result).toContain('-label:spam'); + expect(result).toContain('-category:promotions'); + expect(result).not.toContain('label:inbox'); + expect(result).not.toContain('label:crm'); + }); + + it('handles nested folder paths correctly', () => { + const result = computeGmailExcludeSearchFilter( + [ + { + externalId: 'Label_parent', + name: 'Projects', + isSynced: false, + parentFolderId: null, + }, + { + externalId: 'Label_child', + name: 'Active', + isSynced: true, + parentFolderId: 'Label_parent', + }, + ], + MessageFolderImportPolicy.SELECTED_FOLDERS, + ); + + expect(result).toContain('label:projects-active'); + }); }); - it('should return correct exclude filter for one unsynced folder', () => { - const result = computeGmailExcludeSearchFilter([ - { - externalId: 'Label_123', - name: 'Custom Folder', - isSynced: false, - parentFolderId: null, - }, - ]); + describe('ALL_FOLDERS policy', () => { + it('returns only default exclusions', () => { + const result = computeGmailExcludeSearchFilter( + [ + { + externalId: 'INBOX', + name: 'INBOX', + isSynced: true, + parentFolderId: null, + }, + ], + MessageFolderImportPolicy.ALL_FOLDERS, + ); - expect(result).toBe('-label:custom-folder'); + expect(result).toContain('-label:spam'); + expect(result).toContain('-category:promotions'); + expect(result).not.toContain('label:inbox'); + }); }); - it('should return correct exclude filter for multiple unsynced folders', () => { - const result = computeGmailExcludeSearchFilter([ - { - externalId: 'Label_1', - name: 'Folder One', - isSynced: false, - parentFolderId: null, - }, - { - externalId: 'Label_2', - name: 'Folder Two', - isSynced: false, - parentFolderId: null, - }, - ]); + it('uses correct Gmail search syntax for labels and categories', () => { + const result = computeGmailExcludeSearchFilter( + [ + { + externalId: 'Label_1', + name: 'Work', + isSynced: true, + parentFolderId: null, + }, + ], + MessageFolderImportPolicy.SELECTED_FOLDERS, + ); - expect(result).toBe('-label:folder-one -label:folder-two'); - }); - - it('should return empty string when all folders are synced', () => { - const result = computeGmailExcludeSearchFilter([ - { - externalId: 'Label_1', - name: 'Synced Folder', - isSynced: true, - parentFolderId: null, - }, - ]); - - expect(result).toBe(''); - }); - - it('should only exclude unsynced folders', () => { - const result = computeGmailExcludeSearchFilter([ - { - externalId: 'Label_1', - name: 'Synced', - isSynced: true, - parentFolderId: null, - }, - { - externalId: 'Label_2', - name: 'Not Synced', - isSynced: false, - parentFolderId: null, - }, - { - externalId: 'Label_3', - name: 'Also Synced', - isSynced: true, - parentFolderId: null, - }, - ]); - - expect(result).toBe('-label:not-synced'); - }); - - it('should handle nested folders with parent path', () => { - const folders = [ - { - externalId: 'Label_parent', - name: 'Parent Folder', - isSynced: true, - parentFolderId: null, - }, - { - externalId: 'Label_child', - name: 'Child Folder', - isSynced: false, - parentFolderId: 'Label_parent', - }, - ]; - - const result = computeGmailExcludeSearchFilter(folders); - - expect(result).toBe('-label:parent-folder-child-folder'); - }); - - it('should handle deeply nested folders', () => { - const folders = [ - { - externalId: 'Label_grandparent', - name: 'Level One', - isSynced: true, - parentFolderId: null, - }, - { - externalId: 'Label_parent', - name: 'Level Two', - isSynced: true, - parentFolderId: 'Label_grandparent', - }, - { - externalId: 'Label_child', - name: 'Level Three', - isSynced: false, - parentFolderId: 'Label_parent', - }, - ]; - - const result = computeGmailExcludeSearchFilter(folders); - - expect(result).toBe('-label:level-one-level-two-level-three'); - }); - - it('should skip folders without names', () => { - const result = computeGmailExcludeSearchFilter([ - { - externalId: 'Label_1', - name: '', - isSynced: false, - parentFolderId: null, - }, - { - externalId: 'Label_2', - name: 'Valid Folder', - isSynced: false, - parentFolderId: null, - }, - ]); - - expect(result).toBe('-label:valid-folder'); + expect(result).toContain('-label:trash'); + expect(result).toContain('-label:spam'); + expect(result).toContain('-category:promotions'); + expect(result).toContain('-category:social'); }); }); diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-gmail-default-not-synced-labels-search-filter.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-gmail-default-not-synced-labels-search-filter.ts new file mode 100644 index 0000000000..782b9f9994 --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-gmail-default-not-synced-labels-search-filter.ts @@ -0,0 +1,13 @@ +const CATEGORY_PREFIX = 'CATEGORY_'; + +export const computeGmailDefaultNotSyncedLabelsSearchFilter = ( + labelId: string, +): string => { + if (labelId.startsWith(CATEGORY_PREFIX)) { + const category = labelId.slice(CATEGORY_PREFIX.length).toLowerCase(); + + return `-category:${category}`; + } + + return `-label:${labelId.toLowerCase()}`; +}; diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-gmail-exclude-search-filter.util.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-gmail-exclude-search-filter.util.ts index 05d4403094..691ef4955f 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-gmail-exclude-search-filter.util.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-gmail-exclude-search-filter.util.ts @@ -1,17 +1,47 @@ import { isDefined } from 'twenty-shared/utils'; +import { MessageFolderImportPolicy } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity'; import { type MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity'; +import { MESSAGING_GMAIL_DEFAULT_NOT_SYNCED_LABELS } from 'src/modules/messaging/message-import-manager/drivers/gmail/constants/messaging-gmail-default-not-synced-labels'; import { buildGmailLabelSearchName } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/build-gmail-label-search-name.util'; +import { computeGmailDefaultNotSyncedLabelsSearchFilter } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-gmail-default-not-synced-labels-search-filter'; export const computeGmailExcludeSearchFilter = ( messageFolders: Pick< MessageFolderWorkspaceEntity, 'externalId' | 'isSynced' | 'name' | 'parentFolderId' >[], -) => - messageFolders - .filter((folder) => !folder.isSynced) + messageFolderImportPolicy: MessageFolderImportPolicy, +): string => { + const defaultExclusions = MESSAGING_GMAIL_DEFAULT_NOT_SYNCED_LABELS.map( + computeGmailDefaultNotSyncedLabelsSearchFilter, + ).join(' '); + + if (messageFolderImportPolicy === MessageFolderImportPolicy.ALL_FOLDERS) { + return defaultExclusions; + } + + const allFoldersSynced = + messageFolders.length > 0 && + messageFolders.every((folder) => folder.isSynced); + + if (allFoldersSynced) { + return defaultExclusions; + } + + const labelNamesToInclude = messageFolders + .filter((folder) => folder.isSynced) .map((folder) => buildGmailLabelSearchName(folder, messageFolders)) - .filter(isDefined) - .map((name) => `-label:${name}`) - .join(' '); + .filter(isDefined); + + if (labelNamesToInclude.length === 0) { + return ''; + } + + const inclusionQuery = + labelNamesToInclude.length === 1 + ? `label:${labelNamesToInclude[0]}` + : `(${labelNamesToInclude.map((name) => `label:${name}`).join(' OR ')})`; + + return `${inclusionQuery} ${defaultExclusions}`; +};