Outbound message domains (#14557)

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
neo773
2025-09-24 14:03:04 +05:30
committed by GitHub
parent 6ea7aea92b
commit a1ad8e3a1e
50 changed files with 3053 additions and 103 deletions
@@ -20,6 +20,7 @@ import { captchaModuleFactory } from 'src/engine/core-modules/captcha/captcha.mo
import { CloudflareModule } from 'src/engine/core-modules/cloudflare/cloudflare.module';
import { DnsManagerModule } from 'src/engine/core-modules/dns-manager/dns-manager.module';
import { EmailModule } from 'src/engine/core-modules/email/email.module';
import { EmailingDomainModule } from 'src/engine/core-modules/emailing-domain/emailing-domain.module';
import { ExceptionHandlerModule } from 'src/engine/core-modules/exception-handler/exception-handler.module';
import { exceptionHandlerModuleFactory } from 'src/engine/core-modules/exception-handler/exception-handler.module-factory';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
@@ -83,6 +84,7 @@ import { FileModule } from './file/file.module';
WorkspaceInvitationModule,
WorkspaceSSOModule,
ApprovedAccessDomainModule,
EmailingDomainModule,
PublicDomainModule,
CloudflareModule,
DnsManagerModule,
@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import {
SESv2Client as SESClient,
type SESv2ClientConfig as SESClientConfig,
} from '@aws-sdk/client-sesv2';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class AwsSesClientProvider {
private sesClient: SESClient | null = null;
constructor(private readonly twentyConfigService: TwentyConfigService) {}
public getSESClient(): SESClient {
if (!this.sesClient) {
const config: SESClientConfig = {
region: this.twentyConfigService.get('AWS_SES_REGION'),
};
const accessKeyId = this.twentyConfigService.get('AWS_SES_ACCESS_KEY_ID');
const secretAccessKey = this.twentyConfigService.get(
'AWS_SES_SECRET_ACCESS_KEY',
);
const sessionToken = this.twentyConfigService.get(
'AWS_SES_SESSION_TOKEN',
);
if (accessKeyId && secretAccessKey && sessionToken) {
config.credentials = {
accessKeyId,
secretAccessKey,
sessionToken,
};
}
this.sesClient = new SESClient(config);
}
return this.sesClient;
}
}
@@ -0,0 +1,263 @@
import { Logger } from '@nestjs/common';
import {
CreateEmailIdentityCommand,
CreateTenantCommand,
CreateTenantResourceAssociationCommand,
GetEmailIdentityCommand,
PutEmailIdentityDkimAttributesCommand,
} from '@aws-sdk/client-sesv2';
import { type AwsSesDriverConfig } from 'src/engine/core-modules/emailing-domain/drivers/interfaces/driver-config.interface';
import {
type DomainStatusInput,
type DomainVerificationInput,
type EmailingDomainDriverInterface,
type EmailingDomainVerificationResult,
} from 'src/engine/core-modules/emailing-domain/drivers/interfaces/emailing-domain-driver.interface';
import { type AwsSesClientProvider } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/providers/aws-ses-client.provider';
import { type AwsSesHandleErrorService } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-handle-error.service';
import { EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain';
import { type VerificationRecord } from 'src/engine/core-modules/emailing-domain/dtos/verification-record.dto';
export class AwsSesDriver implements EmailingDomainDriverInterface {
private readonly logger = new Logger(AwsSesDriver.name);
constructor(
private readonly config: AwsSesDriverConfig,
private readonly awsSesClientProvider: AwsSesClientProvider,
private readonly awsSesHandleErrorService: AwsSesHandleErrorService,
) {}
async verifyDomain(
input: DomainVerificationInput,
): Promise<EmailingDomainVerificationResult> {
try {
this.logger.log(`Starting domain verification for: ${input.domain}`);
const tenantName = this.generateTenantName(input.workspaceId);
await this.ensureTenantExists(tenantName);
const { isVerified, verificationRecords } =
await this.createOrUpdateEmailIdentity(input.domain, tenantName);
if (isVerified) {
await this.enableDkimSigning(input.domain);
}
return {
status: isVerified
? EmailingDomainStatus.VERIFIED
: EmailingDomainStatus.PENDING,
verifiedAt: isVerified ? new Date() : null,
verificationRecords,
};
} catch (error) {
this.logger.error(`Failed to verify domain ${input.domain}: ${error}`);
this.awsSesHandleErrorService.handleAwsSesError(error, 'verifyDomain');
}
}
async getDomainStatus(
input: DomainStatusInput,
): Promise<EmailingDomainVerificationResult> {
try {
this.logger.log(`Getting domain status for: ${input.domain}`);
const sesClient = this.awsSesClientProvider.getSESClient();
const getIdentityCommand = new GetEmailIdentityCommand({
EmailIdentity: input.domain,
});
const identityResponse = await sesClient.send(getIdentityCommand);
const status = this.determineVerificationStatus(identityResponse);
const isFullyVerified = status === EmailingDomainStatus.VERIFIED;
const verificationRecords = this.buildVerificationRecords(
input.domain,
identityResponse.DkimAttributes?.Tokens || [],
);
return {
status,
verifiedAt: isFullyVerified ? new Date() : null,
verificationRecords,
};
} catch (error) {
if (error.name === 'NotFoundException') {
return {
status: EmailingDomainStatus.FAILED,
verifiedAt: null,
verificationRecords: [],
};
}
this.logger.error(
`Failed to get domain status ${input.domain}: ${error}`,
);
this.awsSesHandleErrorService.handleAwsSesError(error, 'getDomainStatus');
}
}
private generateTenantName(workspaceId: string): string {
return `twenty-workspace-${workspaceId}`;
}
private async ensureTenantExists(tenantName: string): Promise<void> {
const sesClient = this.awsSesClientProvider.getSESClient();
try {
await sesClient.send(new CreateTenantCommand({ TenantName: tenantName }));
this.logger.log(`Created tenant: ${tenantName}`);
} catch (error) {
if (error.name === 'AlreadyExistsException') {
this.logger.log(`Tenant already exists: ${tenantName}`);
return;
}
throw error;
}
}
private async createOrUpdateEmailIdentity(
domain: string,
tenantName: string,
): Promise<{
isVerified: boolean;
verificationRecords: VerificationRecord[];
}> {
const sesClient = this.awsSesClientProvider.getSESClient();
try {
const getIdentityCommand = new GetEmailIdentityCommand({
EmailIdentity: domain,
});
const existingIdentity = await sesClient.send(getIdentityCommand);
const isVerified = existingIdentity.VerifiedForSendingStatus === true;
const verificationRecords = this.buildVerificationRecords(
domain,
existingIdentity.DkimAttributes?.Tokens || [],
);
if (!isVerified) {
await this.associateResourceWithTenant(domain, tenantName);
}
return { isVerified, verificationRecords };
} catch (error) {
if (error.name === 'NotFoundException') {
return await this.createNewEmailIdentity(domain, tenantName);
}
throw error;
}
}
private async createNewEmailIdentity(
domain: string,
tenantName: string,
): Promise<{
isVerified: boolean;
verificationRecords: VerificationRecord[];
}> {
const sesClient = this.awsSesClientProvider.getSESClient();
const createCommand = new CreateEmailIdentityCommand({
EmailIdentity: domain,
Tags: [{ Key: 'Tenant', Value: tenantName }],
});
const createResponse = await sesClient.send(createCommand);
const dkimTokens = createResponse.DkimAttributes?.Tokens || [];
await this.associateResourceWithTenant(domain, tenantName);
const verificationRecords = this.buildVerificationRecords(
domain,
dkimTokens,
);
return {
isVerified: false,
verificationRecords,
};
}
private async associateResourceWithTenant(
domain: string,
tenantName: string,
): Promise<void> {
const sesClient = this.awsSesClientProvider.getSESClient();
try {
await sesClient.send(
new CreateTenantResourceAssociationCommand({
TenantName: tenantName,
ResourceArn: `arn:aws:ses:${this.config.region}:${this.config.accountId}:identity/${domain}`,
}),
);
this.logger.log(`Associated domain ${domain} with tenant ${tenantName}`);
} catch (error) {
if (error.name === 'AlreadyExistsException') {
this.logger.log(
`Domain ${domain} already associated with tenant ${tenantName}`,
);
return;
}
throw error;
}
}
private async enableDkimSigning(domain: string): Promise<void> {
const sesClient = this.awsSesClientProvider.getSESClient();
const dkimCommand = new PutEmailIdentityDkimAttributesCommand({
EmailIdentity: domain,
SigningEnabled: true,
});
await sesClient.send(dkimCommand);
this.logger.log(`Enabled DKIM signing for domain: ${domain}`);
}
private buildVerificationRecords(
domain: string,
dkimTokens: string[],
): VerificationRecord[] {
return dkimTokens.map((token) => ({
type: 'CNAME' as const,
key: `${token}._domainkey.${domain}`,
value: `${token}.dkim.amazonses.com`,
}));
}
private determineVerificationStatus(identityResponse: {
VerifiedForSendingStatus?: boolean;
DkimAttributes?: {
SigningEnabled?: boolean;
Status?: string;
};
}): EmailingDomainStatus {
const isVerified = identityResponse.VerifiedForSendingStatus === true;
const isDkimEnabled =
identityResponse.DkimAttributes?.SigningEnabled === true;
const dkimStatus = identityResponse.DkimAttributes?.Status;
if (isVerified && isDkimEnabled && dkimStatus === 'SUCCESS') {
return EmailingDomainStatus.VERIFIED;
}
if (
identityResponse.VerifiedForSendingStatus === false ||
dkimStatus === 'FAILED'
) {
return EmailingDomainStatus.FAILED;
}
return EmailingDomainStatus.PENDING;
}
}
@@ -0,0 +1,98 @@
import { Injectable } from '@nestjs/common';
import { type AwsSesError } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/types/aws-ses-error.type';
import {
EmailingDomainDriverException,
EmailingDomainDriverExceptionCode,
} from 'src/engine/core-modules/emailing-domain/drivers/exceptions/emailing-domain-driver.exception';
@Injectable()
export class AwsSesHandleErrorService {
public handleAwsSesError(error: AwsSesError, context?: string): never {
const name = error?.name ?? 'UnknownError';
const message = error?.message ?? 'No message';
const httpStatus = error?.$metadata?.httpStatusCode;
const suffix = context ? ` (${context})` : '';
if (this.isTemporary(name, httpStatus)) {
throw new EmailingDomainDriverException(
`AWS SES temporary error${suffix}: ${message}`,
EmailingDomainDriverExceptionCode.TEMPORARY_ERROR,
);
}
if (this.isInsufficientPermissions(name, httpStatus)) {
throw new EmailingDomainDriverException(
`AWS SES insufficient permissions${suffix}: ${message}`,
EmailingDomainDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
);
}
if (this.isConfigurationError(name, httpStatus)) {
throw new EmailingDomainDriverException(
`AWS SES configuration error${suffix}: ${message}`,
EmailingDomainDriverExceptionCode.CONFIGURATION_ERROR,
);
}
throw new EmailingDomainDriverException(
`AWS SES error${suffix}: ${name} - ${message}`,
EmailingDomainDriverExceptionCode.UNKNOWN,
);
}
private isTemporary(name: string, httpStatus?: number): boolean {
if (httpStatus && httpStatus >= 500) {
return true;
}
if (
name === 'ThrottlingException' ||
name === 'ServiceUnavailable' ||
name === 'InternalFailure' ||
name === 'RequestTimeout' ||
name === 'TooManyRequestsException'
) {
return true;
}
return false;
}
private isInsufficientPermissions(
name: string,
httpStatus?: number,
): boolean {
if (httpStatus === 403) {
return true;
}
if (
name === 'AccessDeniedException' ||
name === 'AccountSuspendedException'
) {
return true;
}
return false;
}
private isConfigurationError(name: string, httpStatus?: number): boolean {
if (httpStatus === 400) {
return true;
}
if (
name === 'InvalidParameterValue' ||
name === 'InvalidParameterCombination' ||
name === 'MissingParameter' ||
name === 'MessageRejected' ||
name === 'MailFromDomainNotVerifiedException' ||
name === 'FromEmailAddressNotVerifiedException'
) {
return true;
}
return false;
}
}
@@ -0,0 +1,8 @@
export type AwsSesError = {
name?: string;
message?: string;
$metadata?: {
httpStatusCode?: number;
requestId?: string;
};
};
@@ -0,0 +1,75 @@
import { Injectable } from '@nestjs/common';
import { type AwsSesDriverConfig } from 'src/engine/core-modules/emailing-domain/drivers/interfaces/driver-config.interface';
import { type EmailingDomainDriverInterface } from 'src/engine/core-modules/emailing-domain/drivers/interfaces/emailing-domain-driver.interface';
import { AwsSesClientProvider } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/providers/aws-ses-client.provider';
import { AwsSesDriver } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-driver.service';
import { AwsSesHandleErrorService } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-handle-error.service';
import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain';
import { DriverFactoryBase } from 'src/engine/core-modules/twenty-config/dynamic-factory.base';
import { ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class EmailingDomainDriverFactory extends DriverFactoryBase<EmailingDomainDriverInterface> {
constructor(
twentyConfigService: TwentyConfigService,
private readonly awsSesClientProvider: AwsSesClientProvider,
private readonly awsSesHandleErrorService: AwsSesHandleErrorService,
) {
super(twentyConfigService);
}
protected buildConfigKey(): string {
const driver = EmailingDomainDriver.AWS_SES;
if (driver === EmailingDomainDriver.AWS_SES) {
const awsConfigHash = this.getConfigGroupHash(
ConfigVariablesGroup.AwsSesSettings,
);
return `aws-ses|${awsConfigHash}`;
}
throw new Error(`Unsupported emailing domain driver: ${driver}`);
}
protected createDriver(): EmailingDomainDriverInterface {
const driver = EmailingDomainDriver.AWS_SES;
switch (driver) {
case EmailingDomainDriver.AWS_SES: {
const region = this.twentyConfigService.get('AWS_SES_REGION');
const accountId = this.twentyConfigService.get('AWS_SES_ACCOUNT_ID');
const accessKeyId = this.twentyConfigService.get(
'AWS_SES_ACCESS_KEY_ID',
);
const secretAccessKey = this.twentyConfigService.get(
'AWS_SES_SECRET_ACCESS_KEY',
);
const sessionToken = this.twentyConfigService.get(
'AWS_SES_SESSION_TOKEN',
);
const awsConfig: AwsSesDriverConfig = {
driver: EmailingDomainDriver.AWS_SES,
region,
accountId,
accessKeyId,
secretAccessKey,
sessionToken,
};
return new AwsSesDriver(
awsConfig,
this.awsSesClientProvider,
this.awsSesHandleErrorService,
);
}
default:
throw new Error(`Invalid emailing domain driver: ${driver}`);
}
}
}
@@ -0,0 +1,11 @@
import { CustomException } from 'src/utils/custom-exception';
export class EmailingDomainDriverException extends CustomException<EmailingDomainDriverExceptionCode> {}
export enum EmailingDomainDriverExceptionCode {
NOT_FOUND = 'NOT_FOUND',
TEMPORARY_ERROR = 'TEMPORARY_ERROR',
INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS',
CONFIGURATION_ERROR = 'CONFIGURATION_ERROR',
UNKNOWN = 'UNKNOWN',
}
@@ -0,0 +1,14 @@
import { type EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain';
export interface BaseDriverConfig {
driver: EmailingDomainDriver;
}
export interface AwsSesDriverConfig extends BaseDriverConfig {
driver: EmailingDomainDriver.AWS_SES;
region: string;
accountId: string;
accessKeyId?: string;
secretAccessKey?: string;
sessionToken?: string;
}
@@ -0,0 +1,27 @@
import { type EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain';
import { type VerificationRecord } from 'src/engine/core-modules/emailing-domain/drivers/types/verifications-record';
export type DomainVerificationInput = {
domain: string;
workspaceId: string;
};
export type DomainStatusInput = {
domain: string;
workspaceId: string;
};
export type EmailingDomainVerificationResult = {
status: EmailingDomainStatus;
verificationRecords: VerificationRecord[];
verifiedAt: Date | null;
};
export interface EmailingDomainDriverInterface {
verifyDomain(
input: DomainVerificationInput,
): Promise<EmailingDomainVerificationResult>;
getDomainStatus(
input: DomainStatusInput,
): Promise<EmailingDomainVerificationResult>;
}
@@ -0,0 +1,10 @@
export enum EmailingDomainDriver {
AWS_SES = 'AWS_SES',
}
export enum EmailingDomainStatus {
PENDING = 'PENDING',
VERIFIED = 'VERIFIED',
FAILED = 'FAILED',
TEMPORARY_FAILURE = 'TEMPORARY_FAILURE',
}
@@ -0,0 +1,6 @@
export type VerificationRecord = {
type: 'TXT' | 'CNAME' | 'MX';
key: string;
value: string;
priority?: number;
};
@@ -0,0 +1,45 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import {
EmailingDomainDriver,
EmailingDomainStatus,
} from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain';
import { VerificationRecord } from 'src/engine/core-modules/emailing-domain/dtos/verification-record.dto';
registerEnumType(EmailingDomainDriver, {
name: 'EmailingDomainDriver',
});
registerEnumType(EmailingDomainStatus, {
name: 'EmailingDomainStatus',
});
@ObjectType('EmailingDomain')
export class EmailingDomainDto {
@IDField(() => UUIDScalarType)
id: string;
@Field(() => Date)
createdAt: Date;
@Field(() => Date)
updatedAt: Date;
@Field(() => String)
domain: string;
@Field(() => EmailingDomainDriver)
driver: EmailingDomainDriver;
@Field(() => EmailingDomainStatus)
status: EmailingDomainStatus;
@Field(() => [VerificationRecord], { nullable: true })
verificationRecords: VerificationRecord[] | null;
@Field(() => Date, { nullable: true })
verifiedAt: Date | null;
}
@@ -0,0 +1,16 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class VerificationRecord {
@Field(() => String)
type: 'TXT' | 'CNAME' | 'MX';
@Field(() => String)
key: string;
@Field(() => String)
value: string;
@Field(() => Number, { nullable: true })
priority?: number;
}
@@ -0,0 +1,71 @@
import { ObjectType } from '@nestjs/graphql';
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
import {
EmailingDomainDriver,
EmailingDomainStatus,
} from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain';
import { VerificationRecord } from 'src/engine/core-modules/emailing-domain/drivers/types/verifications-record';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Entity({ name: 'emailingDomain', schema: 'core' })
@ObjectType()
@Unique('IDX_EMAILING_DOMAIN_DOMAIN_WORKSPACE_ID_UNIQUE', [
'domain',
'workspaceId',
])
export class EmailingDomain {
@PrimaryGeneratedColumn('uuid')
id: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@Column({ type: 'varchar', nullable: false })
domain: string;
@Column({
type: 'enum',
enum: Object.values(EmailingDomainDriver),
nullable: false,
})
driver: EmailingDomainDriver;
@Column({
type: 'enum',
enum: Object.values(EmailingDomainStatus),
default: EmailingDomainStatus.PENDING,
nullable: false,
})
status: EmailingDomainStatus;
@Column({ type: 'jsonb', nullable: true })
verificationRecords: VerificationRecord[];
@Column({ type: 'timestamptz', nullable: true })
verifiedAt: Date | null;
@Column({ nullable: false })
workspaceId: string;
@ManyToOne(() => Workspace, (workspace) => workspace.emailingDomains, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'workspaceId' })
workspace: Relation<Workspace>;
}
@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { AwsSesClientProvider } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/providers/aws-ses-client.provider';
import { AwsSesHandleErrorService } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-handle-error.service';
import { EmailingDomainDriverFactory } from 'src/engine/core-modules/emailing-domain/drivers/emailing-domain-driver.factory';
import { EmailingDomain } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
import { EmailingDomainResolver } from 'src/engine/core-modules/emailing-domain/emailing-domain.resolver';
import { EmailingDomainService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain.service';
@Module({
imports: [
TypeORMModule,
NestjsQueryTypeOrmModule.forFeature([EmailingDomain]),
],
exports: [EmailingDomainService],
providers: [
EmailingDomainService,
EmailingDomainResolver,
EmailingDomainDriverFactory,
AwsSesClientProvider,
AwsSesHandleErrorService,
],
})
export class EmailingDomainModule {}
@@ -0,0 +1,67 @@
import { UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain';
import { EmailingDomainDto } from 'src/engine/core-modules/emailing-domain/dtos/emailing-domain.dto';
import { EmailingDomainService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain.service';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@UseGuards(WorkspaceAuthGuard)
@UsePipes(ResolverValidationPipe)
@Resolver(() => EmailingDomainDto)
export class EmailingDomainResolver {
constructor(private readonly emailingDomainService: EmailingDomainService) {}
@Mutation(() => EmailingDomainDto)
async createEmailingDomain(
@Args('domain') domain: string,
@Args('driver') driver: EmailingDomainDriver,
@AuthWorkspace() currentWorkspace: Workspace,
): Promise<EmailingDomainDto> {
const emailingDomain =
await this.emailingDomainService.createEmailingDomain(
domain,
driver,
currentWorkspace,
);
return emailingDomain;
}
@Mutation(() => Boolean)
async deleteEmailingDomain(
@Args('id') id: string,
@AuthWorkspace() currentWorkspace: Workspace,
): Promise<boolean> {
await this.emailingDomainService.deleteEmailingDomain(currentWorkspace, id);
return true;
}
@Mutation(() => EmailingDomainDto)
async verifyEmailingDomain(
@Args('id') id: string,
@AuthWorkspace() currentWorkspace: Workspace,
): Promise<EmailingDomainDto> {
const emailingDomain =
await this.emailingDomainService.verifyEmailingDomain(
currentWorkspace,
id,
);
return emailingDomain;
}
@Query(() => [EmailingDomainDto])
async getEmailingDomains(
@AuthWorkspace() currentWorkspace: Workspace,
): Promise<EmailingDomainDto[]> {
const emailingDomains =
await this.emailingDomainService.getEmailingDomains(currentWorkspace);
return emailingDomains;
}
}
@@ -0,0 +1,173 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { EmailingDomainDriverFactory } from 'src/engine/core-modules/emailing-domain/drivers/emailing-domain-driver.factory';
import {
EmailingDomainDriver,
EmailingDomainStatus,
} from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain';
import { EmailingDomain } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
import { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable()
export class EmailingDomainService {
constructor(
@InjectRepository(EmailingDomain)
private readonly emailingDomainRepository: Repository<EmailingDomain>,
private readonly emailingDomainDriverFactory: EmailingDomainDriverFactory,
) {}
async createEmailingDomain(
domain: string,
driver: EmailingDomainDriver,
workspace: Workspace,
): Promise<EmailingDomain> {
const existingDomain = await this.emailingDomainRepository.findOneBy({
domain,
workspaceId: workspace.id,
});
if (existingDomain) {
throw new Error('Emailing domain already exists for this workspace');
}
const driverInstance = this.emailingDomainDriverFactory.getCurrentDriver();
const verificationResult = await driverInstance.verifyDomain({
domain,
workspaceId: workspace.id,
});
const domainToCreate = {
domain,
driver,
workspaceId: workspace.id,
...verificationResult,
};
const savedDomain =
await this.emailingDomainRepository.save(domainToCreate);
return savedDomain;
}
async deleteEmailingDomain(
workspace: Workspace,
emailingDomainId: string,
): Promise<void> {
const emailingDomain = await this.emailingDomainRepository.findOneBy({
id: emailingDomainId,
workspaceId: workspace.id,
});
if (!emailingDomain) {
throw new Error('Emailing domain not found');
}
await this.emailingDomainRepository.delete({
id: emailingDomain.id,
});
}
async getEmailingDomains(workspace: Workspace): Promise<EmailingDomain[]> {
return await this.emailingDomainRepository.find({
where: {
workspaceId: workspace.id,
},
order: {
createdAt: 'DESC',
},
});
}
async getEmailingDomain(
workspace: Workspace,
emailingDomainId: string,
): Promise<EmailingDomain | null> {
return await this.emailingDomainRepository.findOneBy({
id: emailingDomainId,
workspaceId: workspace.id,
});
}
async verifyEmailingDomain(
workspace: Workspace,
emailingDomainId: string,
): Promise<EmailingDomain> {
const emailingDomain = await this.getEmailingDomain(
workspace,
emailingDomainId,
);
if (!emailingDomain) {
throw new Error('Emailing domain not found');
}
if (emailingDomain.status === EmailingDomainStatus.VERIFIED) {
throw new Error('Emailing domain is already verified');
}
const driver = this.emailingDomainDriverFactory.getCurrentDriver();
const verificationResult = await driver.verifyDomain({
domain: emailingDomain.domain,
workspaceId: emailingDomain.workspaceId,
});
const updatedDomain = await this.emailingDomainRepository.save({
...emailingDomain,
...verificationResult,
});
return updatedDomain;
}
async syncEmailingDomain(
workspace: Workspace,
emailingDomainId: string,
): Promise<EmailingDomain> {
const emailingDomain = await this.getEmailingDomain(
workspace,
emailingDomainId,
);
if (!emailingDomain) {
throw new Error('Emailing domain not found');
}
await this.emailingDomainRepository.update(
{
id: emailingDomainId,
},
{
verificationRecords: emailingDomain.verificationRecords,
status: EmailingDomainStatus.PENDING,
},
);
try {
const driver = this.emailingDomainDriverFactory.getCurrentDriver();
const statusResult = await driver.getDomainStatus({
domain: emailingDomain.domain,
workspaceId: emailingDomain.workspaceId,
});
const updatedDomain = await this.emailingDomainRepository.save({
...emailingDomain,
...statusResult,
});
return updatedDomain;
} catch (error) {
await this.emailingDomainRepository.update(
{ id: emailingDomainId },
{
verificationRecords: emailingDomain.verificationRecords,
status: emailingDomain.status,
},
);
throw error;
}
}
}
@@ -18,5 +18,6 @@ export enum FeatureFlagKey {
IS_CALENDAR_VIEW_ENABLED = 'IS_CALENDAR_VIEW_ENABLED',
IS_GROUP_BY_ENABLED = 'IS_GROUP_BY_ENABLED',
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED',
IS_DYNAMIC_SEARCH_FIELDS_ENABLED = 'IS_DYNAMIC_SEARCH_FIELDS_ENABLED',
}
@@ -1211,6 +1211,50 @@ export class ConfigVariables {
})
@ValidateIf((env) => env.IS_MAPS_AND_ADDRESS_AUTOCOMPLETE_ENABLED)
GOOGLE_MAP_API_KEY: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.AwsSesSettings,
description: 'AWS region',
type: ConfigVariableType.STRING,
})
@IsAWSRegion()
@IsOptional()
AWS_SES_REGION: AwsRegion;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.AwsSesSettings,
isSensitive: true,
description: 'AWS access key ID',
type: ConfigVariableType.STRING,
})
@IsOptional()
AWS_SES_ACCESS_KEY_ID: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.AwsSesSettings,
isSensitive: true,
description: 'AWS session token',
type: ConfigVariableType.STRING,
})
@IsOptional()
AWS_SES_SESSION_TOKEN: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.AwsSesSettings,
isSensitive: true,
description: 'AWS secret access key',
type: ConfigVariableType.STRING,
})
@IsOptional()
AWS_SES_SECRET_ACCESS_KEY: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.AwsSesSettings,
description: 'AWS Account ID for SES ARN construction',
type: ConfigVariableType.STRING,
})
@IsOptional()
AWS_SES_ACCOUNT_ID: string;
}
export const validate = (config: Record<string, unknown>): ConfigVariables => {
@@ -125,4 +125,9 @@ export const CONFIG_VARIABLES_GROUP_METADATA: Record<
'These have been set to sensible default so you probably dont need to change them unless you have a specific use-case.',
isHiddenOnLoad: true,
},
[ConfigVariablesGroup.AwsSesSettings]: {
position: 2100,
description: 'Configure AWS SES settings for emailing domains',
isHiddenOnLoad: true,
},
};
@@ -19,4 +19,5 @@ export enum ConfigVariablesGroup {
AnalyticsConfig = 'audit-config',
TokensDuration = 'tokens-duration',
TwoFactorAuthentication = 'two-factor-authentication',
AwsSesSettings = 'aws-ses-settings',
}
@@ -19,9 +19,11 @@ import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity';
import { EmailingDomain } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity';
import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity';
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { ViewFieldDTO } from 'src/engine/core-modules/view/dtos/view-field.dto';
@@ -41,7 +43,6 @@ import { AgentHandoffEntity } from 'src/engine/metadata-modules/agent/agent-hand
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { AgentDTO } from 'src/engine/metadata-modules/agent/dtos/agent.dto';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity';
registerEnumType(WorkspaceActivationStatus, {
name: 'WorkspaceActivationStatus',
@@ -116,6 +117,9 @@ export class Workspace {
)
approvedAccessDomains: Relation<ApprovedAccessDomain[]>;
@OneToMany(() => EmailingDomain, (emailingDomain) => emailingDomain.workspace)
emailingDomains: Relation<EmailingDomain[]>;
@OneToMany(() => PublicDomain, (publicDomain) => publicDomain.workspace)
publicDomains: Relation<PublicDomain[]>;