[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
@@ -26,6 +26,9 @@ const createMockMessage = (
messageThread: null,
messageChannelMessageAssociations: [],
messageParticipants: [],
messageCampaign: null,
messageCampaignId: null,
deliveryStatus: null,
deletedAt: null,
createdAt: '2024-03-20T09:00:00Z',
updatedAt: '2024-03-20T09:00:00Z',
@@ -7,7 +7,6 @@ import { MessageChannelVisibility } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { In, Repository } from 'typeorm';
import { NotFoundError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
@@ -90,7 +89,8 @@ export class ApplyMessagesVisibilityRestrictionsService {
.filter(isDefined);
if (messageChannels.length === 0) {
throw new NotFoundError('Associated message channels not found');
messages.splice(i, 1);
continue;
}
const messageChannelsGroupByVisibility = groupBy(
@@ -4,6 +4,7 @@ import {
} from 'twenty-shared/types';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { type MessageCampaignWorkspaceEntity } from 'src/modules/emailing/standard-objects/message-campaign.workspace-entity';
import { type FieldTypeAndNameMetadata } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util';
import { type EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface';
import { type MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity';
@@ -25,4 +26,6 @@ export class MessageParticipantWorkspaceEntity extends BaseWorkspaceEntity {
personId: string | null;
workspaceMember: EntityRelation<WorkspaceMemberWorkspaceEntity> | null;
workspaceMemberId: string | null;
messageCampaign: EntityRelation<MessageCampaignWorkspaceEntity> | null;
messageCampaignId: string | null;
}
@@ -3,6 +3,7 @@ import { FieldMetadataType } from 'twenty-shared/types';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { type FieldTypeAndNameMetadata } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util';
import { type EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface';
import { type MessageCampaignWorkspaceEntity } from 'src/modules/emailing/standard-objects/message-campaign.workspace-entity';
import { type MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
import { type MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity';
import { type MessageThreadWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-thread.workspace-entity';
@@ -24,4 +25,7 @@ export class MessageWorkspaceEntity extends BaseWorkspaceEntity {
messageChannelMessageAssociations: EntityRelation<
MessageChannelMessageAssociationWorkspaceEntity[]
>;
messageCampaign: EntityRelation<MessageCampaignWorkspaceEntity> | null;
messageCampaignId: string | null;
deliveryStatus: string | null;
}
@@ -54,7 +54,6 @@ import { MessagingMessagesImportService } from 'src/modules/messaging/message-im
import { MessagingProcessFolderActionsService } from 'src/modules/messaging/message-import-manager/services/messaging-process-folder-actions.service';
import { MessagingProcessGroupEmailActionsService } from 'src/modules/messaging/message-import-manager/services/messaging-process-group-email-actions.service';
import { MessagingSaveMessagesAndEnqueueContactCreationService } from 'src/modules/messaging/message-import-manager/services/messaging-save-messages-and-enqueue-contact-creation.service';
import { MessagingWebhooksModule } from 'src/engine/core-modules/messaging-webhooks/messaging-webhooks.module';
import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository';
import { MessageParticipantManagerModule } from 'src/modules/messaging/message-participant-manager/message-participant-manager.module';
import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/messaging-monitoring.module';
@@ -86,7 +85,6 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
MessagingMessageCleanerModule,
WorkspaceEventEmitterModule,
ConnectedAccountModule,
MessagingWebhooksModule,
],
providers: [
provideWorkspaceScopedRepository(MessageChannelEntity),
@@ -13,6 +13,9 @@ export type Message = Omit<
| 'messageThreadId'
| 'messageFolders'
| 'id'
| 'messageCampaign'
| 'messageCampaignId'
| 'deliveryStatus'
> & {
attachments: {
filename: string;
@@ -43,6 +46,8 @@ export type MessageParticipant = Omit<
| 'workspaceMember'
| 'message'
| 'messageId'
| 'messageCampaign'
| 'messageCampaignId'
>;
export type MessageWithParticipants = Message & {
@@ -1,7 +1,7 @@
import { isDefined } from 'twenty-shared/utils';
import { type MessageWithParticipants } from 'src/modules/messaging/message-import-manager/types/message';
import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email';
import { getDomainFromEmailOrThrow } from 'src/utils/get-domain-from-email-or-throw';
export const filterOutInternals = (
primaryHandle: string,
@@ -12,7 +12,7 @@ export const filterOutInternals = (
return true;
}
const primaryHandleDomain = getDomainNameByEmail(primaryHandle);
const primaryHandleDomain = getDomainFromEmailOrThrow(primaryHandle);
try {
const isAllHandlesFromSameDomain = message.participants
@@ -20,7 +20,8 @@ export const filterOutInternals = (
.every(
(participant) =>
isDefined(participant.handle) &&
getDomainNameByEmail(participant.handle) === primaryHandleDomain,
getDomainFromEmailOrThrow(participant.handle) ===
primaryHandleDomain,
);
if (isAllHandlesFromSameDomain) {
@@ -5,7 +5,7 @@ import { isDefined } from 'twenty-shared/utils';
import { EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-status.type';
import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
import { EmailingDomainService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain.service';
import { EmailingDomainSenderService } from 'src/modules/emailing/services/emailing-domain-sender.service';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import {
MessageChannelException,
@@ -16,13 +16,14 @@ import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scope
import { type MessageOutboundDriver } from 'src/modules/messaging/message-outbound-manager/interfaces/message-outbound-driver.interface';
import { type SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
import { type SendMessageResult } from 'src/modules/messaging/message-outbound-manager/types/send-message-result.type';
import { getDomainFromEmail } from 'src/utils/get-domain-from-email';
@Injectable()
export class EmailGroupMessageOutboundService implements MessageOutboundDriver {
constructor(
@InjectWorkspaceScopedRepository(EmailingDomainEntity)
private readonly emailingDomainRepository: WorkspaceScopedRepository<EmailingDomainEntity>,
private readonly emailingDomainService: EmailingDomainService,
private readonly emailingDomainSenderService: EmailingDomainSenderService,
) {}
async sendMessage(
@@ -38,7 +39,7 @@ export class EmailGroupMessageOutboundService implements MessageOutboundDriver {
);
}
const result = await this.emailingDomainService.sendEmail(
const result = await this.emailingDomainSenderService.sendEmail(
connectedAccount.workspaceId,
emailingDomain.id,
{
@@ -59,6 +60,7 @@ export class EmailGroupMessageOutboundService implements MessageOutboundDriver {
return {
headerMessageId: result.messageId,
messageExternalId: result.messageId,
deliveredRecipients: result.deliveredRecipients,
};
}
@@ -72,7 +74,7 @@ export class EmailGroupMessageOutboundService implements MessageOutboundDriver {
private async resolveEmailingDomain(
connectedAccount: ConnectedAccountEntity,
): Promise<EmailingDomainEntity> {
const handleDomain = connectedAccount.handle.split('@')[1];
const handleDomain = getDomainFromEmail(connectedAccount.handle);
if (!isNonEmptyString(handleDomain)) {
throw new MessageChannelException(
@@ -1,8 +1,8 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EmailingDomainModule } from 'src/engine/core-modules/emailing-domain/emailing-domain.module';
import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
import { EmailingModule } from 'src/modules/emailing/emailing.module';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository';
@@ -24,7 +24,7 @@ import { SentMessagePersistenceService } from 'src/modules/messaging/message-out
MessagingIMAPDriverModule,
MessagingSmtpDriverModule,
MessagingImportManagerModule,
EmailingDomainModule,
EmailingModule,
TypeOrmModule.forFeature([
MessageChannelEntity,
MessageFolderEntity,
@@ -42,7 +42,7 @@ export class SendEmailService {
sendResult,
subject: data.sanitizedSubject,
body: data.plainTextBody,
recipients: data.recipients,
recipients: sendResult.deliveredRecipients ?? data.recipients,
connectedAccount: data.connectedAccount,
messageChannelId: data.messageChannelId!,
inReplyTo: data.inReplyTo,
@@ -2,4 +2,5 @@ export type SendMessageResult = {
headerMessageId: string;
messageExternalId?: string;
threadExternalId?: string;
deliveredRecipients?: { to: string[]; cc: string[]; bcc: string[] };
};