[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:
+1
@@ -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
|
||||
|
||||
+4
@@ -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 = ({
|
||||
|
||||
+2
@@ -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: [
|
||||
|
||||
+121
-3
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-18
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user