feat(emailing-domain): add LOG driver for local development (#21286)

## What

Adds a `LOG` driver to the emailing-domain feature, selected via a new
`EMAILING_DOMAIN_DRIVER` config variable (defaults to `AWS_SES`, so
production behavior is unchanged).

The LOG driver:
- resolves domains to `VERIFIED` instantly (no DNS / SES setup)
- logs each `sendEmail` and returns a synthetic `messageId` instead of
calling SES

It also dev-seeds a pre-verified domain per workspace
(`<workspaceId>.dev.twenty.local`) so the feature works out of the box.

## Why

The emailing-domain feature currently ships only the AWS SES driver, so
the verify → send flow can't be exercised locally (or in CI) without
real AWS credentials. This unblocks local development and review of
anything built on emailing domains.

## Usage

```
EMAILING_DOMAIN_DRIVER=LOG
```

The seeded `*.dev.twenty.local` domain is already verified; sends are
logged (`[log-driver] sendEmail ...`).

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2026-06-08 14:59:11 +02:00
committed by GitHub
parent b1b030c894
commit dfb3da1f8d
22 changed files with 724 additions and 608 deletions
@@ -8,6 +8,7 @@ import { AwsSesRegisterDomainService } from 'src/engine/core-modules/emailing-do
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 { AwsSesSendEmailService } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-send-email.service';
import { LogEmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/log/services/log-emailing-domain-driver.service';
import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type';
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';
@@ -23,26 +24,31 @@ export class EmailingDomainDriverFactory extends DriverFactoryBase<EmailingDomai
private readonly awsSesHandleErrorService: AwsSesHandleErrorService,
private readonly awsSesRegisterDomainService: AwsSesRegisterDomainService,
private readonly awsSesSendEmailService: AwsSesSendEmailService,
private readonly logEmailingDomainDriver: LogEmailingDomainDriver,
) {
super(twentyConfigService, configGroupHashService);
}
protected buildConfigKey(): string {
const driver = EmailingDomainDriver.AWS_SES;
const driver = this.twentyConfigService.get('EMAILING_DOMAIN_DRIVER');
if (driver === EmailingDomainDriver.AWS_SES) {
const awsConfigHash = this.configGroupHashService.computeHash(
ConfigVariablesGroup.AWS_SES_SETTINGS,
);
switch (driver) {
case EmailingDomainDriver.AWS_SES: {
const awsConfigHash = this.configGroupHashService.computeHash(
ConfigVariablesGroup.AWS_SES_SETTINGS,
);
return `aws-ses|${awsConfigHash}`;
return `aws-ses|${awsConfigHash}`;
}
case EmailingDomainDriver.LOG:
return 'log';
default:
throw new Error(`Unsupported emailing domain driver: ${driver}`);
}
throw new Error(`Unsupported emailing domain driver: ${driver}`);
}
protected createDriver(): EmailingDomainDriverInterface {
const driver = EmailingDomainDriver.AWS_SES;
const driver = this.twentyConfigService.get('EMAILING_DOMAIN_DRIVER');
switch (driver) {
case EmailingDomainDriver.AWS_SES: {
@@ -76,6 +82,9 @@ export class EmailingDomainDriverFactory extends DriverFactoryBase<EmailingDomai
);
}
case EmailingDomainDriver.LOG:
return this.logEmailingDomainDriver;
default:
throw new Error(`Invalid emailing domain driver: ${driver}`);
}
@@ -0,0 +1,71 @@
import { Injectable, Logger } from '@nestjs/common';
import { v4 } from 'uuid';
import {
type EmailingDomainDriverInterface,
type EmailingDomainResourceInput,
type EmailingDomainVerificationResult,
} from 'src/engine/core-modules/emailing-domain/drivers/interfaces/emailing-domain-driver.interface';
import { EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-status.type';
import {
type EmailingDomainSendEmailInput,
type EmailingDomainSendEmailResult,
} from 'src/engine/core-modules/emailing-domain/drivers/types/send-email';
@Injectable()
export class LogEmailingDomainDriver implements EmailingDomainDriverInterface {
private readonly logger = new Logger(LogEmailingDomainDriver.name);
async provisionWorkspace(workspaceId: string): Promise<void> {
this.logger.log(`[log-driver] provisionWorkspace(${workspaceId})`);
}
async deprovisionWorkspace(workspaceId: string): Promise<void> {
this.logger.log(`[log-driver] deprovisionWorkspace(${workspaceId})`);
}
async verifyDomain(
input: EmailingDomainResourceInput,
): Promise<EmailingDomainVerificationResult> {
this.logger.log(
`[log-driver] verifyDomain(${input.domain}) → VERIFIED (instant)`,
);
return {
status: EmailingDomainStatus.VERIFIED,
verificationRecords: [],
};
}
async getDomainStatus(
input: EmailingDomainResourceInput,
): Promise<EmailingDomainVerificationResult> {
this.logger.log(`[log-driver] getDomainStatus(${input.domain}) → VERIFIED`);
return {
status: EmailingDomainStatus.VERIFIED,
verificationRecords: [],
};
}
async registerDomain(input: EmailingDomainResourceInput): Promise<void> {
this.logger.log(`[log-driver] registerDomain(${input.domain})`);
}
async cleanupDomain(input: EmailingDomainResourceInput): Promise<void> {
this.logger.log(`[log-driver] cleanupDomain(${input.domain})`);
}
async sendEmail(
input: EmailingDomainSendEmailInput,
): Promise<EmailingDomainSendEmailResult> {
const messageId = `log-${v4()}`;
this.logger.log(
`[log-driver] sendEmail from=${input.from} to=${input.to.join(',')} subject="${input.subject}" → fake messageId=${messageId}`,
);
return { messageId };
}
}
@@ -1,3 +1,4 @@
export enum EmailingDomainDriver {
AWS_SES = 'AWS_SES',
LOG = 'LOG',
}
@@ -3,14 +3,9 @@ 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 } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type';
import { EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-status.type';
import { VerificationRecordDTO } from 'src/engine/core-modules/emailing-domain/dtos/verification-record.dto';
registerEnumType(EmailingDomainDriver, {
name: 'EmailingDomainDriver',
});
registerEnumType(EmailingDomainStatus, {
name: 'EmailingDomainStatus',
});
@@ -29,9 +24,6 @@ export class EmailingDomainDTO {
@Field(() => String)
domain: string;
@Field(() => EmailingDomainDriver)
driver: EmailingDomainDriver;
@Field(() => EmailingDomainStatus)
status: EmailingDomainStatus;
@@ -9,7 +9,6 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type';
import { EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-status.type';
import { EmailingDomainTenantStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-tenant-status.type';
import { VerificationRecord } from 'src/engine/core-modules/emailing-domain/drivers/types/verifications-record';
@@ -31,13 +30,6 @@ export class EmailingDomainEntity extends WorkspaceRelatedEntity {
@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),
@@ -8,6 +8,7 @@ import { AwsSesRegisterDomainService } from 'src/engine/core-modules/emailing-do
import { AwsSesHandleErrorService } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-handle-error.service';
import { AwsSesSendEmailService } from 'src/engine/core-modules/emailing-domain/drivers/aws-ses/services/aws-ses-send-email.service';
import { EmailingDomainDriverFactory } from 'src/engine/core-modules/emailing-domain/drivers/emailing-domain-driver.factory';
import { LogEmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/log/services/log-emailing-domain-driver.service';
import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
import { EmailingDomainResolver } from 'src/engine/core-modules/emailing-domain/emailing-domain.resolver';
import { EmailingDomainWorkspaceCleanupJob } from 'src/engine/core-modules/emailing-domain/jobs/emailing-domain-workspace-cleanup.job';
@@ -34,6 +35,7 @@ import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspac
AwsSesHandleErrorService,
AwsSesRegisterDomainService,
AwsSesSendEmailService,
LogEmailingDomainDriver,
provideWorkspaceScopedRepository(EmailingDomainEntity),
],
})
@@ -5,7 +5,6 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import { FeatureFlagKey } from 'twenty-shared/types';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type';
import { EmailingDomainDTO } from 'src/engine/core-modules/emailing-domain/dtos/emailing-domain.dto';
import { SendEmailViaDomainOutputDTO } from 'src/engine/core-modules/emailing-domain/dtos/send-email-via-domain-output.dto';
import { SendEmailViaDomainInput } from 'src/engine/core-modules/emailing-domain/dtos/send-email-via-domain.input';
@@ -34,13 +33,11 @@ export class EmailingDomainResolver {
@RequireFeatureFlag(FeatureFlagKey.IS_EMAIL_GROUP_ENABLED)
async createEmailingDomain(
@Args('domain') domain: string,
@Args('driver') driver: EmailingDomainDriver,
@AuthWorkspace() currentWorkspace: WorkspaceEntity,
): Promise<EmailingDomainDTO> {
const emailingDomain =
await this.emailingDomainService.createEmailingDomain(
domain,
driver,
currentWorkspace,
);
@@ -5,7 +5,6 @@ import {
EmailingDomainDriverExceptionCode,
} from 'src/engine/core-modules/emailing-domain/drivers/exceptions/emailing-domain-driver.exception';
import { EmailingDomainDriverFactory } from 'src/engine/core-modules/emailing-domain/drivers/emailing-domain-driver.factory';
import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type';
import { EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-status.type';
import { EmailingDomainTenantStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-tenant-status.type';
import {
@@ -28,7 +27,6 @@ export class EmailingDomainService {
async createEmailingDomain(
domain: string,
driverType: EmailingDomainDriver,
workspace: WorkspaceEntity,
): Promise<EmailingDomainEntity> {
const existingEmailingDomain = await this.emailingDomainRepository.findOne(
@@ -65,7 +63,6 @@ export class EmailingDomainService {
return this.emailingDomainRepository.save(workspace.id, {
domain,
driver: driverType,
status: verificationResult.status,
verificationRecords: verificationResult.verificationRecords,
verifiedAt: isVerifiedOnCreation ? new Date() : null,
@@ -20,6 +20,7 @@ import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
import { CodeInterpreterDriverType } from 'src/engine/core-modules/code-interpreter/code-interpreter.interface';
import { EmailDriver } from 'src/engine/core-modules/email/enums/email-driver.enum';
import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type';
import { ExceptionHandlerDriver } from 'src/engine/core-modules/exception-handler/interfaces';
import { StorageDriverType } from 'src/engine/core-modules/file-storage/interfaces';
import { LoggerDriverType } from 'src/engine/core-modules/logger/interfaces';
@@ -1697,6 +1698,16 @@ export class ConfigVariables {
@IsOptional()
MINTLIFY_SUBDOMAIN: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.AWS_SES_SETTINGS,
description:
'Driver used for the emailing domain feature — AWS_SES for production, LOG for local development (no AWS credentials needed)',
type: ConfigVariableType.ENUM,
options: Object.values(EmailingDomainDriver),
})
@CastToUpperSnakeCase()
EMAILING_DOMAIN_DRIVER: EmailingDomainDriver = EmailingDomainDriver.AWS_SES;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.AWS_SES_SETTINGS,
description: 'AWS region',