diff --git a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/imap-smtp-caldav-connection.resolver.ts b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/imap-smtp-caldav-connection.resolver.ts index ed68ced8f8..638b0ff55f 100644 --- a/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/imap-smtp-caldav-connection.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/imap-smtp-caldav-connection/imap-smtp-caldav-connection.resolver.ts @@ -103,7 +103,7 @@ export class ImapSmtpCaldavResolver { handle, ); - await this.imapSmtpCaldavApisService.setupCompleteAccount({ + await this.imapSmtpCaldavApisService.processAccount({ handle, workspaceMemberId: accountOwnerId, workspaceId: workspace.id, diff --git a/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.spec.ts b/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.spec.ts new file mode 100644 index 0000000000..2c08301b5f --- /dev/null +++ b/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.spec.ts @@ -0,0 +1,537 @@ +import { Test, type TestingModule } from '@nestjs/testing'; + +import { ConnectedAccountProvider } from 'twenty-shared/types'; + +import { type EmailAccountConnectionParameters } from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection.dto'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { getQueueToken } from 'src/engine/core-modules/message-queue/utils/get-queue-token.util'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { type CalendarChannelWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity'; +import { ImapSmtpCalDavAPIService } from 'src/modules/connected-account/services/imap-smtp-caldav-apis.service'; +import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; +import { + MessageChannelSyncStage, + MessageChannelSyncStatus, + MessageChannelType, + type MessageChannelWorkspaceEntity, +} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity'; + +jest.mock('uuid', () => ({ + v4: jest.fn(() => 'mocked-uuid'), +})); + +describe('ImapSmtpCalDavAPIService', () => { + let service: ImapSmtpCalDavAPIService; + + const mockConnectedAccountRepository = { + findOne: jest.fn(), + save: jest.fn(), + }; + + const mockMessageChannelRepository = { + findOne: jest.fn(), + save: jest.fn(), + }; + + const mockCalendarChannelRepository = { + findOne: jest.fn(), + save: jest.fn(), + }; + + const mockWorkspaceDataSource = { + transaction: jest.fn((callback) => callback({})), + }; + + const mockMessageQueueService = { + add: jest.fn(), + }; + + const mockCalendarQueueService = { + add: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ImapSmtpCalDavAPIService, + { + provide: TwentyORMGlobalManager, + useValue: { + getRepositoryForWorkspace: jest + .fn() + .mockImplementation((_workspaceId, entity) => { + if (entity === 'connectedAccount') + return mockConnectedAccountRepository; + if (entity === 'messageChannel') + return mockMessageChannelRepository; + if (entity === 'calendarChannel') + return mockCalendarChannelRepository; + + return {}; + }), + getDataSourceForWorkspace: jest + .fn() + .mockImplementation(() => mockWorkspaceDataSource), + }, + }, + { + provide: getQueueToken(MessageQueue.messagingQueue), + useValue: mockMessageQueueService, + }, + { + provide: getQueueToken(MessageQueue.calendarQueue), + useValue: mockCalendarQueueService, + }, + ], + }).compile(); + + service = module.get(ImapSmtpCalDavAPIService); + + jest.clearAllMocks(); + }); + + describe('processAccount', () => { + const baseInput = { + handle: 'test@example.com', + workspaceMemberId: 'workspace-member-id', + workspaceId: 'workspace-id', + connectionParameters: { + IMAP: { + host: 'imap.example.com', + port: 993, + secure: true, + password: 'password', + }, + SMTP: { + host: 'smtp.example.com', + port: 587, + secure: true, + username: 'test@example.com', + password: 'password', + }, + } as EmailAccountConnectionParameters, + }; + + it('should create new account with message and calendar channels when account does not exist', async () => { + mockConnectedAccountRepository.findOne.mockResolvedValue(null); + mockMessageChannelRepository.findOne.mockResolvedValue(null); + mockCalendarChannelRepository.findOne.mockResolvedValue(null); + + const expectedMessageChannel = { + id: 'mocked-uuid', + connectedAccountId: 'mocked-uuid', + type: MessageChannelType.EMAIL, + handle: 'test@example.com', + isSyncEnabled: true, + syncStatus: MessageChannelSyncStatus.ONGOING, + syncStage: MessageChannelSyncStage.FULL_MESSAGE_LIST_FETCH_PENDING, + syncCursor: '', + syncStageStartedAt: null, + }; + + mockMessageChannelRepository.save.mockResolvedValue( + expectedMessageChannel, + ); + + await service.processAccount(baseInput); + + expect(mockConnectedAccountRepository.save).toHaveBeenCalledWith( + { + id: 'mocked-uuid', + handle: 'test@example.com', + provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV, + connectionParameters: baseInput.connectionParameters, + accountOwnerId: 'workspace-member-id', + }, + {}, + ); + + expect(mockMessageChannelRepository.save).toHaveBeenCalledWith( + { + id: 'mocked-uuid', + connectedAccountId: 'mocked-uuid', + type: MessageChannelType.EMAIL, + handle: 'test@example.com', + isSyncEnabled: true, + syncStatus: MessageChannelSyncStatus.ONGOING, + syncStage: MessageChannelSyncStage.FULL_MESSAGE_LIST_FETCH_PENDING, + syncCursor: '', + syncStageStartedAt: null, + }, + {}, + ); + + expect(mockMessageQueueService.add).toHaveBeenCalledWith( + 'MessagingMessageListFetchJob', + { + workspaceId: 'workspace-id', + messageChannelId: 'mocked-uuid', + }, + ); + }); + + it('should preserve existing channels when updating account credentials', async () => { + const existingAccount = { + id: 'existing-account-id', + handle: 'test@example.com', + accountOwnerId: 'workspace-member-id', + provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV, + } as ConnectedAccountWorkspaceEntity; + + const existingMessageChannel = { + id: 'existing-message-channel-id', + connectedAccountId: 'existing-account-id', + type: MessageChannelType.EMAIL, + handle: 'test@example.com', + isSyncEnabled: true, + syncStatus: MessageChannelSyncStatus.ONGOING, + } as MessageChannelWorkspaceEntity; + + const existingCalendarChannel = { + id: 'existing-calendar-channel-id', + connectedAccountId: 'existing-account-id', + } as CalendarChannelWorkspaceEntity; + + mockConnectedAccountRepository.findOne.mockResolvedValue(existingAccount); + mockMessageChannelRepository.findOne.mockResolvedValue( + existingMessageChannel, + ); + mockCalendarChannelRepository.findOne.mockResolvedValue( + existingCalendarChannel, + ); + + const inputWithConnectedAccountId = { + ...baseInput, + connectedAccountId: 'existing-account-id', + }; + + await service.processAccount(inputWithConnectedAccountId); + + expect(mockConnectedAccountRepository.save).toHaveBeenCalledWith( + { + id: 'existing-account-id', + handle: 'test@example.com', + provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV, + connectionParameters: baseInput.connectionParameters, + accountOwnerId: 'workspace-member-id', + }, + {}, + ); + + expect(mockMessageChannelRepository.save).not.toHaveBeenCalled(); + expect(mockCalendarChannelRepository.save).not.toHaveBeenCalled(); + + expect(mockMessageQueueService.add).toHaveBeenCalledWith( + 'MessagingMessageListFetchJob', + { + workspaceId: 'workspace-id', + messageChannelId: 'existing-message-channel-id', + }, + ); + }); + + it('should only create message channel when only IMAP is configured', async () => { + const imapOnlyInput = { + ...baseInput, + connectionParameters: { + IMAP: { + host: 'imap.example.com', + port: 993, + secure: true, + password: 'password', + }, + } as EmailAccountConnectionParameters, + }; + + mockConnectedAccountRepository.findOne.mockResolvedValue(null); + mockMessageChannelRepository.findOne.mockResolvedValue(null); + mockCalendarChannelRepository.findOne.mockResolvedValue(null); + + const expectedMessageChannel = { + id: 'mocked-uuid', + connectedAccountId: 'mocked-uuid', + type: MessageChannelType.EMAIL, + handle: 'test@example.com', + isSyncEnabled: true, + syncStatus: MessageChannelSyncStatus.ONGOING, + syncStage: MessageChannelSyncStage.FULL_MESSAGE_LIST_FETCH_PENDING, + }; + + mockMessageChannelRepository.save.mockResolvedValue( + expectedMessageChannel, + ); + + await service.processAccount(imapOnlyInput); + + expect(mockMessageChannelRepository.save).toHaveBeenCalled(); + expect(mockCalendarChannelRepository.save).not.toHaveBeenCalled(); + expect(mockMessageQueueService.add).toHaveBeenCalledWith( + 'MessagingMessageListFetchJob', + { + workspaceId: 'workspace-id', + messageChannelId: 'mocked-uuid', + }, + ); + + expect(mockCalendarQueueService.add).not.toHaveBeenCalled(); + }); + + it('should create both channels when only CALDAV is configured but disable message sync', async () => { + const caldavOnlyInput = { + ...baseInput, + connectionParameters: { + CALDAV: { + host: 'caldav.example.com', + port: 443, + secure: true, + username: 'test@example.com', + password: 'password', + }, + } as EmailAccountConnectionParameters, + }; + + mockConnectedAccountRepository.findOne.mockResolvedValue(null); + mockMessageChannelRepository.findOne.mockResolvedValue(null); + mockCalendarChannelRepository.findOne.mockResolvedValue(null); + + const expectedCalendarChannel = { + id: 'mocked-uuid', + connectedAccountId: 'mocked-uuid', + handle: 'test@example.com', + }; + + mockCalendarChannelRepository.save.mockResolvedValue( + expectedCalendarChannel, + ); + + await service.processAccount(caldavOnlyInput); + + expect(mockMessageChannelRepository.save).toHaveBeenCalledWith( + { + id: 'mocked-uuid', + connectedAccountId: 'mocked-uuid', + type: MessageChannelType.EMAIL, + handle: 'test@example.com', + isSyncEnabled: false, + syncStatus: MessageChannelSyncStatus.NOT_SYNCED, + syncStage: undefined, + syncCursor: '', + syncStageStartedAt: null, + }, + {}, + ); + expect(mockCalendarChannelRepository.save).toHaveBeenCalled(); + + expect(mockMessageQueueService.add).not.toHaveBeenCalled(); + expect(mockCalendarQueueService.add).toHaveBeenCalledWith( + 'CalendarEventListFetchJob', + { + workspaceId: 'workspace-id', + calendarChannelId: 'mocked-uuid', + }, + ); + }); + + it('should handle IMAP + SMTP configuration without CALDAV', async () => { + const imapSmtpInput = { + ...baseInput, + connectionParameters: { + IMAP: { + host: 'imap.example.com', + port: 993, + secure: true, + password: 'password', + }, + SMTP: { + host: 'smtp.example.com', + port: 587, + secure: true, + username: 'test@example.com', + password: 'password', + }, + } as EmailAccountConnectionParameters, + }; + + mockConnectedAccountRepository.findOne.mockResolvedValue(null); + mockMessageChannelRepository.findOne.mockResolvedValue(null); + mockCalendarChannelRepository.findOne.mockResolvedValue(null); + + const expectedMessageChannel = { + id: 'mocked-uuid', + connectedAccountId: 'mocked-uuid', + type: MessageChannelType.EMAIL, + handle: 'test@example.com', + isSyncEnabled: true, + syncStatus: MessageChannelSyncStatus.ONGOING, + syncStage: MessageChannelSyncStage.FULL_MESSAGE_LIST_FETCH_PENDING, + }; + + mockMessageChannelRepository.save.mockResolvedValue( + expectedMessageChannel, + ); + + await service.processAccount(imapSmtpInput); + + expect(mockMessageChannelRepository.save).toHaveBeenCalled(); + expect(mockCalendarChannelRepository.save).not.toHaveBeenCalled(); + expect(mockMessageQueueService.add).toHaveBeenCalledWith( + 'MessagingMessageListFetchJob', + { + workspaceId: 'workspace-id', + messageChannelId: 'mocked-uuid', + }, + ); + expect(mockCalendarQueueService.add).not.toHaveBeenCalled(); + }); + + it('should handle full IMAP + SMTP + CALDAV configuration', async () => { + const fullConfigInput = { + ...baseInput, + connectionParameters: { + IMAP: { + host: 'imap.example.com', + port: 993, + secure: true, + password: 'password', + }, + SMTP: { + host: 'smtp.example.com', + port: 587, + secure: true, + username: 'test@example.com', + password: 'password', + }, + CALDAV: { + host: 'caldav.example.com', + port: 443, + secure: true, + username: 'test@example.com', + password: 'password', + }, + } as EmailAccountConnectionParameters, + }; + + mockConnectedAccountRepository.findOne.mockResolvedValue(null); + mockMessageChannelRepository.findOne.mockResolvedValue(null); + mockCalendarChannelRepository.findOne.mockResolvedValue(null); + + const expectedMessageChannel = { + id: 'mocked-uuid', + connectedAccountId: 'mocked-uuid', + type: MessageChannelType.EMAIL, + handle: 'test@example.com', + isSyncEnabled: true, + syncStatus: MessageChannelSyncStatus.ONGOING, + syncStage: MessageChannelSyncStage.FULL_MESSAGE_LIST_FETCH_PENDING, + }; + + const expectedCalendarChannel = { + id: 'mocked-uuid', + connectedAccountId: 'mocked-uuid', + handle: 'test@example.com', + }; + + mockMessageChannelRepository.save.mockResolvedValue( + expectedMessageChannel, + ); + mockCalendarChannelRepository.save.mockResolvedValue( + expectedCalendarChannel, + ); + + await service.processAccount(fullConfigInput); + + expect(mockMessageChannelRepository.save).toHaveBeenCalled(); + expect(mockCalendarChannelRepository.save).toHaveBeenCalled(); + expect(mockMessageQueueService.add).toHaveBeenCalledWith( + 'MessagingMessageListFetchJob', + { + workspaceId: 'workspace-id', + messageChannelId: 'mocked-uuid', + }, + ); + expect(mockCalendarQueueService.add).toHaveBeenCalledWith( + 'CalendarEventListFetchJob', + { + workspaceId: 'workspace-id', + calendarChannelId: 'mocked-uuid', + }, + ); + }); + + it('should handle account found by handle when connectedAccountId is not provided', async () => { + const existingAccount = { + id: 'existing-account-id', + handle: 'test@example.com', + accountOwnerId: 'workspace-member-id', + provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV, + } as ConnectedAccountWorkspaceEntity; + + mockConnectedAccountRepository.findOne.mockResolvedValueOnce( + existingAccount, + ); + + mockMessageChannelRepository.findOne.mockResolvedValue(null); + mockCalendarChannelRepository.findOne.mockResolvedValue(null); + + await service.processAccount(baseInput); + + expect(mockConnectedAccountRepository.findOne).toHaveBeenCalledWith({ + where: { + handle: 'test@example.com', + accountOwnerId: 'workspace-member-id', + }, + }); + + expect(mockConnectedAccountRepository.save).toHaveBeenCalledWith( + { + id: 'existing-account-id', + handle: 'test@example.com', + provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV, + connectionParameters: baseInput.connectionParameters, + accountOwnerId: 'workspace-member-id', + }, + {}, + ); + }); + + it('should not enqueue sync jobs when channels are disabled', async () => { + const disabledInput = { + ...baseInput, + connectionParameters: { + SMTP: { + host: 'smtp.example.com', + port: 587, + secure: true, + username: 'test@example.com', + password: 'password', + }, + } as EmailAccountConnectionParameters, + }; + + mockConnectedAccountRepository.findOne.mockResolvedValue(null); + mockMessageChannelRepository.findOne.mockResolvedValue(null); + mockCalendarChannelRepository.findOne.mockResolvedValue(null); + + await service.processAccount(disabledInput); + + expect(mockMessageQueueService.add).not.toHaveBeenCalled(); + expect(mockCalendarQueueService.add).not.toHaveBeenCalled(); + }); + + it('should handle transaction correctly', async () => { + mockConnectedAccountRepository.findOne.mockResolvedValue(null); + mockMessageChannelRepository.findOne.mockResolvedValue(null); + mockCalendarChannelRepository.findOne.mockResolvedValue(null); + mockMessageChannelRepository.save.mockResolvedValue({ + id: 'mocked-uuid', + connectedAccountId: 'mocked-uuid', + }); + + await service.processAccount(baseInput); + + expect(mockWorkspaceDataSource.transaction).toHaveBeenCalledWith( + expect.any(Function), + ); + }); + }); +}); diff --git a/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.ts b/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.ts index c855283ea4..69a873d456 100644 --- a/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.ts +++ b/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.ts @@ -40,7 +40,7 @@ export class ImapSmtpCalDavAPIService { private readonly calendarQueueService: MessageQueueService, ) {} - async setupCompleteAccount(input: { + async processAccount(input: { handle: string; workspaceMemberId: string; workspaceId: string; @@ -83,8 +83,17 @@ export class ImapSmtpCalDavAPIService { workspaceId, }); - let createdMessageChannel: MessageChannelWorkspaceEntity | null = null; - let createdCalendarChannel: CalendarChannelWorkspaceEntity | null = null; + let messageChannel: MessageChannelWorkspaceEntity | null = existingAccount + ? await messageChannelRepository.findOne({ + where: { connectedAccountId: existingAccount.id }, + }) + : null; + + let calendarChannel: CalendarChannelWorkspaceEntity | null = existingAccount + ? await calendarChannelRepository.findOne({ + where: { connectedAccountId: existingAccount.id }, + }) + : null; await workspaceDataSource.transaction(async () => { await this.upsertConnectedAccount( @@ -93,24 +102,28 @@ export class ImapSmtpCalDavAPIService { connectedAccountRepository, ); - createdMessageChannel = await this.setupMessageChannels( - input, - accountId, - messageChannelRepository, - ); + if (!messageChannel) { + messageChannel = await this.setupMessageChannels( + input, + accountId, + messageChannelRepository, + ); + } - createdCalendarChannel = await this.setupCalendarChannels( - input, - accountId, - calendarChannelRepository, - ); + if (!calendarChannel) { + calendarChannel = await this.setupCalendarChannels( + input, + accountId, + calendarChannelRepository, + ); + } }); await this.enqueueSyncJobs( input, workspaceId, - createdMessageChannel, - createdCalendarChannel, + messageChannel, + calendarChannel, ); } @@ -144,16 +157,6 @@ export class ImapSmtpCalDavAPIService { accountId: string, messageChannelRepository: WorkspaceRepository, ): Promise { - const existingChannels = await messageChannelRepository.find({ - where: { connectedAccountId: accountId }, - }); - - if (existingChannels.length > 0) { - await messageChannelRepository.delete({ - connectedAccountId: accountId, - }); - } - const shouldEnableSync = Boolean(input.connectionParameters.IMAP); const newMessageChannel = await messageChannelRepository.save( @@ -187,16 +190,6 @@ export class ImapSmtpCalDavAPIService { accountId: string, calendarChannelRepository: WorkspaceRepository, ): Promise { - const existingChannels = await calendarChannelRepository.find({ - where: { connectedAccountId: accountId }, - }); - - if (existingChannels.length > 0) { - await calendarChannelRepository.delete({ - connectedAccountId: accountId, - }); - } - const shouldEnableSync = Boolean(input.connectionParameters.CALDAV); if (shouldEnableSync) { diff --git a/packages/twenty-server/src/modules/messaging/message-folder-manager/services/sync-message-folders.service.ts b/packages/twenty-server/src/modules/messaging/message-folder-manager/services/sync-message-folders.service.ts index 24f50a100e..c7a2d95931 100644 --- a/packages/twenty-server/src/modules/messaging/message-folder-manager/services/sync-message-folders.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-folder-manager/services/sync-message-folders.service.ts @@ -82,6 +82,7 @@ export class SyncMessageFoldersService { { name: folder.name, externalId: folder.externalId, + isSentFolder: folder.isSentFolder, }, manager, );