Empty Gmail box bug (#12225)

if gmail is empty, there is an error. This PR handles this use-case
This commit is contained in:
Guillim
2025-05-23 17:21:31 +02:00
committed by GitHub
parent 88b967dfb8
commit 6ef9a3b4c9
4 changed files with 237 additions and 18 deletions

View File

@ -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>(
GmailGetMessageListService,
);
gmailClientProvider = module.get<GmailClientProvider>(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);
});
});
});

View File

@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { gmail_v1 as gmailV1 } from 'googleapis'; 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 { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { import {
@ -40,7 +39,7 @@ export class GmailGetMessageListService {
let pageToken: string | undefined; let pageToken: string | undefined;
let hasMoreMessages = true; let hasMoreMessages = true;
let firstMessageExternalId: string | undefined;
const messageExternalIds: string[] = []; const messageExternalIds: string[] = [];
while (hasMoreMessages) { while (hasMoreMessages) {
@ -64,30 +63,28 @@ export class GmailGetMessageListService {
}; };
}); });
pageToken = messageList.data.nextPageToken ?? undefined;
hasMoreMessages = !!pageToken;
const { messages } = messageList.data; const { messages } = messageList.data;
const hasMessages = messages && messages.length > 0;
if (!messages || messages.length === 0) { if (!hasMessages) {
break; break;
} }
if (!firstMessageExternalId) { pageToken = messageList.data.nextPageToken ?? undefined;
firstMessageExternalId = messageList.data.messages?.[0].id ?? undefined; hasMoreMessages = !!pageToken;
}
// @ts-expect-error legacy noImplicitAny // @ts-expect-error legacy noImplicitAny
messageExternalIds.push(...messages.map((message) => message.id)); messageExternalIds.push(...messages.map((message) => message.id));
} }
if (!isDefined(firstMessageExternalId)) { if (messageExternalIds.length === 0) {
throw new MessageImportDriverException( return {
`No firstMessageExternalId found for connected account ${connectedAccount.id}`, messageExternalIds,
MessageImportDriverExceptionCode.UNKNOWN, nextSyncCursor: '',
); };
} }
const firstMessageExternalId = messageExternalIds[0];
const firstMessageContent = await gmailClient.users.messages const firstMessageContent = await gmailClient.users.messages
.get({ .get({
userId: 'me', userId: 'me',

View File

@ -43,6 +43,19 @@ export class MessagingFullMessageListFetchService {
messageChannel, 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) { for (const fullMessageList of fullMessageLists) {
const { messageExternalIds, nextSyncCursor, folderId } = const { messageExternalIds, nextSyncCursor, folderId } =
fullMessageList; fullMessageList;

View File

@ -48,15 +48,19 @@ export class MessagingGetMessageListService {
messageChannel: MessageChannelWorkspaceEntity, messageChannel: MessageChannelWorkspaceEntity,
): Promise<GetFullMessageListForFoldersResponse[]> { ): Promise<GetFullMessageListForFoldersResponse[]> {
switch (messageChannel.connectedAccount.provider) { switch (messageChannel.connectedAccount.provider) {
case ConnectedAccountProvider.GOOGLE: case ConnectedAccountProvider.GOOGLE: {
const fullMessageList =
await this.gmailGetMessageListService.getFullMessageList(
messageChannel.connectedAccount,
);
return [ return [
{ {
...(await this.gmailGetMessageListService.getFullMessageList( ...fullMessageList,
messageChannel.connectedAccount,
)),
folderId: undefined, folderId: undefined,
}, },
]; ];
}
case ConnectedAccountProvider.MICROSOFT: { case ConnectedAccountProvider.MICROSOFT: {
const folderRepository = const folderRepository =
await this.twentyORMManager.getRepository<MessageFolderWorkspaceEntity>( await this.twentyORMManager.getRepository<MessageFolderWorkspaceEntity>(