[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:
+29
-3
@@ -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;
|
||||
}
|
||||
|
||||
-72
@@ -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;
|
||||
}
|
||||
+37
@@ -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;
|
||||
}
|
||||
+4
-3
@@ -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,
|
||||
|
||||
+59
-53
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -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(),
|
||||
});
|
||||
+9
@@ -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(),
|
||||
});
|
||||
+7
-5
@@ -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,
|
||||
|
||||
+53
-46
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
+67
-47
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+12
-8
@@ -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;
|
||||
|
||||
+42
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user