[WIP] Feat/marketing emails (#21173)

Marketing/campaign emails on top of the emailing-domain (SES) feature:
send a broadcast to a hand-picked list, with per-customer-domain
unsubscribe links and opt-out-only **unsubscribe topics**.

## Model

Standard objects (workspace schema, flat-metadata):
- `messageCampaign` — a campaign send (subject, body template, from
address, status, list, optional unsubscribe topic).
- `messageList` + `messageListMember` — the hand-picked audience (person
↔ list join). A campaign's recipients are its list's members; everyone
is sendable unless suppressed.

Core entities (`core` schema, workspace-scoped — readable by the public
unsubscribe flow without a workspace context):
- `unsubscribeTopic` — an opt-out-only category (name, description,
visibility). There is no opt-in subscription state.
- `messageSuppression` — the single consent store: a row with
`unsubscribeTopicId` NULL is a global block; a row with an
`unsubscribeTopicId` and reason `UNSUBSCRIBE` is a per-topic opt-out.
Two partial unique indexes dedupe global vs per-topic rows (Postgres
treats NULLs as distinct).
- `emailingDomain` — the workspace's SES sending domain,
auto-provisioned when an email channel is added (and cleaned up when its
last channel is removed), with verification status + DNS records.

Campaign messages reuse the existing `message` / `messageThread` /
`messageParticipant` model — one outbound `message` per recipient with a
`deliveryStatus` state machine.

## Sending

- `sendMessageCampaign` resolves the audience **under the caller's
permissions**, creates the campaign, and enqueues a single fan-out job
(the request never materializes per-recipient rows or jobs).
- The fan-out job materializes one QUEUED message per recipient
(deterministic ids → idempotent re-runs, reconciles crash-orphaned rows)
and fans out per-recipient send jobs carrying **only ids**.
- Each send job renders per-recipient `{{variable}}` merge fields and
sends via `EmailingDomainSenderService`, which applies suppression
(global + per-topic) and the unsubscribe footer/headers. Suppressed
recipients are recorded `SKIPPED`.
- The campaign finalizes `SENT`, or `SENT_WITH_ERRORS` if any recipient
terminally failed.
- `previewMessageCampaignAudience` returns a pre-send breakdown (total /
without-email / duplicate / globally-unsubscribed / topic-unsubscribed /
sendable), shown as a hint under the composer pickers.

## Unsubscribe

- Encrypted (AES-256-GCM) token carrying workspaceId, address, optional
`unsubscribeTopicId`, `issuedAt`, and a `preview` flag.
- One-click POST (RFC 8058) + `mailto:` — topic-scoped when the token
carries a topic, global otherwise.
- Preferences page: a checkbox per visible topic (checked = still
receiving); submitting creates per-topic opt-outs for unchecked topics
and lifts re-checked ones (UNSUBSCRIBE only — never
`BOUNCE`/`COMPLAINT`, never a global block).
- A **Preview** action in settings opens the live page via a
preview-claim token; opt-out POSTs are no-ops for preview tokens, so
previewing never mutates state.
- SES webhooks: inbound unsubscribe + outbound bounce/complaint →
suppression (race-safe against at-least-once delivery, with reason
escalation that never downgrades).
- Per-customer unsubscribe hostname (Cloudflare DNS); sends are gated on
it being active, except in LOG/demo mode.

## Architecture

Campaign orchestration, suppression, the sender, the unsubscribe
controller, and the SES webhook handlers live in `src/modules/emailing`
+ `src/modules/messaging-webhooks` (the workspace-feature layer).
`core-modules/emailing-domain` keeps the SES driver, domain
provisioning, the `unsubscribeTopic` / `messageSuppression` core
entities, and the unsubscribe token/hostname plumbing. Domain creation
is validated (`CreateEmailingDomainInput` — domain-format regex,
lowercased) before any value reaches SES or the unsubscribe hostname.

## Frontend

- Campaign composer side panel (from / list / unsubscribe topic /
subject / body) with a live audience-preview hint.
- Email settings: email channels each showing their auto-provisioned
sending domain in a single section (status + DNS records + a "Check
verification" action), plus an **Unsubscribe Topics** section to
create/manage topics and preview the recipient page. A demo-mode banner
is shown when the LOG driver is active.

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
neo773
2026-06-13 22:07:39 +05:30
committed by GitHub
parent 2c5da39dc5
commit 5d892bdfd0
238 changed files with 10433 additions and 3517 deletions
@@ -50,6 +50,7 @@ export enum EngineComponentKey {
FRONT_COMPONENT_RENDERER = 'FRONT_COMPONENT_RENDERER',
REPLY_TO_EMAIL_THREAD = 'REPLY_TO_EMAIL_THREAD',
COMPOSE_EMAIL = 'COMPOSE_EMAIL',
COMPOSE_CAMPAIGN = 'COMPOSE_CAMPAIGN',
// TODO: Remove deprecated keys once upgrade:1-21:refactor-navigation-commands has run on all workspaces
// Deprecated: replaced by NAVIGATION engine key with payload
@@ -23,6 +23,10 @@ const NAVIGATION_FEATURE_FLAG_GATE_BY_OBJECT_UNIVERSAL_IDENTIFIER: Partial<
> = {
[STANDARD_OBJECTS.callRecording.universalIdentifier]:
FeatureFlagKey.IS_CALL_RECORDING_ENABLED,
[STANDARD_OBJECTS.messageCampaign.universalIdentifier]:
FeatureFlagKey.IS_EMAIL_GROUP_ENABLED,
[STANDARD_OBJECTS.messageList.universalIdentifier]:
FeatureFlagKey.IS_EMAIL_GROUP_ENABLED,
};
export const buildNavigationConditionalAvailabilityExpression = ({
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EmailingDomainModule } from 'src/engine/core-modules/emailing-domain/emailing-domain.module';
import { ConnectedAccountMetadataModule } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.module';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { MessageChannelGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/message-channel/interceptors/message-channel-graphql-api-exception.interceptor';
@@ -17,6 +18,7 @@ import { MessagingImportManagerModule } from 'src/modules/messaging/message-impo
PermissionsModule,
ConnectedAccountMetadataModule,
MessagingImportManagerModule,
EmailingDomainModule,
WorkspaceEventEmitterModule,
],
providers: [
@@ -4,6 +4,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { In, Repository } from 'typeorm';
import {
@@ -16,6 +17,8 @@ import {
MessageChannelVisibility,
} from 'twenty-shared/types';
import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type';
import { EmailingDomainService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain.service';
import { StorageDriverType } from 'src/engine/core-modules/file-storage/interfaces/file-storage.interface';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
@@ -31,6 +34,7 @@ import { type MessageChannelDeletedEvent } from 'src/engine/metadata-modules/mes
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { INBOUND_EMAIL_LOCAL_PART_PREFIX } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/constants/inbound-email-local-part-prefix.constant';
import { INBOUND_EMAIL_LOCAL_PART_RANDOM_BYTES } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/constants/inbound-email-local-part-random-bytes.constant';
import { getDomainFromEmail } from 'src/utils/get-domain-from-email';
@Injectable()
export class MessageChannelMetadataService {
@@ -39,6 +43,7 @@ export class MessageChannelMetadataService {
private readonly repository: Repository<MessageChannelEntity>,
private readonly connectedAccountMetadataService: ConnectedAccountMetadataService,
private readonly twentyConfigService: TwentyConfigService,
private readonly emailingDomainService: EmailingDomainService,
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
) {}
@@ -219,10 +224,14 @@ export class MessageChannelMetadataService {
'INBOUND_EMAIL_DOMAIN',
);
const storageType = this.twentyConfigService.get('STORAGE_TYPE');
const isEmailingDomainInDemoMode =
this.twentyConfigService.get('EMAILING_DOMAIN_DRIVER') ===
EmailingDomainDriver.LOG;
if (
!isNonEmptyString(inboundEmailDomain) ||
storageType !== StorageDriverType.S_3
!isEmailingDomainInDemoMode &&
(!isNonEmptyString(inboundEmailDomain) ||
storageType !== StorageDriverType.S_3)
) {
throw new MessageChannelException(
'Email handles are not configured: INBOUND_EMAIL_DOMAIN must be set and STORAGE_TYPE must be S3',
@@ -230,11 +239,24 @@ export class MessageChannelMetadataService {
);
}
const sendDomain = getDomainFromEmail(handle)?.toLowerCase();
if (isNonEmptyString(sendDomain)) {
await this.emailingDomainService.ensureEmailingDomain(
sendDomain,
workspaceId,
);
}
const localPart =
INBOUND_EMAIL_LOCAL_PART_PREFIX +
randomBytes(INBOUND_EMAIL_LOCAL_PART_RANDOM_BYTES).toString('hex');
const forwardingAddress = `${localPart}@${inboundEmailDomain}`;
const forwardingDomain = isNonEmptyString(inboundEmailDomain)
? inboundEmailDomain
: 'demo.invalid';
const forwardingAddress = `${localPart}@${forwardingDomain}`;
const connectedAccount = await this.connectedAccountMetadataService.create({
workspaceId,
@@ -266,6 +288,36 @@ export class MessageChannelMetadataService {
return { messageChannel, forwardingAddress };
}
async getOrCreateEmailGroupChannel({
fromAddress,
userWorkspaceId,
workspaceId,
}: {
fromAddress: string;
userWorkspaceId: string;
workspaceId: string;
}): Promise<MessageChannelDTO> {
const existingChannel = await this.repository.findOne({
where: {
workspaceId,
type: MessageChannelType.EMAIL_GROUP,
connectedAccount: { handle: fromAddress },
},
});
if (existingChannel) {
return existingChannel;
}
const { messageChannel } = await this.createEmailGroupChannel({
handle: fromAddress,
userWorkspaceId,
workspaceId,
});
return messageChannel;
}
async delete({
id,
workspaceId,
@@ -287,4 +339,70 @@ export class MessageChannelMetadataService {
return messageChannel;
}
async deleteEmailGroupChannel({
id,
userWorkspaceId,
workspaceId,
}: {
id: string;
userWorkspaceId: string;
workspaceId: string;
}): Promise<MessageChannelDTO> {
const messageChannel = await this.verifyOwnership({
id,
userWorkspaceId,
workspaceId,
});
if (messageChannel.type !== MessageChannelType.EMAIL_GROUP) {
throw new MessageChannelException(
`Message channel ${id} is not an email group`,
MessageChannelExceptionCode.INVALID_MESSAGE_CHANNEL_INPUT,
);
}
const connectedAccount =
await this.connectedAccountMetadataService.findById({
id: messageChannel.connectedAccountId,
workspaceId,
});
const sendDomain = getDomainFromEmail(
connectedAccount?.handle ?? '',
)?.toLowerCase();
await this.connectedAccountMetadataService.delete({
id: messageChannel.connectedAccountId,
workspaceId,
});
if (
isNonEmptyString(sendDomain) &&
!(await this.hasEmailGroupChannelForDomain(workspaceId, sendDomain))
) {
await this.emailingDomainService.deleteEmailingDomainByDomainIfExists(
workspaceId,
sendDomain,
);
}
return messageChannel;
}
private async hasEmailGroupChannelForDomain(
workspaceId: string,
domain: string,
): Promise<boolean> {
const emailGroupChannels = await this.repository.find({
where: { workspaceId, type: MessageChannelType.EMAIL_GROUP },
relations: { connectedAccount: true },
});
return emailGroupChannels.some(
(channel) =>
isDefined(channel.connectedAccount) &&
getDomainFromEmail(channel.connectedAccount.handle)?.toLowerCase() ===
domain,
);
}
}
@@ -186,25 +186,10 @@ export class MessageChannelResolver {
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<MessageChannelDTO> {
const messageChannel =
await this.messageChannelMetadataService.verifyOwnership({
id,
userWorkspaceId,
workspaceId: workspace.id,
});
if (messageChannel.type !== MessageChannelType.EMAIL_GROUP) {
throw new MessageChannelException(
`Message channel ${id} is not an email group`,
MessageChannelExceptionCode.INVALID_MESSAGE_CHANNEL_INPUT,
);
}
await this.connectedAccountMetadataService.delete({
id: messageChannel.connectedAccountId,
return this.messageChannelMetadataService.deleteEmailGroupChannel({
id,
userWorkspaceId,
workspaceId: workspace.id,
});
return messageChannel;
}
}