[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
@@ -0,0 +1,158 @@
import {
BadRequestException,
Body,
Controller,
Get,
Header,
HttpCode,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { UnsubscribeTokenService } from 'src/engine/core-modules/emailing-domain/services/unsubscribe-token.service';
import { MessageSuppressionReason } from 'src/engine/core-modules/emailing-domain/types/message-suppression-reason.type';
import { MessageSuppressionSource } from 'src/engine/core-modules/emailing-domain/types/message-suppression-source.type';
import { type UnsubscribeTokenPayload } from 'src/engine/core-modules/emailing-domain/types/unsubscribe-token-payload.type';
import { buildUnsubscribePreferencesPage } from 'src/engine/core-modules/emailing-domain/utils/build-unsubscribe-preferences-page.util';
import { buildUnsubscribeResultPage } from 'src/engine/core-modules/emailing-domain/utils/build-unsubscribe-result-page.util';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { MessageSuppressionService } from 'src/modules/emailing/services/message-suppression.service';
const UNSUBSCRIBE_TOKEN_FORMAT = /^[A-Za-z0-9_-]{1,1024}$/;
const UPDATE_PREFERENCES_PATH = '/emailing/unsubscribe/preferences';
const UNSUBSCRIBE_ALL_PATH = '/emailing/unsubscribe/all';
const HTML_CONTENT_TYPE = 'text/html; charset=utf-8';
const PREVIEW_RESULT_PAGE = buildUnsubscribeResultPage(
'Preview',
'This is a preview — no changes were saved.',
);
type UnsubscribeFormBody = {
t?: string;
unsubscribeTopicId?: string | string[];
};
@Controller('emailing/unsubscribe')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
export class UnsubscribeController {
constructor(
private readonly unsubscribeTokenService: UnsubscribeTokenService,
private readonly messageSuppressionService: MessageSuppressionService,
) {}
@Post()
@HttpCode(200)
async handleOneClickUnsubscribe(@Query('t') token: string): Promise<void> {
const payload = this.verifyTokenOrThrow(token);
if (payload.preview === true) {
return;
}
await this.messageSuppressionService.suppress({
workspaceId: payload.workspaceId,
emailAddress: payload.emailAddress,
reason: MessageSuppressionReason.UNSUBSCRIBE,
source: MessageSuppressionSource.SYSTEM,
unsubscribeTopicId: payload.unsubscribeTopicId ?? null,
});
}
@Get()
@Header('Content-Type', HTML_CONTENT_TYPE)
async handlePreferencesPage(@Query('t') token: string): Promise<string> {
const payload = this.verifyTokenOrThrow(token);
const topics = await this.messageSuppressionService.getTopicOptOutState({
workspaceId: payload.workspaceId,
emailAddress: payload.emailAddress,
});
return buildUnsubscribePreferencesPage({
token,
topics,
updatePath: UPDATE_PREFERENCES_PATH,
unsubscribeAllPath: UNSUBSCRIBE_ALL_PATH,
});
}
@Post('preferences')
@Header('Content-Type', HTML_CONTENT_TYPE)
async handleUpdatePreferences(
@Body() body: UnsubscribeFormBody,
): Promise<string> {
const payload = this.verifyTokenOrThrow(body.t);
if (payload.preview === true) {
return PREVIEW_RESULT_PAGE;
}
await this.messageSuppressionService.setTopicOptOuts({
workspaceId: payload.workspaceId,
emailAddress: payload.emailAddress,
keptTopicIds: this.normalizeTopicIds(body.unsubscribeTopicId),
});
return buildUnsubscribeResultPage(
'Preferences updated',
'Your email preferences have been saved.',
);
}
@Post('all')
@Header('Content-Type', HTML_CONTENT_TYPE)
async handleUnsubscribeAll(
@Body() body: UnsubscribeFormBody,
): Promise<string> {
const payload = this.verifyTokenOrThrow(body.t);
if (payload.preview === true) {
return PREVIEW_RESULT_PAGE;
}
await this.messageSuppressionService.suppress({
workspaceId: payload.workspaceId,
emailAddress: payload.emailAddress,
reason: MessageSuppressionReason.UNSUBSCRIBE,
source: MessageSuppressionSource.SYSTEM,
});
return buildUnsubscribeResultPage(
'You have been unsubscribed',
'You will no longer receive marketing emails from this sender.',
);
}
private normalizeTopicIds(
unsubscribeTopicId: string | string[] | undefined,
): string[] {
if (Array.isArray(unsubscribeTopicId)) {
return unsubscribeTopicId.filter(isNonEmptyString);
}
return isNonEmptyString(unsubscribeTopicId) ? [unsubscribeTopicId] : [];
}
private verifyTokenOrThrow(
token: string | undefined,
): UnsubscribeTokenPayload {
if (!isNonEmptyString(token) || !UNSUBSCRIBE_TOKEN_FORMAT.test(token)) {
throw new BadRequestException('Malformed unsubscribe token');
}
const payload = this.unsubscribeTokenService.verify(token);
if (payload === null) {
throw new BadRequestException('Invalid unsubscribe token');
}
return payload;
}
}