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.
This commit is contained in:
neo773
2026-01-22 16:30:45 +05:30
committed by GitHub
parent 670ff1583e
commit 1ffd23764c
5 changed files with 213 additions and 186 deletions
@@ -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');
});
});
@@ -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
@@ -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');
});
});
@@ -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()}`;
};
@@ -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}`;
};