Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ea1664020 |
+73
@@ -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,
|
||||
})),
|
||||
},
|
||||
});
|
||||
+203
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
+217
@@ -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']);
|
||||
});
|
||||
});
|
||||
+2
-91
@@ -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(
|
||||
|
||||
+120
@@ -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]);
|
||||
});
|
||||
});
|
||||
+18
@@ -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),
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user