Compare commits

...
Author SHA1 Message Date
neo773 2ea1664020 wip 2026-05-26 02:53:33 +05:30
6 changed files with 633 additions and 91 deletions
@@ -0,0 +1,73 @@
import { type gmail_v1 as gmailV1 } from 'googleapis';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
export const mockConnectedAccount: Pick<
ConnectedAccountEntity,
'provider' | 'id' | 'handle' | 'handleAliases'
> = {
id: 'connected-account-id',
provider: ConnectedAccountProvider.GOOGLE,
handle: '[email protected]',
handleAliases: [],
};
type SyncedFolderInput = {
externalId: string;
isSynced: boolean;
};
export const createMockMessageFolder = ({
externalId,
isSynced,
}: SyncedFolderInput) =>
({
id: `folder-${externalId}`,
externalId,
isSynced,
}) as never;
type RawGmailMessageInput = {
id: string;
threadId: string;
labelIds: string[];
from?: string;
to?: string;
};
export const createRawGmailMessage = ({
id,
threadId,
labelIds,
from = '[email protected]',
to = '[email protected]',
}: RawGmailMessageInput): gmailV1.Schema$Message => ({
id,
threadId,
historyId: '1',
internalDate: '1700000000000',
labelIds,
payload: {
headers: [
{ name: 'From', value: from },
{ name: 'To', value: to },
{ name: 'Message-ID', value: `<${id}@external.com>` },
{ name: 'Subject', value: `Subject ${id}` },
],
mimeType: 'text/plain',
body: { data: Buffer.from('body').toString('base64') },
},
});
export const createThreadGetResponse = (
threadMessages: { id: string; labelIds: string[] }[],
) => ({
data: {
messages: threadMessages.map((threadMessage) => ({
id: threadMessage.id,
labelIds: threadMessage.labelIds,
})),
},
});
@@ -0,0 +1,203 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { google } from 'googleapis';
import { MessageFolderImportPolicy } from 'twenty-shared/types';
import { GoogleOAuth2ClientProvider } from 'src/modules/connected-account/oauth2-client-manager/drivers/google/google-oauth2-client.provider';
import {
createMockMessageFolder,
createRawGmailMessage,
createThreadGetResponse,
mockConnectedAccount,
} from 'src/modules/messaging/message-import-manager/drivers/gmail/services/__mocks__/gmail-get-messages.mocks';
import { GmailGetMessagesService } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-messages.service';
import { GmailMessagesImportErrorHandler } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-messages-import-error-handler.service';
import {
createCountedGmailClient,
createGmailQuotaCounter,
} from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/__tests__/test-utils/gmail-quota-counter';
const SYNCED_LABEL = 'INBOX';
const NON_SYNCED_LABEL = 'Label_archive';
type MailboxFixture = {
initialMessageIds: string[];
rawById: Record<string, ReturnType<typeof createRawGmailMessage>>;
threadMessageIds: Record<string, string[]>;
};
const buildMailbox = (threadCount: number): MailboxFixture => {
const initialMessageIds: string[] = [];
const rawById: Record<string, ReturnType<typeof createRawGmailMessage>> = {};
const threadMessageIds: Record<string, string[]> = {};
for (let index = 0; index < threadCount; index++) {
const threadId = `thread-${index}`;
const rootId = `root-${index}`;
const replyId = `reply-${index}`;
const ancestorId = `ancestor-${index}`;
rawById[rootId] = createRawGmailMessage({
id: rootId,
threadId,
labelIds: [SYNCED_LABEL],
});
rawById[replyId] = createRawGmailMessage({
id: replyId,
threadId,
labelIds: [SYNCED_LABEL],
});
rawById[ancestorId] = createRawGmailMessage({
id: ancestorId,
threadId,
labelIds: [NON_SYNCED_LABEL],
});
initialMessageIds.push(rootId, replyId);
threadMessageIds[threadId] = [rootId, replyId, ancestorId];
}
return { initialMessageIds, rawById, threadMessageIds };
};
const labelIdsOf = (
rawById: MailboxFixture['rawById'],
messageId: string,
): string[] => rawById[messageId]?.labelIds ?? [];
const runBaselineSelectiveSync = async (
mailbox: MailboxFixture,
countedClient: ReturnType<typeof createCountedGmailClient>,
): Promise<string[]> => {
const fetched = await Promise.all(
mailbox.initialMessageIds.map((id) => countedClient.users.messages.get({ id })),
);
const fetchedIds = new Set(mailbox.initialMessageIds);
const threadIds = [
...new Set(fetched.map((response) => (response as { data: { threadId: string } }).data.threadId)),
];
const includedIds = new Set<string>(mailbox.initialMessageIds);
const missingIds: string[] = [];
await Promise.all(
threadIds.map(async (threadId) => {
const thread = (await countedClient.users.threads.get({ id: threadId })) as {
data: { messages: { id: string; labelIds: string[] }[] };
};
const threadTouchesSynced = thread.data.messages.some((message) =>
message.labelIds.includes(SYNCED_LABEL),
);
if (!threadTouchesSynced) {
return;
}
for (const message of thread.data.messages) {
if (!fetchedIds.has(message.id)) {
missingIds.push(message.id);
}
}
}),
);
await Promise.all(missingIds.map((id) => countedClient.users.messages.get({ id })));
for (const id of missingIds) {
includedIds.add(id);
}
return [...includedIds].sort();
};
describe('Gmail SELECTED_FOLDERS quota: baseline vs grouped-thread', () => {
let service: GmailGetMessagesService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
GmailGetMessagesService,
{
provide: GoogleOAuth2ClientProvider,
useValue: { getClient: jest.fn().mockResolvedValue({}) },
},
{
provide: GmailMessagesImportErrorHandler,
useValue: { handleError: jest.fn() },
},
],
}).compile();
service = module.get(GmailGetMessagesService);
});
afterEach(() => {
jest.restoreAllMocks();
});
const threadCount = 3;
it('imports fewer-or-equal messages while consuming strictly fewer quota units and zero threads.get', async () => {
const mailbox = buildMailbox(threadCount);
const baselineCounter = createGmailQuotaCounter();
const baselineClient = createCountedGmailClient(baselineCounter, {
messagesGet: ({ id }) => Promise.resolve({ data: mailbox.rawById[id] }),
threadsGet: ({ id }) =>
Promise.resolve(
createThreadGetResponse(
mailbox.threadMessageIds[id].map((messageId) => ({
id: messageId,
labelIds: labelIdsOf(mailbox.rawById, messageId),
})),
),
),
});
const baselineResult = await runBaselineSelectiveSync(mailbox, baselineClient);
const groupedCounter = createGmailQuotaCounter();
const groupedClient = createCountedGmailClient(groupedCounter, {
messagesGet: ({ id }) => Promise.resolve({ data: mailbox.rawById[id] }),
threadsGet: () => Promise.reject(new Error('threads.get should not be called')),
});
jest.spyOn(google, 'gmail').mockReturnValue(groupedClient as never);
const groupedResult = await service.getMessages(
mailbox.initialMessageIds,
mockConnectedAccount,
{
messageFolderImportPolicy: MessageFolderImportPolicy.SELECTED_FOLDERS,
messageFolders: [
createMockMessageFolder({ externalId: SYNCED_LABEL, isSynced: true }),
],
} as never,
);
const groupedResultIds = groupedResult
.map((message) => message.externalId)
.sort();
const syncedFolderMessageIds = mailbox.initialMessageIds.slice().sort();
expect(groupedResultIds).toStrictEqual(syncedFolderMessageIds);
expect(baselineResult).toStrictEqual(
[
...syncedFolderMessageIds,
...Array.from({ length: threadCount }, (_, index) => `ancestor-${index}`),
].sort(),
);
expect(groupedCounter.callCounts['threads.get']).toBe(0);
expect(baselineCounter.callCounts['threads.get']).toBe(threadCount);
expect(groupedCounter.totalQuotaUnits()).toBe(threadCount * 2 * 20);
expect(baselineCounter.totalQuotaUnits()).toBe(
threadCount * 2 * 20 + threadCount * 40 + threadCount * 20,
);
expect(groupedCounter.totalQuotaUnits()).toBeLessThan(
baselineCounter.totalQuotaUnits(),
);
});
});
@@ -0,0 +1,217 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { google } from 'googleapis';
import { MessageFolderImportPolicy } from 'twenty-shared/types';
import { GoogleOAuth2ClientProvider } from 'src/modules/connected-account/oauth2-client-manager/drivers/google/google-oauth2-client.provider';
import {
createMockMessageFolder,
createRawGmailMessage,
mockConnectedAccount,
} from 'src/modules/messaging/message-import-manager/drivers/gmail/services/__mocks__/gmail-get-messages.mocks';
import { GmailGetMessagesService } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-messages.service';
import { GmailMessagesImportErrorHandler } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-messages-import-error-handler.service';
describe('GmailGetMessagesService', () => {
let service: GmailGetMessagesService;
let errorHandler: GmailMessagesImportErrorHandler;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
GmailGetMessagesService,
{
provide: GoogleOAuth2ClientProvider,
useValue: { getClient: jest.fn().mockResolvedValue({}) },
},
{
provide: GmailMessagesImportErrorHandler,
useValue: { handleError: jest.fn() },
},
],
}).compile();
service = module.get(GmailGetMessagesService);
errorHandler = module.get(GmailMessagesImportErrorHandler);
});
afterEach(() => {
jest.restoreAllMocks();
});
const threadsGet = jest.fn();
const mockGmailClient = (
messagesGet: jest.Mock,
): void => {
jest.spyOn(google, 'gmail').mockReturnValue({
users: { messages: { get: messagesGet }, threads: { get: threadsGet } },
} as never);
};
const messagesGetReturning = (
messagesById: Record<string, Parameters<typeof createRawGmailMessage>[0]>,
): jest.Mock =>
jest.fn(({ id }: { id: string }) =>
Promise.resolve({ data: createRawGmailMessage(messagesById[id]) }),
);
const allFoldersChannel = {
messageFolderImportPolicy: MessageFolderImportPolicy.ALL_FOLDERS,
messageFolders: [],
} as never;
const selectedFoldersChannel = (syncedExternalIds: string[]) =>
({
messageFolderImportPolicy: MessageFolderImportPolicy.SELECTED_FOLDERS,
messageFolders: syncedExternalIds.map((externalId) =>
createMockMessageFolder({ externalId, isSynced: true }),
),
}) as never;
it('should return every fetched message when policy is ALL_FOLDERS', async () => {
const messagesGet = messagesGetReturning({
'msg-1': { id: 'msg-1', threadId: 'thread-1', labelIds: ['INBOX'] },
'msg-2': { id: 'msg-2', threadId: 'thread-2', labelIds: ['Label_other'] },
});
mockGmailClient(messagesGet);
const result = await service.getMessages(
['msg-1', 'msg-2'],
mockConnectedAccount,
allFoldersChannel,
);
expect(result.map((message) => message.externalId).sort()).toStrictEqual([
'msg-1',
'msg-2',
]);
});
it('should return no messages when SELECTED_FOLDERS has no synced folder', async () => {
const messagesGet = messagesGetReturning({
'msg-1': { id: 'msg-1', threadId: 'thread-1', labelIds: ['Label_custom'] },
});
mockGmailClient(messagesGet);
const channelWithoutSyncedFolder = {
messageFolderImportPolicy: MessageFolderImportPolicy.SELECTED_FOLDERS,
messageFolders: [
createMockMessageFolder({ externalId: 'Label_custom', isSynced: false }),
],
} as never;
const result = await service.getMessages(
['msg-1'],
mockConnectedAccount,
channelWithoutSyncedFolder,
);
expect(result).toStrictEqual([]);
});
it('should sync a new message that lands in a synced folder', async () => {
const messagesGet = messagesGetReturning({
'msg-1': { id: 'msg-1', threadId: 'thread-1', labelIds: ['INBOX'] },
});
mockGmailClient(messagesGet);
const result = await service.getMessages(
['msg-1'],
mockConnectedAccount,
selectedFoldersChannel(['INBOX']),
);
expect(result.map((message) => message.externalId)).toStrictEqual(['msg-1']);
});
it('should keep every fetched message of a thread when one of them touches a synced folder', async () => {
const messagesGet = messagesGetReturning({
'root': { id: 'root', threadId: 'thread-1', labelIds: ['INBOX'] },
'reply': { id: 'reply', threadId: 'thread-1', labelIds: ['Label_other'] },
});
mockGmailClient(messagesGet);
const result = await service.getMessages(
['root', 'reply'],
mockConnectedAccount,
selectedFoldersChannel(['INBOX']),
);
expect(result.map((message) => message.externalId).sort()).toStrictEqual([
'reply',
'root',
]);
});
it('should drop a fetched message whose thread never touches a synced folder', async () => {
const messagesGet = messagesGetReturning({
'synced': { id: 'synced', threadId: 'thread-1', labelIds: ['INBOX'] },
'unrelated': {
id: 'unrelated',
threadId: 'thread-2',
labelIds: ['Label_other'],
},
});
mockGmailClient(messagesGet);
const result = await service.getMessages(
['synced', 'unrelated'],
mockConnectedAccount,
selectedFoldersChannel(['INBOX']),
);
expect(result.map((message) => message.externalId)).toStrictEqual(['synced']);
});
it('should never call threads.get for a SELECTED_FOLDERS sync', async () => {
const messagesGet = messagesGetReturning({
'msg-1': { id: 'msg-1', threadId: 'thread-1', labelIds: ['INBOX'] },
'msg-2': { id: 'msg-2', threadId: 'thread-1', labelIds: ['INBOX'] },
});
mockGmailClient(messagesGet);
await service.getMessages(
['msg-1', 'msg-2'],
mockConnectedAccount,
selectedFoldersChannel(['INBOX']),
);
expect(threadsGet).not.toHaveBeenCalled();
});
it('should swallow per-message errors via the error handler and import the rest', async () => {
const messagesGet = jest
.fn()
.mockRejectedValueOnce(new Error('message boom'))
.mockImplementationOnce(() =>
Promise.resolve({
data: createRawGmailMessage({
id: 'msg-2',
threadId: 'thread-2',
labelIds: ['INBOX'],
}),
}),
);
mockGmailClient(messagesGet);
const result = await service.getMessages(
['msg-1', 'msg-2'],
mockConnectedAccount,
selectedFoldersChannel(['INBOX']),
);
expect(errorHandler.handleError).toHaveBeenCalledWith(
expect.any(Error),
'msg-1',
);
expect(result.map((message) => message.externalId)).toStrictEqual(['msg-2']);
});
});
@@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common';
import { batchFetchImplementation } from '@jrmdayn/googleapis-batcher';
import { isNonEmptyString } from '@sniptt/guards';
import { type gmail_v1 as gmailV1, google } from 'googleapis';
import { isDefined } from 'twenty-shared/utils';
@@ -11,6 +10,7 @@ import { GoogleOAuth2ClientProvider } from 'src/modules/connected-account/oauth2
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { GmailMessagesImportErrorHandler } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-messages-import-error-handler.service';
import { filterGmailMessagesByFolderPolicy } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/filter-gmail-messages-by-folder-policy.util';
import { groupGmailMessagesBySyncedThread } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/group-gmail-messages-by-synced-thread.util';
import { parseAndFormatGmailMessage } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/parse-and-format-gmail-message.util';
import { type MessageWithParticipants } from 'src/modules/messaging/message-import-manager/types/message';
@@ -66,96 +66,7 @@ export class GmailGetMessagesService {
return filteredMessages;
}
const syncedLabelIds = (messageChannel.messageFolders ?? [])
.filter(
(folder) => folder.isSynced && isNonEmptyString(folder.externalId),
)
.map((folder) => folder.externalId as string);
if (syncedLabelIds.length === 0) {
return filteredMessages;
}
const fetchedMessageIds = new Set(
fetchedMessages.map((message) => message.externalId),
);
const filteredMessageIds = new Set(
filteredMessages.map((message) => message.externalId),
);
const excludedMessageIds = new Set(
fetchedMessages
.filter((message) => !filteredMessageIds.has(message.externalId))
.map((message) => message.externalId),
);
const threadIds = [
...new Set(
fetchedMessages
.map((message) => message.messageThreadExternalId)
.filter(isNonEmptyString),
),
];
const matchingThreadIds = new Set<string>();
const missingMessageIds: string[] = [];
await Promise.all(
threadIds.map((threadId) =>
batchedGmailClient.users.threads
.get({
userId: 'me',
id: threadId,
format: 'metadata',
metadataHeaders: [],
})
.then((response) => {
const threadMessages = response.data.messages ?? [];
const threadHasSyncedLabel = threadMessages.some(
(threadMessage) =>
!excludedMessageIds.has(threadMessage.id ?? '') &&
(threadMessage.labelIds ?? []).some((labelId) =>
syncedLabelIds.includes(labelId),
),
);
if (!threadHasSyncedLabel) {
return;
}
matchingThreadIds.add(threadId);
for (const threadMessage of threadMessages) {
if (
isNonEmptyString(threadMessage.id) &&
!fetchedMessageIds.has(threadMessage.id)
) {
missingMessageIds.push(threadMessage.id);
}
}
})
.catch((error) => {
this.gmailMessagesImportErrorHandler.handleError(error, threadId);
}),
),
);
const threadSiblings =
missingMessageIds.length > 0
? await this.fetchMessages(
batchedGmailClient,
missingMessageIds,
connectedAccount,
)
: [];
const includedMessages = fetchedMessages.filter(
(message) =>
filteredMessageIds.has(message.externalId) ||
matchingThreadIds.has(message.messageThreadExternalId),
);
return [...includedMessages, ...threadSiblings];
return groupGmailMessagesBySyncedThread(fetchedMessages, filteredMessages);
}
private async fetchMessages(
@@ -0,0 +1,120 @@
import { MessageFolderImportPolicy } from 'twenty-shared/types';
import { type MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { filterGmailMessagesByFolderPolicy } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/filter-gmail-messages-by-folder-policy.util';
import { type MessageWithParticipants } from 'src/modules/messaging/message-import-manager/types/message';
const createMessage = (
externalId: string,
labelIds: string[],
): MessageWithParticipants =>
({
externalId,
messageThreadExternalId: `thread-${externalId}`,
labelIds,
participants: [],
}) as unknown as MessageWithParticipants;
const createChannel = (
messageFolderImportPolicy: MessageFolderImportPolicy,
syncedExternalIds: string[],
): Pick<
MessageChannelEntity,
'messageFolders' | 'messageFolderImportPolicy'
> =>
({
messageFolderImportPolicy,
messageFolders: syncedExternalIds.map((externalId) => ({
externalId,
isSynced: true,
})),
}) as unknown as Pick<
MessageChannelEntity,
'messageFolders' | 'messageFolderImportPolicy'
>;
describe('filterGmailMessagesByFolderPolicy', () => {
it('should return all messages unchanged when policy is ALL_FOLDERS', () => {
const messages = [
createMessage('a', ['CATEGORY_PROMOTIONS']),
createMessage('b', ['Label_custom']),
];
const result = filterGmailMessagesByFolderPolicy(
messages,
createChannel(MessageFolderImportPolicy.ALL_FOLDERS, []),
);
expect(result).toStrictEqual(messages);
});
it('should exclude messages that are not in any synced folder', () => {
const inSynced = createMessage('a', ['Label_custom']);
const notSynced = createMessage('b', ['Label_other']);
const result = filterGmailMessagesByFolderPolicy(
[inSynced, notSynced],
createChannel(MessageFolderImportPolicy.SELECTED_FOLDERS, [
'Label_custom',
]),
);
expect(result).toStrictEqual([inSynced]);
});
it('should include a message in a synced custom folder even when it carries an excluded category label', () => {
const message = createMessage('a', [
'Label_custom',
'CATEGORY_PROMOTIONS',
]);
const result = filterGmailMessagesByFolderPolicy(
[message],
createChannel(MessageFolderImportPolicy.SELECTED_FOLDERS, [
'Label_custom',
]),
);
expect(result).toStrictEqual([message]);
});
it('should exclude an INBOX message carrying an excluded category label', () => {
const promotional = createMessage('a', ['INBOX', 'CATEGORY_PROMOTIONS']);
const result = filterGmailMessagesByFolderPolicy(
[promotional],
createChannel(MessageFolderImportPolicy.SELECTED_FOLDERS, ['INBOX']),
);
expect(result).toStrictEqual([]);
});
it('should include an INBOX message that has no excluded category label', () => {
const primary = createMessage('a', ['INBOX']);
const result = filterGmailMessagesByFolderPolicy(
[primary],
createChannel(MessageFolderImportPolicy.SELECTED_FOLDERS, ['INBOX']),
);
expect(result).toStrictEqual([primary]);
});
it('should include an INBOX message when it is also in a synced custom folder despite an excluded category label', () => {
const message = createMessage('a', [
'INBOX',
'Label_custom',
'CATEGORY_SOCIAL',
]);
const result = filterGmailMessagesByFolderPolicy(
[message],
createChannel(MessageFolderImportPolicy.SELECTED_FOLDERS, [
'INBOX',
'Label_custom',
]),
);
expect(result).toStrictEqual([message]);
});
});
@@ -0,0 +1,18 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type MessageWithParticipants } from 'src/modules/messaging/message-import-manager/types/message';
export const groupGmailMessagesBySyncedThread = (
fetchedMessages: MessageWithParticipants[],
syncedMessages: MessageWithParticipants[],
): MessageWithParticipants[] => {
const syncedThreadExternalIds = new Set(
syncedMessages
.map((message) => message.messageThreadExternalId)
.filter(isNonEmptyString),
);
return fetchedMessages.filter((message) =>
syncedThreadExternalIds.has(message.messageThreadExternalId),
);
};