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:
@@ -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,
|
||||
|
||||
+43
@@ -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;
|
||||
}
|
||||
}
|
||||
+263
@@ -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;
|
||||
}
|
||||
}
|
||||
+98
@@ -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;
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
export type AwsSesError = {
|
||||
name?: string;
|
||||
message?: string;
|
||||
$metadata?: {
|
||||
httpStatusCode?: number;
|
||||
requestId?: string;
|
||||
};
|
||||
};
|
||||
+75
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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',
|
||||
}
|
||||
+14
@@ -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;
|
||||
}
|
||||
+27
@@ -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>;
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
export enum EmailingDomainDriver {
|
||||
AWS_SES = 'AWS_SES',
|
||||
}
|
||||
|
||||
export enum EmailingDomainStatus {
|
||||
PENDING = 'PENDING',
|
||||
VERIFIED = 'VERIFIED',
|
||||
FAILED = 'FAILED',
|
||||
TEMPORARY_FAILURE = 'TEMPORARY_FAILURE',
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
export type VerificationRecord = {
|
||||
type: 'TXT' | 'CNAME' | 'MX';
|
||||
key: string;
|
||||
value: string;
|
||||
priority?: number;
|
||||
};
|
||||
+45
@@ -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;
|
||||
}
|
||||
+16
@@ -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;
|
||||
}
|
||||
+71
@@ -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>;
|
||||
}
|
||||
+27
@@ -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 {}
|
||||
+67
@@ -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;
|
||||
}
|
||||
}
|
||||
+173
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -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 => {
|
||||
|
||||
+5
@@ -125,4 +125,9 @@ export const CONFIG_VARIABLES_GROUP_METADATA: Record<
|
||||
'These have been set to sensible default so you probably don’t 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,
|
||||
},
|
||||
};
|
||||
|
||||
+1
@@ -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[]>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user