[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:
+3
@@ -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',
|
||||
|
||||
+2
-2
@@ -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(
|
||||
|
||||
+3
@@ -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;
|
||||
}
|
||||
|
||||
+4
@@ -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;
|
||||
}
|
||||
|
||||
-2
@@ -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 & {
|
||||
|
||||
+4
-3
@@ -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) {
|
||||
|
||||
+6
-4
@@ -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(
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
@@ -2,4 +2,5 @@ export type SendMessageResult = {
|
||||
headerMessageId: string;
|
||||
messageExternalId?: string;
|
||||
threadExternalId?: string;
|
||||
deliveredRecipients?: { to: string[]; cc: string[]; bcc: string[] };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user