[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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user