Fix/preserve channels on credential update (#14633)

https://github.com/user-attachments/assets/6165f177-c32d-4ddf-8991-3986f01897ee


/closes #14522
This commit is contained in:
neo773
2025-09-23 12:52:39 +05:30
committed by GitHub
parent 6f49481095
commit 0011864f9f
4 changed files with 567 additions and 36 deletions
@@ -103,7 +103,7 @@ export class ImapSmtpCaldavResolver {
handle,
);
await this.imapSmtpCaldavApisService.setupCompleteAccount({
await this.imapSmtpCaldavApisService.processAccount({
handle,
workspaceMemberId: accountOwnerId,
workspaceId: workspace.id,
@@ -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>(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),
);
});
});
});
@@ -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<MessageChannelWorkspaceEntity>,
): Promise<MessageChannelWorkspaceEntity | null> {
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<CalendarChannelWorkspaceEntity>,
): Promise<CalendarChannelWorkspaceEntity | null> {
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) {
@@ -82,6 +82,7 @@ export class SyncMessageFoldersService {
{
name: folder.name,
externalId: folder.externalId,
isSentFolder: folder.isSentFolder,
},
manager,
);