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 new file mode 100644 index 000000000..dc015de1a --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-message-list.service.spec.ts @@ -0,0 +1,205 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { ConnectedAccountProvider } from 'twenty-shared/types'; + +import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; +import { GmailClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/gmail-client.provider'; +import { GmailGetHistoryService } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-history.service'; +import { GmailGetMessageListService } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-message-list.service'; +import { GmailHandleErrorService } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-handle-error.service'; + +describe('GmailGetMessageListService', () => { + let service: GmailGetMessageListService; + let gmailClientProvider: GmailClientProvider; + + const mockConnectedAccount: Pick< + ConnectedAccountWorkspaceEntity, + 'provider' | 'refreshToken' | 'id' | 'handle' + > = { + id: 'connected-account-id', + provider: ConnectedAccountProvider.GOOGLE, + refreshToken: 'refresh-token', + handle: 'test@gmail.com', + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GmailGetMessageListService, + { + provide: GmailClientProvider, + useValue: { + getGmailClient: jest.fn(), + }, + }, + { + provide: GmailGetHistoryService, + useValue: { + getHistory: jest.fn(), + getMessageIdsFromHistory: jest.fn(), + }, + }, + { + provide: GmailHandleErrorService, + useValue: { + handleGmailMessageListFetchError: jest.fn(), + handleGmailMessagesImportError: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get( + GmailGetMessageListService, + ); + gmailClientProvider = module.get(GmailClientProvider); + }); + + describe('getFullMessageList', () => { + it('should return 0 messageExternalIds when gmail returns 0 messages', async () => { + const mockGmailClient = { + users: { + messages: { + list: jest.fn().mockResolvedValue({ + data: { + messages: [], + nextPageToken: undefined, + }, + }), + }, + }, + }; + + (gmailClientProvider.getGmailClient as jest.Mock).mockResolvedValue( + mockGmailClient, + ); + + const result = await service.getFullMessageList(mockConnectedAccount); + + expect(result.messageExternalIds).toHaveLength(0); + expect(mockGmailClient.users.messages.list).toHaveBeenCalledTimes(1); + }); + + it('should return 5 messageExternalIds when gmail returns 5 messages', async () => { + const mockMessages = [ + { + id: `message-1`, + }, + { + id: `message-2`, + }, + { + id: `message-3`, + }, + { + id: `message-4`, + }, + { + id: `message-5`, + }, + ]; + + const mockGmailClient = { + users: { + messages: { + list: jest.fn().mockResolvedValue({ + data: { + messages: mockMessages, + nextPageToken: undefined, + }, + }), + get: jest.fn().mockResolvedValue({ + data: { + historyId: 'history-id-123', + }, + }), + }, + }, + }; + + (gmailClientProvider.getGmailClient as jest.Mock).mockResolvedValue( + mockGmailClient, + ); + + const result = await service.getFullMessageList(mockConnectedAccount); + + expect(result.messageExternalIds).toHaveLength(5); + }); + + it('should return 3 messageExternalIds when gmail provides a nextpagetoken with 2 messages, then 1', async () => { + const mockGmailClient = { + users: { + messages: { + list: jest + .fn() + .mockResolvedValueOnce({ + data: { + messages: [ + { + id: `message-1`, + }, + { + id: `message-2`, + }, + ], + nextPageToken: 'next-page-token', + }, + }) + .mockResolvedValueOnce({ + data: { + messages: [ + { + id: `message-3`, + }, + ], + nextPageToken: undefined, + }, + }), + get: jest.fn().mockResolvedValue({ + data: { + historyId: 'history-id-123', + }, + }), + }, + }, + }; + + (gmailClientProvider.getGmailClient as jest.Mock).mockResolvedValue( + mockGmailClient, + ); + + const result = await service.getFullMessageList(mockConnectedAccount); + + expect(result.messageExternalIds).toHaveLength(3); + expect(mockGmailClient.users.messages.list).toHaveBeenCalledTimes(2); + }); + it('should go through while loop once when gmail provides a nextpagetoken but 0 messages - should never happen IRL', async () => { + const mockGmailClient = { + users: { + messages: { + list: jest.fn().mockResolvedValue({ + data: { + messages: [], + nextPageToken: 'next-page-token', + }, + }), + }, + get: jest.fn().mockResolvedValue({ + data: { + historyId: 'history-id-123', + }, + }), + }, + }; + + (gmailClientProvider.getGmailClient as jest.Mock).mockResolvedValue( + mockGmailClient, + ); + + const result = await service.getFullMessageList(mockConnectedAccount); + + expect(result.messageExternalIds).toHaveLength(0); + expect(mockGmailClient.users.messages.list).toHaveBeenCalledTimes(1); + }); + }); +}); 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 5a7e682cd..8c3149b68 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 @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common'; import { gmail_v1 as gmailV1 } from 'googleapis'; -import { isDefined } from 'twenty-shared/utils'; import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; import { @@ -40,7 +39,7 @@ export class GmailGetMessageListService { let pageToken: string | undefined; let hasMoreMessages = true; - let firstMessageExternalId: string | undefined; + const messageExternalIds: string[] = []; while (hasMoreMessages) { @@ -64,30 +63,28 @@ export class GmailGetMessageListService { }; }); - pageToken = messageList.data.nextPageToken ?? undefined; - hasMoreMessages = !!pageToken; - const { messages } = messageList.data; + const hasMessages = messages && messages.length > 0; - if (!messages || messages.length === 0) { + if (!hasMessages) { break; } - if (!firstMessageExternalId) { - firstMessageExternalId = messageList.data.messages?.[0].id ?? undefined; - } + pageToken = messageList.data.nextPageToken ?? undefined; + hasMoreMessages = !!pageToken; // @ts-expect-error legacy noImplicitAny messageExternalIds.push(...messages.map((message) => message.id)); } - if (!isDefined(firstMessageExternalId)) { - throw new MessageImportDriverException( - `No firstMessageExternalId found for connected account ${connectedAccount.id}`, - MessageImportDriverExceptionCode.UNKNOWN, - ); + if (messageExternalIds.length === 0) { + return { + messageExternalIds, + nextSyncCursor: '', + }; } + const firstMessageExternalId = messageExternalIds[0]; const firstMessageContent = await gmailClient.users.messages .get({ userId: 'me', diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-full-message-list-fetch.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-full-message-list-fetch.service.ts index 834b00243..93e34bb66 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-full-message-list-fetch.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-full-message-list-fetch.service.ts @@ -43,6 +43,19 @@ export class MessagingFullMessageListFetchService { messageChannel, ); + const isEmptyMailbox = fullMessageLists.some( + (fullMessageList) => fullMessageList.messageExternalIds.length === 0, + ); + + if (isEmptyMailbox) { + await this.messageChannelSyncStatusService.resetAndScheduleFullMessageListFetch( + [messageChannel.id], + workspaceId, + ); + + return; + } + for (const fullMessageList of fullMessageLists) { const { messageExternalIds, nextSyncCursor, folderId } = fullMessageList; diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-get-message-list.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-get-message-list.service.ts index 3dc496830..6033a8bb4 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-get-message-list.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-get-message-list.service.ts @@ -48,15 +48,19 @@ export class MessagingGetMessageListService { messageChannel: MessageChannelWorkspaceEntity, ): Promise { switch (messageChannel.connectedAccount.provider) { - case ConnectedAccountProvider.GOOGLE: + case ConnectedAccountProvider.GOOGLE: { + const fullMessageList = + await this.gmailGetMessageListService.getFullMessageList( + messageChannel.connectedAccount, + ); + return [ { - ...(await this.gmailGetMessageListService.getFullMessageList( - messageChannel.connectedAccount, - )), + ...fullMessageList, folderId: undefined, }, ]; + } case ConnectedAccountProvider.MICROSOFT: { const folderRepository = await this.twentyORMManager.getRepository(