[CONNECTED_ACCOUNT_BREAKING_CHANGE] Encrypt ConnectedAccount connectionParameters (#20673)

# Introduction
Prevent any cross user `connectedAccount` `connectionParamaters` leak
Also encrypt in db all `connectionParameters` password
Never return any password through `DTO` anymore
The settings now allow update mutation without providing the password in
edition mode

Verified all `connectionParameters.password` interaction

## Integration tests
- Added more coverage for both failing and successful paths
- Introduced a new env var that allow bypass the provider connection
test

## Legacy connected Account decryption support
Stop allowing non encrypted decryption on `accessToken` and
`refreshToken`, only allow legacy decryption on refactored
`connectionParameters`

## Upsert ownership
Completely got rid of the connected workspace schema context which is
legacy
Also now a user can only upsert a connected account for him only..

## New UI
<img width="1770" height="1852" alt="image"
src="https://github.com/user-attachments/assets/55c1dc89-42ff-4084-95e2-cc5f9e23753b"
/>
If in edition the password is by default disabled
It needs to be selected as being edited to be enabled

## Next
- Refactor tool permissions flag not to include connected accounts
- Remove the legacy connected standard object
- Refactor and improve connected account resolver auth
This commit is contained in:
Paul Rastoin
2026-05-19 14:56:44 +02:00
committed by GitHub
parent 72c0c36db5
commit 57f13c9b92
61 changed files with 2263 additions and 1340 deletions
@@ -2,12 +2,17 @@ import { Module } from '@nestjs/common';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { ConnectedAccountTokenEncryptionModule } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.module';
import { CalDavClientService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-client.service';
import { CalDavFetchEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-fetch-events.service';
import { CalDavGetEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-get-events.service';
@Module({
imports: [SecureHttpClientModule, TwentyConfigModule],
imports: [
SecureHttpClientModule,
TwentyConfigModule,
ConnectedAccountTokenEncryptionModule,
],
providers: [
CalDavClientService,
CalDavFetchEventsService,
@@ -1,9 +1,9 @@
import { Injectable, Logger } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service';
import { CalDavClientService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-client.service';
import { CalDavFetchEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-fetch-events.service';
import { type CalDavSyncCursor } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/types/caldav-sync-cursor';
@@ -20,28 +20,29 @@ export class CalDavGetEventsService {
constructor(
private readonly clientService: CalDavClientService,
private readonly fetchEventsService: CalDavFetchEventsService,
private readonly connectedAccountTokenEncryptionService: ConnectedAccountTokenEncryptionService,
) {}
async getCalendarEvents(
connectedAccount: Pick<
ConnectedAccountEntity,
'provider' | 'id' | 'connectionParameters' | 'handle'
'provider' | 'id' | 'connectionParameters' | 'handle' | 'workspaceId'
>,
syncCursor?: string,
): Promise<GetCalendarEventsResponse> {
this.logger.debug(`Getting calendar events for ${connectedAccount.handle}`);
try {
const params = connectedAccount.connectionParameters?.CALDAV;
if (
!isNonEmptyString(params?.host) ||
!isNonEmptyString(params?.password) ||
!isDefined(connectedAccount.handle)
) {
throw new Error('Missing required CalDAV connection parameters');
if (!isDefined(connectedAccount.connectionParameters?.CALDAV)) {
throw new Error('CalDAV settings not configured for this account');
}
const params =
this.connectedAccountTokenEncryptionService.decryptProtocolPassword({
protocolParams: connectedAccount.connectionParameters.CALDAV,
workspaceId: connectedAccount.workspaceId,
});
const client = await this.clientService.getClient({
serverUrl: params.host,
username: params.username ?? connectedAccount.handle,
@@ -8,6 +8,7 @@ import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { ConnectedAccountTokenEncryptionModule } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.module';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
@@ -35,6 +36,7 @@ import { MessagingFolderSyncManagerModule } from 'src/modules/messaging/message-
AuthModule,
CalendarCommonModule,
ConnectedAccountModule,
ConnectedAccountTokenEncryptionModule,
MessagingCommonModule,
MessagingFolderSyncManagerModule,
],
@@ -9,7 +9,7 @@ import {
import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service';
import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service';
import { type EmailAccountConnectionParameters } from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection.dto';
import { type ImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
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 { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
@@ -19,6 +19,7 @@ import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channe
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/services/calendar-channel-sync-status.service';
import { CalendarEventListFetchJob } from 'src/modules/calendar/calendar-event-import-manager/jobs/calendar-event-list-fetch.job';
import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service';
import { AccountsToReconnectService } from 'src/modules/connected-account/services/accounts-to-reconnect.service';
import { ImapSmtpCalDavAPIService } from 'src/modules/connected-account/services/imap-smtp-caldav-apis.service';
import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/services/message-channel-sync-status.service';
@@ -76,7 +77,9 @@ describe('ImapSmtpCalDavAPIService', () => {
};
const mockUserWorkspaceRepository = {
findOne: jest.fn().mockResolvedValue({ id: 'user-workspace-id' }),
findOne: jest
.fn()
.mockResolvedValue({ id: 'user-workspace-id', userId: 'user-id' }),
};
const mockWorkspaceMemberRepository = {
@@ -114,6 +117,36 @@ describe('ImapSmtpCalDavAPIService', () => {
resetAndMarkAsCalendarEventListFetchPending: jest.fn(),
};
const encryptPassword = (password: string) => `enc:v2:${password}`;
const withEncryptedPasswords = (
params: ImapSmtpCaldavParams,
): ImapSmtpCaldavParams => {
const result: ImapSmtpCaldavParams = {};
for (const protocol of ['IMAP', 'SMTP', 'CALDAV'] as const) {
if (params[protocol]) {
result[protocol] = {
...params[protocol],
password: encryptPassword(params[protocol]!.password),
};
}
}
return result;
};
const mockConnectedAccountTokenEncryptionService = {
encryptConnectionParameters: jest.fn(
({
connectionParameters,
}: {
connectionParameters: ImapSmtpCaldavParams;
workspaceId: string;
}) => withEncryptedPasswords(connectionParameters),
),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
@@ -185,6 +218,10 @@ describe('ImapSmtpCalDavAPIService', () => {
provide: CalendarChannelSyncStatusService,
useValue: mockCalendarChannelSyncStatusService,
},
{
provide: ConnectedAccountTokenEncryptionService,
useValue: mockConnectedAccountTokenEncryptionService,
},
],
}).compile();
@@ -193,10 +230,10 @@ describe('ImapSmtpCalDavAPIService', () => {
jest.clearAllMocks();
});
describe('processAccount', () => {
describe('upsertConnectedAccount', () => {
const baseInput = {
handle: 'test@example.com',
workspaceMemberId: 'workspace-member-id',
userWorkspaceId: 'user-workspace-id',
workspaceId: 'workspace-id',
connectionParameters: {
IMAP: {
@@ -212,7 +249,7 @@ describe('ImapSmtpCalDavAPIService', () => {
username: 'test@example.com',
password: 'password',
},
} as EmailAccountConnectionParameters,
} as ImapSmtpCaldavParams,
};
it('should create new account with message channel when account does not exist and IMAP is configured', async () => {
@@ -225,15 +262,18 @@ describe('ImapSmtpCalDavAPIService', () => {
});
mockUserWorkspaceRepository.findOne.mockResolvedValue({
id: 'user-workspace-id',
userId: 'user-id',
});
await service.processAccount(baseInput);
await service.upsertConnectedAccount(baseInput);
expect(mockTransactionManagerSave).toHaveBeenCalledWith({
id: 'mocked-uuid',
handle: 'test@example.com',
provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV,
connectionParameters: baseInput.connectionParameters,
connectionParameters: withEncryptedPasswords(
baseInput.connectionParameters,
),
userWorkspaceId: 'user-workspace-id',
workspaceId: 'workspace-id',
authFailedAt: null,
@@ -286,6 +326,7 @@ describe('ImapSmtpCalDavAPIService', () => {
});
mockUserWorkspaceRepository.findOne.mockResolvedValue({
id: 'user-workspace-id',
userId: 'user-id',
});
const inputWithConnectedAccountId = {
@@ -299,17 +340,19 @@ describe('ImapSmtpCalDavAPIService', () => {
username: 'test@example.com',
password: 'password',
},
} as EmailAccountConnectionParameters,
} as ImapSmtpCaldavParams,
connectedAccountId: 'existing-account-id',
};
await service.processAccount(inputWithConnectedAccountId);
await service.upsertConnectedAccount(inputWithConnectedAccountId);
expect(mockTransactionManagerSave).toHaveBeenCalledWith({
id: 'existing-account-id',
handle: 'test@example.com',
provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV,
connectionParameters: inputWithConnectedAccountId.connectionParameters,
connectionParameters: withEncryptedPasswords(
inputWithConnectedAccountId.connectionParameters,
),
userWorkspaceId: 'user-workspace-id',
workspaceId: 'workspace-id',
authFailedAt: null,
@@ -374,11 +417,12 @@ describe('ImapSmtpCalDavAPIService', () => {
});
mockUserWorkspaceRepository.findOne.mockResolvedValue({
id: 'user-workspace-id',
userId: 'user-id',
});
await service.processAccount({
await service.upsertConnectedAccount({
...baseInput,
connectedAccountId: 'existing-account-id',
existingAccount,
});
expect(
@@ -397,9 +441,10 @@ describe('ImapSmtpCalDavAPIService', () => {
});
mockUserWorkspaceRepository.findOne.mockResolvedValue({
id: 'user-workspace-id',
userId: 'user-id',
});
await service.processAccount(baseInput);
await service.upsertConnectedAccount(baseInput);
expect(
mockAccountsToReconnectService.removeAccountToReconnect,
@@ -424,7 +469,7 @@ describe('ImapSmtpCalDavAPIService', () => {
secure: true,
password: 'password',
},
} as EmailAccountConnectionParameters,
} as ImapSmtpCaldavParams,
};
mockConnectedAccountRepository.findOne.mockResolvedValue(null);
@@ -436,9 +481,10 @@ describe('ImapSmtpCalDavAPIService', () => {
});
mockUserWorkspaceRepository.findOne.mockResolvedValue({
id: 'user-workspace-id',
userId: 'user-id',
});
await service.processAccount(imapOnlyInput);
await service.upsertConnectedAccount(imapOnlyInput);
expect(
mockCreateMessageChannelService.createMessageChannel,
@@ -459,7 +505,7 @@ describe('ImapSmtpCalDavAPIService', () => {
username: 'test@example.com',
password: 'password',
},
} as EmailAccountConnectionParameters,
} as ImapSmtpCaldavParams,
};
mockConnectedAccountRepository.findOne.mockResolvedValue(null);
@@ -471,9 +517,10 @@ describe('ImapSmtpCalDavAPIService', () => {
});
mockUserWorkspaceRepository.findOne.mockResolvedValue({
id: 'user-workspace-id',
userId: 'user-id',
});
await service.processAccount(caldavOnlyInput);
await service.upsertConnectedAccount(caldavOnlyInput);
expect(
mockCreateMessageChannelService.createMessageChannel,
@@ -500,7 +547,7 @@ describe('ImapSmtpCalDavAPIService', () => {
username: 'test@example.com',
password: 'password',
},
} as EmailAccountConnectionParameters,
} as ImapSmtpCaldavParams,
};
mockConnectedAccountRepository.findOne.mockResolvedValue(null);
@@ -512,9 +559,10 @@ describe('ImapSmtpCalDavAPIService', () => {
});
mockUserWorkspaceRepository.findOne.mockResolvedValue({
id: 'user-workspace-id',
userId: 'user-id',
});
await service.processAccount(imapSmtpInput);
await service.upsertConnectedAccount(imapSmtpInput);
expect(
mockCreateMessageChannelService.createMessageChannel,
@@ -548,7 +596,7 @@ describe('ImapSmtpCalDavAPIService', () => {
username: 'test@example.com',
password: 'password',
},
} as EmailAccountConnectionParameters,
} as ImapSmtpCaldavParams,
};
mockConnectedAccountRepository.findOne.mockResolvedValue(null);
@@ -560,9 +608,10 @@ describe('ImapSmtpCalDavAPIService', () => {
});
mockUserWorkspaceRepository.findOne.mockResolvedValue({
id: 'user-workspace-id',
userId: 'user-id',
});
await service.processAccount(fullConfigInput);
await service.upsertConnectedAccount(fullConfigInput);
expect(
mockCreateMessageChannelService.createMessageChannel,
@@ -592,9 +641,10 @@ describe('ImapSmtpCalDavAPIService', () => {
});
mockUserWorkspaceRepository.findOne.mockResolvedValue({
id: 'user-workspace-id',
userId: 'user-id',
});
await service.processAccount(baseInput);
await service.upsertConnectedAccount(baseInput);
expect(mockConnectedAccountRepository.findOne).toHaveBeenCalledWith({
where: {
@@ -608,7 +658,9 @@ describe('ImapSmtpCalDavAPIService', () => {
id: 'existing-account-id',
handle: 'test@example.com',
provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV,
connectionParameters: baseInput.connectionParameters,
connectionParameters: withEncryptedPasswords(
baseInput.connectionParameters,
),
userWorkspaceId: 'user-workspace-id',
workspaceId: 'workspace-id',
authFailedAt: null,
@@ -626,7 +678,7 @@ describe('ImapSmtpCalDavAPIService', () => {
username: 'test@example.com',
password: 'password',
},
} as EmailAccountConnectionParameters,
} as ImapSmtpCaldavParams,
};
mockConnectedAccountRepository.findOne.mockResolvedValue(null);
@@ -638,9 +690,10 @@ describe('ImapSmtpCalDavAPIService', () => {
});
mockUserWorkspaceRepository.findOne.mockResolvedValue({
id: 'user-workspace-id',
userId: 'user-id',
});
await service.processAccount(smtpOnlyInput);
await service.upsertConnectedAccount(smtpOnlyInput);
expect(
mockCreateMessageChannelService.createMessageChannel,
@@ -660,9 +713,10 @@ describe('ImapSmtpCalDavAPIService', () => {
});
mockUserWorkspaceRepository.findOne.mockResolvedValue({
id: 'user-workspace-id',
userId: 'user-id',
});
await service.processAccount(baseInput);
await service.upsertConnectedAccount(baseInput);
expect(
mockConnectedAccountRepository.manager.transaction,
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import {
@@ -7,24 +7,21 @@ import {
MessageChannelSyncStage,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { EntityManager, Repository } from 'typeorm';
import { v4 } from 'uuid';
import { NotFoundError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service';
import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service';
import { type EmailAccountConnectionParameters } from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection.dto';
import { NotFoundError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { type ImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { getWorkspaceContext } from 'src/engine/twenty-orm/storage/orm-workspace-context.storage';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { resolveRolePermissionConfig } from 'src/engine/twenty-orm/utils/resolve-role-permission-config.util';
import {
CalendarEventListFetchJob,
type CalendarEventListFetchJobData,
@@ -32,17 +29,17 @@ import {
import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/services/calendar-channel-sync-status.service';
import { AccountsToReconnectService } from 'src/modules/connected-account/services/accounts-to-reconnect.service';
import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/services/message-channel-sync-status.service';
import { SyncMessageFoldersService } from 'src/modules/messaging/message-folder-manager/services/sync-message-folders.service';
import {
MessagingMessageListFetchJob,
type MessagingMessageListFetchJobData,
} from 'src/modules/messaging/message-import-manager/jobs/messaging-message-list-fetch.job';
import { SyncMessageFoldersService } from 'src/modules/messaging/message-folder-manager/services/sync-message-folders.service';
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Injectable()
export class ImapSmtpCalDavAPIService {
private readonly logger = new Logger(ImapSmtpCalDavAPIService.name);
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
@InjectRepository(CalendarChannelEntity)
private readonly calendarChannelRepository: Repository<CalendarChannelEntity>,
@InjectRepository(ConnectedAccountEntity)
@@ -61,210 +58,161 @@ export class ImapSmtpCalDavAPIService {
private readonly accountsToReconnectService: AccountsToReconnectService,
private readonly messagingChannelSyncStatusService: MessageChannelSyncStatusService,
private readonly calendarChannelSyncStatusService: CalendarChannelSyncStatusService,
private readonly connectedAccountTokenEncryptionService: ConnectedAccountTokenEncryptionService,
) {}
async getImapSmtpCaldavConnectedAccount(
workspaceId: string,
id: string,
): Promise<ConnectedAccountEntity | null> {
const authContext = buildSystemAuthContext(workspaceId);
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const connectedAccount = await this.connectedAccountRepository.findOne({
where: {
id,
provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV,
workspaceId,
},
});
return connectedAccount;
},
authContext,
);
}
async processAccount(input: {
async upsertConnectedAccount(input: {
handle: string;
workspaceMemberId: string;
userWorkspaceId: string;
workspaceId: string;
connectionParameters: EmailAccountConnectionParameters;
connectedAccountId?: string;
connectionParameters: ImapSmtpCaldavParams;
existingAccount?: ConnectedAccountEntity | null;
}): Promise<string> {
const { handle, workspaceId, workspaceMemberId, connectedAccountId } =
input;
const { handle, workspaceId, userWorkspaceId } = input;
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const workspaceContext = getWorkspaceContext();
const rolePermissionConfig = resolveRolePermissionConfig({
authContext: workspaceContext.authContext,
userWorkspaceRoleMap: workspaceContext.userWorkspaceRoleMap,
apiKeyRoleMap: workspaceContext.apiKeyRoleMap,
});
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: { id: userWorkspaceId, workspaceId },
});
const workspaceMemberRepo =
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
rolePermissionConfig ?? undefined,
);
if (!isDefined(userWorkspace)) {
throw new NotFoundError(
`UserWorkspace with id ${userWorkspaceId} not found in workspace ${workspaceId}`,
);
}
const member = await workspaceMemberRepo.findOne({
where: { id: workspaceMemberId },
});
const existingAccount =
input.existingAccount ??
(await this.connectedAccountRepository.findOne({
where: { handle, userWorkspaceId, workspaceId },
}));
if (!member) {
throw new NotFoundError(
`Workspace member with id ${workspaceMemberId} not found`,
);
}
const newOrExistingAccountId = existingAccount?.id ?? v4();
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: { userId: member.userId, workspaceId },
});
const existingMessageChannel = existingAccount
? await this.messageChannelRepository.findOne({
where: { connectedAccountId: existingAccount.id, workspaceId },
})
: null;
if (!userWorkspace) {
throw new NotFoundError(
`UserWorkspace not found for userId ${member.userId} in workspace ${workspaceId}`,
);
}
const existingCalendarChannel = existingAccount
? await this.calendarChannelRepository.findOne({
where: { connectedAccountId: existingAccount.id, workspaceId },
})
: null;
const userWorkspaceId = userWorkspace.id;
const shouldCreateMessageChannel =
!isDefined(existingMessageChannel) &&
Boolean(input.connectionParameters.IMAP);
const existingAccount = connectedAccountId
? await this.connectedAccountRepository.findOne({
where: { id: connectedAccountId, workspaceId },
})
: await this.connectedAccountRepository.findOne({
where: {
handle,
userWorkspaceId,
workspaceId,
},
});
const shouldCreateCalendarChannel =
!isDefined(existingCalendarChannel) &&
Boolean(input.connectionParameters.CALDAV);
const newOrExistingAccountId =
existingAccount?.id ?? connectedAccountId ?? v4();
const existingMessageChannel = existingAccount
? await this.messageChannelRepository.findOne({
where: { connectedAccountId: existingAccount.id, workspaceId },
})
: null;
const existingCalendarChannel = existingAccount
? await this.calendarChannelRepository.findOne({
where: { connectedAccountId: existingAccount.id, workspaceId },
})
: null;
const shouldCreateMessageChannel =
!isDefined(existingMessageChannel) &&
Boolean(input.connectionParameters.IMAP);
const shouldCreateCalendarChannel =
!isDefined(existingCalendarChannel) &&
Boolean(input.connectionParameters.CALDAV);
await this.connectedAccountRepository.manager.transaction(
async (transactionManager: EntityManager) => {
await transactionManager
.getRepository(ConnectedAccountEntity)
.save({
id: newOrExistingAccountId,
handle,
provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV,
connectionParameters: input.connectionParameters,
userWorkspaceId,
workspaceId,
authFailedAt: null,
});
if (shouldCreateMessageChannel) {
await this.createMessageChannelService.createMessageChannel({
workspaceId,
connectedAccountId: newOrExistingAccountId,
handle,
transactionManager,
});
}
if (shouldCreateCalendarChannel) {
await this.createCalendarChannelService.createCalendarChannel({
workspaceId,
connectedAccountId: newOrExistingAccountId,
handle,
transactionManager,
});
}
},
);
if (isDefined(existingAccount)) {
await this.accountsToReconnectService.removeAccountToReconnect(
member.userId,
workspaceId,
newOrExistingAccountId,
);
}
if (shouldCreateMessageChannel) {
const newMessageChannel = await this.messageChannelRepository.findOne(
await this.connectedAccountRepository.manager.transaction(
async (transactionManager: EntityManager) => {
const encryptedConnectionParameters =
this.connectedAccountTokenEncryptionService.encryptConnectionParameters(
{
where: {
connectedAccountId: newOrExistingAccountId,
workspaceId,
},
relations: ['connectedAccount', 'messageFolders'],
connectionParameters: input.connectionParameters,
workspaceId,
},
);
if (isDefined(newMessageChannel)) {
await this.syncMessageFoldersService.syncMessageFolders({
messageChannel: newMessageChannel,
workspaceId,
});
}
}
await transactionManager.getRepository(ConnectedAccountEntity).save({
id: newOrExistingAccountId,
handle,
provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV,
connectionParameters: encryptedConnectionParameters,
userWorkspaceId,
workspaceId,
authFailedAt: null,
});
if (
isDefined(existingMessageChannel) &&
isDefined(input.connectionParameters.IMAP) &&
existingMessageChannel.syncStage !==
MessageChannelSyncStage.PENDING_CONFIGURATION
) {
await this.messagingChannelSyncStatusService.resetAndMarkAsMessagesListFetchPending(
[existingMessageChannel.id],
if (shouldCreateMessageChannel) {
await this.createMessageChannelService.createMessageChannel({
workspaceId,
);
await this.messageQueueService.add<MessagingMessageListFetchJobData>(
MessagingMessageListFetchJob.name,
{ workspaceId, messageChannelId: existingMessageChannel.id },
);
connectedAccountId: newOrExistingAccountId,
handle,
transactionManager,
});
}
if (
isDefined(existingCalendarChannel) &&
isDefined(input.connectionParameters.CALDAV) &&
existingCalendarChannel.syncStage !==
CalendarChannelSyncStage.PENDING_CONFIGURATION
) {
await this.calendarChannelSyncStatusService.resetAndMarkAsCalendarEventListFetchPending(
[existingCalendarChannel.id],
if (shouldCreateCalendarChannel) {
await this.createCalendarChannelService.createCalendarChannel({
workspaceId,
);
await this.calendarQueueService.add<CalendarEventListFetchJobData>(
CalendarEventListFetchJob.name,
{ workspaceId, calendarChannelId: existingCalendarChannel.id },
);
connectedAccountId: newOrExistingAccountId,
handle,
transactionManager,
});
}
return newOrExistingAccountId;
},
);
if (isDefined(existingAccount)) {
await this.accountsToReconnectService.removeAccountToReconnect(
userWorkspace.userId,
workspaceId,
newOrExistingAccountId,
);
}
if (shouldCreateMessageChannel) {
const newMessageChannel = await this.messageChannelRepository.findOne({
where: {
connectedAccountId: newOrExistingAccountId,
workspaceId,
},
relations: ['connectedAccount', 'messageFolders'],
});
if (isDefined(newMessageChannel)) {
try {
await this.syncMessageFoldersService.syncMessageFolders({
messageChannel: newMessageChannel,
workspaceId,
});
} catch (error) {
this.logger.warn(
`Initial folder sync failed for account ${newOrExistingAccountId}, will retry on next scheduled sync: ${error?.message}`,
);
}
}
}
if (
isDefined(existingMessageChannel) &&
isDefined(input.connectionParameters.IMAP) &&
existingMessageChannel.syncStage !==
MessageChannelSyncStage.PENDING_CONFIGURATION
) {
await this.messagingChannelSyncStatusService.resetAndMarkAsMessagesListFetchPending(
[existingMessageChannel.id],
workspaceId,
);
await this.messageQueueService.add<MessagingMessageListFetchJobData>(
MessagingMessageListFetchJob.name,
{ workspaceId, messageChannelId: existingMessageChannel.id },
);
}
if (
isDefined(existingCalendarChannel) &&
isDefined(input.connectionParameters.CALDAV) &&
existingCalendarChannel.syncStage !==
CalendarChannelSyncStage.PENDING_CONFIGURATION
) {
await this.calendarChannelSyncStatusService.resetAndMarkAsCalendarEventListFetchPending(
[existingCalendarChannel.id],
workspaceId,
);
await this.calendarQueueService.add<CalendarEventListFetchJobData>(
CalendarEventListFetchJob.name,
{ workspaceId, calendarChannelId: existingCalendarChannel.id },
);
}
return newOrExistingAccountId;
}
}
@@ -28,12 +28,13 @@ const createMockMailbox = (
const CONNECTED_ACCOUNT: Pick<
ConnectedAccountEntity,
'id' | 'provider' | 'connectionParameters' | 'handle'
'id' | 'provider' | 'connectionParameters' | 'handle' | 'workspaceId'
> = {
id: 'account-1',
provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV,
connectionParameters: {},
handle: 'test@example.com',
workspaceId: 'workspace-1',
};
const MESSAGE_CHANNEL: Pick<MessageChannelEntity, 'messageFolderImportPolicy'> =
@@ -29,7 +29,7 @@ export class ImapGetAllFoldersService implements MessageFolderDriver {
public async getAllMessageFolders(
connectedAccount: Pick<
ConnectedAccountEntity,
'id' | 'provider' | 'connectionParameters' | 'handle'
'id' | 'provider' | 'connectionParameters' | 'handle' | 'workspaceId'
>,
messageChannel: Pick<MessageChannelEntity, 'messageFolderImportPolicy'>,
): Promise<DiscoveredMessageFolder[]> {
@@ -5,6 +5,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
import { ConnectedAccountTokenEncryptionModule } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.module';
import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { BlocklistWorkspaceEntity } from 'src/modules/blocklist/standard-objects/blocklist.workspace-entity';
@@ -31,6 +32,7 @@ import { MessageParticipantManagerModule } from 'src/modules/messaging/message-p
EmailAliasManagerModule,
FeatureFlagModule,
SecureHttpClientModule,
ConnectedAccountTokenEncryptionModule,
WorkspaceDataSourceModule,
MessageParticipantManagerModule,
],
@@ -2,17 +2,16 @@ import { Injectable, Logger } from '@nestjs/common';
import { ImapFlow } from 'imapflow';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { CustomError, isDefined } from 'twenty-shared/utils';
import { isDefined } from 'twenty-shared/utils';
import { type ImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { MessageImportDriverExceptionCode } from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service';
import { parseImapAuthenticationError } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/parse-imap-authentication-error.util';
type ConnectedAccountIdentifier = Pick<
ConnectedAccountEntity,
'id' | 'provider' | 'connectionParameters' | 'handle'
'id' | 'provider' | 'connectionParameters' | 'handle' | 'workspaceId'
>;
@Injectable()
@@ -24,6 +23,7 @@ export class ImapClientProvider {
constructor(
private readonly secureHttpClientService: SecureHttpClientService,
private readonly connectedAccountTokenEncryptionService: ConnectedAccountTokenEncryptionService,
) {}
async getClient(
@@ -60,31 +60,24 @@ export class ImapClientProvider {
throw new Error('Connected account is not an IMAP provider');
}
const connectionParameters: ImapSmtpCaldavParams =
(connectedAccount.connectionParameters as unknown as ImapSmtpCaldavParams) ||
{};
if (!isDefined(connectedAccount.handle)) {
throw new CustomError(
'Handle is required',
MessageImportDriverExceptionCode.CHANNEL_MISCONFIGURED,
);
}
const imapParams =
this.connectedAccountTokenEncryptionService.decryptProtocolPassword({
protocolParams: connectedAccount.connectionParameters.IMAP,
workspaceId: connectedAccount.workspaceId,
});
const validatedImapHost =
await this.secureHttpClientService.getValidatedHost(
connectionParameters.IMAP?.host || '',
);
await this.secureHttpClientService.getValidatedHost(imapParams.host);
const client = new ImapFlow({
host: validatedImapHost,
port: connectionParameters.IMAP?.port || 993,
secure: connectionParameters.IMAP?.secure,
port: imapParams.port || 993,
secure: imapParams.secure,
auth: {
user: isDefined(connectionParameters.IMAP?.username)
? connectionParameters.IMAP?.username
user: isDefined(imapParams.username)
? imapParams.username
: connectedAccount.handle,
pass: connectionParameters.IMAP?.password || '',
pass: imapParams.password,
},
logger: false,
tls: {
@@ -18,7 +18,12 @@ import { sanitizeString } from 'src/modules/messaging/message-import-manager/uti
type ConnectedAccount = Pick<
ConnectedAccountEntity,
'id' | 'provider' | 'handle' | 'handleAliases' | 'connectionParameters'
| 'id'
| 'provider'
| 'handle'
| 'handleAliases'
| 'connectionParameters'
| 'workspaceId'
>;
@Injectable()
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
import { ConnectedAccountTokenEncryptionModule } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.module';
import { SmtpClientProvider } from './providers/smtp-client.provider';
@Module({
imports: [SecureHttpClientModule],
imports: [SecureHttpClientModule, ConnectedAccountTokenEncryptionModule],
providers: [SmtpClientProvider],
exports: [SmtpClientProvider],
})
@@ -1,31 +1,42 @@
import { Injectable } from '@nestjs/common';
import { createTransport, type Transporter } from 'nodemailer';
import { isDefined } from 'twenty-shared/utils';
import type SMTPConnection from 'nodemailer/lib/smtp-connection';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service';
@Injectable()
export class SmtpClientProvider {
constructor(
private readonly secureHttpClientService: SecureHttpClientService,
private readonly connectedAccountTokenEncryptionService: ConnectedAccountTokenEncryptionService,
) {}
public async getSmtpClient(
connectedAccount: Pick<
ConnectedAccountEntity,
'connectionParameters' | 'handle'
'provider' | 'connectionParameters' | 'handle' | 'workspaceId'
>,
): Promise<Transporter> {
const smtpParams = connectedAccount.connectionParameters?.SMTP;
if (!isDefined(smtpParams)) {
throw new Error('SMTP settings not configured for this account');
if (
connectedAccount.provider !== ConnectedAccountProvider.IMAP_SMTP_CALDAV ||
!isDefined(connectedAccount.connectionParameters?.SMTP)
) {
throw new Error('Connected account is not an SMTP provider');
}
const smtpParams =
this.connectedAccountTokenEncryptionService.decryptProtocolPassword({
protocolParams: connectedAccount.connectionParameters.SMTP,
workspaceId: connectedAccount.workspaceId,
});
const validatedSmtpHost =
await this.secureHttpClientService.getValidatedHost(smtpParams.host);