[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
@@ -3,7 +3,33 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ImapSmtpCaldavConnectionParametersDTO } from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection.dto';
@ObjectType('ImapSmtpCaldavPublicConnectionParams')
class PublicConnectionParamsDTO {
@Field(() => String)
host: string;
@Field(() => Number)
port: number;
@Field(() => String, { nullable: true })
username?: string;
@Field(() => Boolean, { nullable: true })
secure?: boolean;
}
@ObjectType('ImapSmtpCaldavPublicConnectionParameters')
class ImapSmtpCaldavPublicConnectionParametersDTO {
@Field(() => PublicConnectionParamsDTO, { nullable: true })
IMAP?: PublicConnectionParamsDTO;
@Field(() => PublicConnectionParamsDTO, { nullable: true })
SMTP?: PublicConnectionParamsDTO;
@Field(() => PublicConnectionParamsDTO, { nullable: true })
CALDAV?: PublicConnectionParamsDTO;
}
@ObjectType('ConnectedImapSmtpCaldavAccount')
export class ConnectedImapSmtpCaldavAccountDTO {
@@ -19,6 +45,6 @@ export class ConnectedImapSmtpCaldavAccountDTO {
@Field(() => UUIDScalarType)
userWorkspaceId: string;
@Field(() => ImapSmtpCaldavConnectionParametersDTO, { nullable: true })
connectionParameters: ImapSmtpCaldavConnectionParametersDTO | null;
@Field(() => ImapSmtpCaldavPublicConnectionParametersDTO, { nullable: true })
connectionParameters: ImapSmtpCaldavPublicConnectionParametersDTO | null;
}
@@ -1,72 +0,0 @@
import { Field, InputType, ObjectType } from '@nestjs/graphql';
@InputType()
export class AccountType {
@Field(() => String)
type: 'IMAP' | 'SMTP' | 'CALDAV';
}
@InputType()
export class ConnectionParameters {
@Field(() => String)
host: string;
@Field(() => Number)
port: number;
@Field(() => String, { nullable: true })
username?: string;
/**
* Note: This field is stored in plain text in the database.
* While encrypting it could provide an extra layer of defense, we have decided not to,
* as database access implies a broader compromise. For context, see discussion in PR #12576.
*/
@Field(() => String)
password: string;
@Field(() => Boolean, { nullable: true })
secure?: boolean;
}
@InputType()
export class EmailAccountConnectionParameters {
@Field(() => ConnectionParameters, { nullable: true })
IMAP?: ConnectionParameters;
@Field(() => ConnectionParameters, { nullable: true })
SMTP?: ConnectionParameters;
@Field(() => ConnectionParameters, { nullable: true })
CALDAV?: ConnectionParameters;
}
@ObjectType('ConnectionParametersOutput')
export class ConnectionParametersDTO {
@Field(() => String)
host: string;
@Field(() => Number)
port: number;
@Field(() => String, { nullable: true })
username?: string;
@Field(() => String)
password: string;
@Field(() => Boolean, { nullable: true })
secure?: boolean;
}
@ObjectType('ImapSmtpCaldavConnectionParameters')
export class ImapSmtpCaldavConnectionParametersDTO {
@Field(() => ConnectionParametersDTO, { nullable: true })
IMAP?: ConnectionParametersDTO;
@Field(() => ConnectionParametersDTO, { nullable: true })
SMTP?: ConnectionParametersDTO;
@Field(() => ConnectionParametersDTO, { nullable: true })
CALDAV?: ConnectionParametersDTO;
}
@@ -0,0 +1,37 @@
import { Field, InputType } from '@nestjs/graphql';
@InputType()
export class AccountType {
@Field(() => String)
type: 'IMAP' | 'SMTP' | 'CALDAV';
}
@InputType()
export class ConnectionParametersInput {
@Field(() => String)
host: string;
@Field(() => Number)
port: number;
@Field(() => String, { nullable: true })
username?: string;
@Field(() => String, { nullable: true })
password?: string;
@Field(() => Boolean, { nullable: true })
secure?: boolean;
}
@InputType('EmailAccountConnectionParameters')
export class EmailAccountConnectionParametersInput {
@Field(() => ConnectionParametersInput, { nullable: true })
IMAP?: ConnectionParametersInput;
@Field(() => ConnectionParametersInput, { nullable: true })
SMTP?: ConnectionParametersInput;
@Field(() => ConnectionParametersInput, { nullable: true })
CALDAV?: ConnectionParametersInput;
}
@@ -1,11 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { ImapSmtpCaldavValidatorModule } from 'src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection-validator.module';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
import { MessageQueueModule } from 'src/engine/core-modules/message-queue/message-queue.module';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { ConnectedAccountMetadataModule } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.module';
import { ConnectedAccountTokenEncryptionModule } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { CalDavDriverModule } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/caldav-driver.module';
@@ -21,11 +21,12 @@ import { ImapSmtpCaldavService } from './services/imap-smtp-caldav-connection.se
@Module({
imports: [
ConnectedAccountModule,
ConnectedAccountMetadataModule,
ConnectedAccountTokenEncryptionModule,
MessagingIMAPDriverModule,
IMAPAPIsModule,
MessagingImportManagerModule,
MessageQueueModule,
TypeOrmModule.forFeature([ConnectedAccountEntity]),
TwentyORMModule,
FeatureFlagModule,
ImapSmtpCaldavValidatorModule,
@@ -2,23 +2,27 @@ import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { ConnectedImapSmtpCaldavAccountDTO } from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connected-account.dto';
import { ImapSmtpCaldavConnectionSuccessDTO } from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection-success.dto';
import { EmailAccountConnectionParameters } from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection.dto';
import { ImapSmtpCaldavValidatorService } from 'src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection-validator.service';
import { EmailAccountConnectionParametersInput } from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection.input';
import { ImapSmtpCaldavService } from 'src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection.service';
import { buildPublicConnectionParameters } from 'src/engine/core-modules/imap-smtp-caldav-connection/utils/build-public-connection-parameters.util';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ImapSmtpCalDavAPIService } from 'src/modules/connected-account/services/imap-smtp-caldav-apis.service';
@MetadataResolver()
@@ -26,9 +30,10 @@ import { ImapSmtpCalDavAPIService } from 'src/modules/connected-account/services
@UseFilters(AuthGraphqlApiExceptionFilter, PermissionsGraphqlApiExceptionFilter)
export class ImapSmtpCaldavResolver {
constructor(
private readonly ImapSmtpCaldavConnectionService: ImapSmtpCaldavService,
private readonly imapSmtpCaldavService: ImapSmtpCaldavService,
private readonly imapSmtpCaldavApisService: ImapSmtpCalDavAPIService,
private readonly mailConnectionValidatorService: ImapSmtpCaldavValidatorService,
private readonly connectedAccountMetadataService: ConnectedAccountMetadataService,
private readonly connectedAccountTokenEncryptionService: ConnectedAccountTokenEncryptionService,
) {}
@Query(() => ConnectedImapSmtpCaldavAccountDTO)
@@ -39,24 +44,29 @@ export class ImapSmtpCaldavResolver {
async getConnectedImapSmtpCaldavAccount(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<ConnectedImapSmtpCaldavAccountDTO> {
const connectedAccount =
await this.imapSmtpCaldavApisService.getImapSmtpCaldavConnectedAccount(
workspace.id,
await this.connectedAccountMetadataService.findByIdAndUserWorkspaceId({
id,
);
userWorkspaceId,
workspaceId: workspace.id,
});
if (!isDefined(connectedAccount) || !isDefined(connectedAccount?.handle)) {
throw new UserInputError(
`Connected mail account with ID ${id} not found`,
);
if (
!isDefined(connectedAccount) ||
connectedAccount.provider !== ConnectedAccountProvider.IMAP_SMTP_CALDAV
) {
throw new UserInputError('Connected account not found');
}
return {
id: connectedAccount.id,
handle: connectedAccount.handle,
provider: connectedAccount.provider,
connectionParameters: connectedAccount.connectionParameters,
connectionParameters: buildPublicConnectionParameters(
connectedAccount.connectionParameters,
),
userWorkspaceId: connectedAccount.userWorkspaceId,
};
}
@@ -67,26 +77,52 @@ export class ImapSmtpCaldavResolver {
SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS),
)
async saveImapSmtpCaldavAccount(
@Args('accountOwnerId', { type: () => UUIDScalarType })
accountOwnerId: string,
@Args('handle') handle: string,
@Args('connectionParameters')
connectionParameters: EmailAccountConnectionParameters,
connectionParameters: EmailAccountConnectionParametersInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
@Args('id', { type: () => UUIDScalarType, nullable: true }) id?: string,
): Promise<ImapSmtpCaldavConnectionSuccessDTO> {
const validatedParams = await this.validateAndTestConnectionParameters(
connectionParameters,
handle,
);
const existingAccount = isDefined(id)
? await this.connectedAccountMetadataService.findByIdAndUserWorkspaceId({
id,
userWorkspaceId,
workspaceId: workspace.id,
})
: null;
if (
isDefined(id) &&
(!existingAccount ||
existingAccount.provider !== ConnectedAccountProvider.IMAP_SMTP_CALDAV)
) {
throw new UserInputError('Connected account not found');
}
const decryptedExistingParams = existingAccount?.connectionParameters
? this.connectedAccountTokenEncryptionService.decryptConnectionParameters(
{
connectionParameters: existingAccount.connectionParameters,
workspaceId: workspace.id,
},
)
: null;
const validatedParams =
await this.imapSmtpCaldavService.validateAndTestConnectionParameters({
connectionParameters,
handle,
existingConnectionParameters: decryptedExistingParams,
});
const connectedAccountId =
await this.imapSmtpCaldavApisService.processAccount({
await this.imapSmtpCaldavApisService.upsertConnectedAccount({
handle,
workspaceMemberId: accountOwnerId,
userWorkspaceId,
workspaceId: workspace.id,
connectionParameters: validatedParams,
connectedAccountId: id,
existingAccount,
});
return {
@@ -94,34 +130,4 @@ export class ImapSmtpCaldavResolver {
connectedAccountId,
};
}
private async validateAndTestConnectionParameters(
connectionParameters: EmailAccountConnectionParameters,
handle: string,
): Promise<EmailAccountConnectionParameters> {
const validatedParams: EmailAccountConnectionParameters = {};
const protocols = ['IMAP', 'SMTP', 'CALDAV'] as const;
for (const protocol of protocols) {
const params = connectionParameters[protocol];
if (params) {
validatedParams[protocol] =
await this.mailConnectionValidatorService.validateProtocolConnectionParams(
params,
);
const validatedProtocolParams = validatedParams[protocol];
if (validatedProtocolParams) {
await this.ImapSmtpCaldavConnectionService.testImapSmtpCaldav(
handle,
validatedProtocolParams,
protocol,
);
}
}
}
return validatedParams;
}
}
@@ -0,0 +1,8 @@
import { z } from 'zod';
import { connectionParametersSchema } from 'src/engine/core-modules/imap-smtp-caldav-connection/schemas/connection-parameters.schema';
export const connectionParametersUpdateSchema =
connectionParametersSchema.extend({
password: z.string().min(1, 'Password is required').optional(),
});
@@ -0,0 +1,9 @@
import { z } from 'zod';
export const connectionParametersSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.int().positive('Port must be a positive number'),
username: z.string().optional(),
password: z.string().min(1, 'Password is required'),
secure: z.boolean().optional(),
});
@@ -1,13 +1,12 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { type DAVClient } from 'tsdav';
import { ImapSmtpCaldavValidatorService } from 'src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection-validator.service';
import { ImapSmtpCaldavService } from 'src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection.service';
import { type ConnectionParameters } 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 { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.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';
@@ -34,9 +33,12 @@ describe('ImapSmtpCaldavService', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ImapSmtpCaldavService,
{ provide: GlobalWorkspaceOrmManager, useValue: {} },
{ provide: getRepositoryToken(ConnectedAccountEntity), useValue: {} },
{ provide: SecureHttpClientService, useValue: {} },
{ provide: ImapSmtpCaldavValidatorService, useValue: {} },
{
provide: TwentyConfigService,
useValue: { get: jest.fn().mockReturnValue(true) },
},
{
provide: CalDavClientService,
useValue: mockCalDavClientService,
@@ -1,11 +1,14 @@
import { Injectable } from '@nestjs/common';
import { msg } from '@lingui/core/macro';
import { z } from 'zod';
import { isNonEmptyString } from '@sniptt/guards';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { ConnectionParametersInput } from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection.input';
import { connectionParametersUpdateSchema } from 'src/engine/core-modules/imap-smtp-caldav-connection/schemas/connection-parameters-update.schema';
import { connectionParametersSchema } from 'src/engine/core-modules/imap-smtp-caldav-connection/schemas/connection-parameters.schema';
import { type ConnectionParameters } 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';
@Injectable()
export class ImapSmtpCaldavValidatorService {
@@ -13,59 +16,63 @@ export class ImapSmtpCaldavValidatorService {
private readonly secureHttpClientService: SecureHttpClientService,
) {}
private readonly protocolConnectionSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z.int().positive('Port must be a positive number'),
username: z.string().optional(),
password: z.string().min(1, 'Password is required'),
secure: z.boolean().optional(),
});
async validateProtocolConnectionParams(
params: ConnectionParameters,
): Promise<ConnectionParameters> {
async validateProtocolConnectionParams({
params,
existingProtocolParams,
}: {
params: ConnectionParametersInput;
existingProtocolParams: ConnectionParameters | null;
}): Promise<ConnectionParameters> {
if (!params) {
throw new UserInputError('Protocol connection parameters are required', {
userFriendlyMessage: msg`Please provide connection details to configure your email account.`,
});
}
try {
const validated = this.protocolConnectionSchema.parse(params);
const schema = existingProtocolParams
? connectionParametersUpdateSchema
: connectionParametersSchema;
try {
await this.secureHttpClientService.getValidatedHost(validated.host);
} catch {
throw new UserInputError(
'Connection to private or internal network addresses is not allowed',
{
userFriendlyMessage: msg`The server address you entered is not allowed. Please use a public server address.`,
},
);
}
const result = schema.safeParse(params);
return validated;
} catch (error) {
if (error instanceof UserInputError) {
throw error;
}
if (!result.success) {
const errorMessages = result.error.issues
.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
.join(', ');
if (error instanceof z.ZodError) {
const errorMessages = error.issues
.map((err) => `${err.path.join('.')}: ${err.message}`)
.join(', ');
throw new UserInputError(
`Protocol connection validation failed: ${errorMessages}`,
{
userFriendlyMessage: msg`Please check your connection settings. Make sure the server host, port, and password are correct.`,
},
);
}
throw new UserInputError('Protocol connection validation failed', {
userFriendlyMessage: msg`There was an issue with your connection settings. Please try again.`,
});
throw new UserInputError(
`Protocol connection validation failed: ${errorMessages}`,
{
userFriendlyMessage: msg`Please check your connection settings. Make sure the server host, port, and password are correct.`,
},
);
}
const validated = result.data;
try {
await this.secureHttpClientService.getValidatedHost(validated.host);
} catch {
throw new UserInputError(
'Connection to private or internal network addresses is not allowed',
{
userFriendlyMessage: msg`The server address you entered is not allowed. Please use a public server address.`,
},
);
}
const password =
validated.password ?? existingProtocolParams?.password ?? null;
if (!isNonEmptyString(password)) {
throw new UserInputError(
'Password is required — no existing password found',
{
userFriendlyMessage: msg`Please provide a password for this connection.`,
},
);
}
return { ...validated, password };
}
}
@@ -1,21 +1,21 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
import { ImapFlow } from 'imapflow';
import { createTransport } from 'nodemailer';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { Repository } from 'typeorm';
import { ACCOUNT_TYPES } from 'twenty-shared/constants';
import { assertUnreachable } from 'twenty-shared/utils';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { type EmailAccountConnectionParametersInput } from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection.input';
import { ImapSmtpCaldavValidatorService } from 'src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection-validator.service';
import {
type AccountType,
type ConnectionParameters,
type ImapSmtpCaldavParams,
} from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.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';
@@ -24,12 +24,11 @@ export class ImapSmtpCaldavService {
private readonly logger = new Logger(ImapSmtpCaldavService.name);
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
@InjectRepository(ConnectedAccountEntity)
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
private readonly secureHttpClientService: SecureHttpClientService,
private readonly twentyConfigService: TwentyConfigService,
private readonly caldavClientService: CalDavClientService,
private readonly caldavFetchEventsService: CalDavFetchEventsService,
private readonly imapSmtpCaldavValidatorService: ImapSmtpCaldavValidatorService,
) {}
async testImapConnection(
@@ -182,50 +181,71 @@ export class ImapSmtpCaldavService {
return true;
}
async testImapSmtpCaldav(
handle: string,
params: ConnectionParameters,
accountType: AccountType,
): Promise<boolean> {
if (accountType === 'IMAP') {
return this.testImapConnection(handle, params);
async testImapSmtpCaldav({
handle,
params,
accountType,
}: {
handle: string;
params: ConnectionParameters;
accountType: AccountType;
}): Promise<boolean> {
if (
!this.twentyConfigService.get(
'IS_IMAP_SMTP_CALDAV_CONNECTION_TEST_ENABLED',
)
) {
return true;
}
if (accountType === 'SMTP') {
return this.testSmtpConnection(handle, params);
switch (accountType) {
case 'IMAP':
return this.testImapConnection(handle, params);
case 'SMTP':
return this.testSmtpConnection(handle, params);
case 'CALDAV':
return this.testCaldavConnection(handle, params);
default:
assertUnreachable(accountType);
}
if (accountType === 'CALDAV') {
return this.testCaldavConnection(handle, params);
}
throw new UserInputError(
'Invalid account type. Must be one of: IMAP, SMTP, CALDAV',
{
userFriendlyMessage: msg`Please select a valid connection type (IMAP, SMTP, or CalDAV) and try again.`,
},
);
}
async getImapSmtpCaldav(
workspaceId: string,
connectionId: string,
): Promise<ConnectedAccountEntity | null> {
const authContext = buildSystemAuthContext(workspaceId);
async validateAndTestConnectionParameters({
connectionParameters,
handle,
existingConnectionParameters,
}: {
connectionParameters: EmailAccountConnectionParametersInput;
handle: string;
existingConnectionParameters: ImapSmtpCaldavParams | null;
}): Promise<ImapSmtpCaldavParams> {
const validatedParams: ImapSmtpCaldavParams = {};
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const connectedAccount = await this.connectedAccountRepository.findOne({
where: {
id: connectionId,
provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV,
workspaceId,
},
for (const protocol of ACCOUNT_TYPES) {
const params = connectionParameters[protocol];
if (params) {
const existingProtocolParams =
existingConnectionParameters?.[protocol] ?? null;
const validatedProtocolParams =
await this.imapSmtpCaldavValidatorService.validateProtocolConnectionParams(
{
params,
existingProtocolParams,
},
);
await this.testImapSmtpCaldav({
handle,
params: validatedProtocolParams,
accountType: protocol,
});
return connectedAccount;
},
authContext,
);
validatedParams[protocol] = validatedProtocolParams;
}
}
return validatedParams;
}
}
@@ -1,12 +1,16 @@
export type ConnectionParameters = {
host: string;
port: number;
username?: string;
password: string;
secure?: boolean;
};
import { z } from 'zod';
export type AccountType = 'IMAP' | 'SMTP' | 'CALDAV';
import { ACCOUNT_TYPES } from 'twenty-shared/constants';
import { connectionParametersUpdateSchema } from 'src/engine/core-modules/imap-smtp-caldav-connection/schemas/connection-parameters-update.schema';
import { connectionParametersSchema } from 'src/engine/core-modules/imap-smtp-caldav-connection/schemas/connection-parameters.schema';
export type ConnectionParameters = z.infer<typeof connectionParametersSchema>;
export type ConnectionParametersUpdate = z.infer<
typeof connectionParametersUpdateSchema
>;
export type AccountType = (typeof ACCOUNT_TYPES)[number];
export type ImapSmtpCaldavParams = {
IMAP?: ConnectionParameters;
@@ -0,0 +1,42 @@
import { ACCOUNT_TYPES } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { type ImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
type PublicConnectionParams = {
host: string;
port: number;
username?: string;
secure?: boolean;
};
type PublicConnectionParameters = {
IMAP?: PublicConnectionParams;
SMTP?: PublicConnectionParams;
CALDAV?: PublicConnectionParams;
} | null;
export const buildPublicConnectionParameters = (
connectionParameters: ImapSmtpCaldavParams | null | undefined,
): PublicConnectionParameters => {
if (!isDefined(connectionParameters)) {
return null;
}
return ACCOUNT_TYPES.reduce<NonNullable<PublicConnectionParameters>>(
(result, protocol) => {
const params = connectionParameters[protocol];
if (!isDefined(params)) {
return result;
}
const { password: _, ...publicParams } = params;
result[protocol] = publicParams;
return result;
},
{},
);
};
@@ -175,6 +175,15 @@ export class ConfigVariables {
})
IS_IMAP_SMTP_CALDAV_ENABLED = true;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ADVANCED_SETTINGS,
description:
'Enable or disable the connection test when saving IMAP/SMTP/CALDAV accounts',
type: ConfigVariableType.BOOLEAN,
})
@IsOptional()
IS_IMAP_SMTP_CALDAV_CONNECTION_TEST_ENABLED = true;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ADVANCED_SETTINGS,
description: