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:
+35
-41
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+4
-5
@@ -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
|
||||
|
||||
+125
-134
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+13
@@ -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()}`;
|
||||
};
|
||||
+36
-6
@@ -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}`;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user