[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:
+35
@@ -0,0 +1,35 @@
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
|
||||
import { type MessageChannel } from '@/accounts/types/MessageChannel';
|
||||
import { getEmailChannelDomain } from '@/settings/accounts/utils/getEmailChannelDomain';
|
||||
import { GetEmailingDomainsDocument } from '~/generated-metadata/graphql';
|
||||
import { getColorByEmailingDomainStatus } from '~/pages/settings/emailing-domains/utils/getEmailingDomainStatusColor';
|
||||
import { getTextByEmailingDomainStatus } from '~/pages/settings/emailing-domains/utils/getEmailingDomainStatusText';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Status } from 'twenty-ui-deprecated/display';
|
||||
|
||||
type SettingsWorkspaceEmailChannelDomainStatusCellProps = {
|
||||
item: MessageChannel;
|
||||
};
|
||||
|
||||
export const SettingsWorkspaceEmailChannelDomainStatusCell = ({
|
||||
item,
|
||||
}: SettingsWorkspaceEmailChannelDomainStatusCellProps) => {
|
||||
const { data } = useQuery(GetEmailingDomainsDocument);
|
||||
|
||||
const channelDomain = getEmailChannelDomain(item.connectedAccount?.handle);
|
||||
const emailingDomain = data?.getEmailingDomains?.find(
|
||||
(domain) => domain.domain.toLowerCase() === channelDomain,
|
||||
);
|
||||
|
||||
if (!isDefined(emailingDomain)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Status
|
||||
color={getColorByEmailingDomainStatus(emailingDomain.status)}
|
||||
text={getTextByEmailingDomainStatus(emailingDomain.status)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+9
-3
@@ -3,6 +3,7 @@ import { useLingui } from '@lingui/react/macro';
|
||||
import { type MessageChannel } from '@/accounts/types/MessageChannel';
|
||||
import { useMyMessageChannels } from '@/settings/accounts/hooks/useMyMessageChannels';
|
||||
import { SettingsTableListSection } from '@/settings/components/SettingsTableListSection';
|
||||
import { SettingsWorkspaceEmailChannelDomainStatusCell } from '@/settings/workspace/components/SettingsWorkspaceEmailChannelDomainStatusCell';
|
||||
import { SettingsWorkspaceEmailGroupForwardingCell } from '@/settings/workspace/components/SettingsWorkspaceEmailGroupForwardingCell';
|
||||
import { SettingsWorkspaceEmailGroupSourceCell } from '@/settings/workspace/components/SettingsWorkspaceEmailGroupSourceCell';
|
||||
import { MessageChannelType, SettingsPath } from 'twenty-shared/types';
|
||||
@@ -19,7 +20,7 @@ export const SettingsWorkspaceEmailGroupSection = () => {
|
||||
|
||||
return (
|
||||
<SettingsTableListSection<MessageChannel>
|
||||
title={t`Email Handles`}
|
||||
title={t`Email Channels`}
|
||||
description={t`Shared addresses your workspace uses to send and receive email.`}
|
||||
items={emailGroupChannels}
|
||||
columns={[
|
||||
@@ -28,14 +29,19 @@ export const SettingsWorkspaceEmailGroupSection = () => {
|
||||
label: t`Forwarding address`,
|
||||
Cell: SettingsWorkspaceEmailGroupForwardingCell,
|
||||
},
|
||||
{
|
||||
label: t`Domain`,
|
||||
align: 'right',
|
||||
Cell: SettingsWorkspaceEmailChannelDomainStatusCell,
|
||||
},
|
||||
]}
|
||||
gridAutoColumns="1fr 1fr"
|
||||
gridAutoColumns="1fr 1fr 1fr"
|
||||
onRowClick={(channel) =>
|
||||
navigateSettings(SettingsPath.EmailGroupChannelDetail, {
|
||||
messageChannelId: channel.id,
|
||||
})
|
||||
}
|
||||
footerButtonLabel={t`Add email handle`}
|
||||
footerButtonLabel={t`Add email channel`}
|
||||
onFooterButtonClick={() =>
|
||||
navigateSettings(SettingsPath.NewEmailGroupChannel)
|
||||
}
|
||||
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
|
||||
import { SettingsTableListSection } from '@/settings/components/SettingsTableListSection';
|
||||
import { SettingsEmailingDomainNameCell } from '@/settings/emailing-domains/components/SettingsEmailingDomainNameCell';
|
||||
import { SettingsEmailingDomainStatusCell } from '@/settings/emailing-domains/components/SettingsEmailingDomainStatusCell';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import {
|
||||
type GetEmailingDomainsQuery,
|
||||
GetEmailingDomainsDocument,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
|
||||
type EmailingDomain = GetEmailingDomainsQuery['getEmailingDomains'][0];
|
||||
|
||||
export const SettingsWorkspaceEmailingDomainsSection = () => {
|
||||
const { t } = useLingui();
|
||||
const navigateSettings = useNavigateSettings();
|
||||
|
||||
const { data } = useQuery(GetEmailingDomainsDocument);
|
||||
const emailingDomains = data?.getEmailingDomains ?? [];
|
||||
|
||||
return (
|
||||
<SettingsTableListSection<EmailingDomain>
|
||||
title={t`Emailing Domains`}
|
||||
description={t`Verify domains so the workspace can send outbound email through them.`}
|
||||
items={emailingDomains}
|
||||
columns={[
|
||||
{ label: t`Domain`, Cell: SettingsEmailingDomainNameCell },
|
||||
{
|
||||
label: t`Status`,
|
||||
align: 'right',
|
||||
Cell: SettingsEmailingDomainStatusCell,
|
||||
},
|
||||
]}
|
||||
gridAutoColumns="1fr 1fr"
|
||||
onRowClick={(emailingDomain) =>
|
||||
navigateSettings(SettingsPath.EmailingDomainDetail, {
|
||||
domainId: emailingDomain.id,
|
||||
})
|
||||
}
|
||||
footerButtonLabel={t`Add emailing domain`}
|
||||
onFooterButtonClick={() =>
|
||||
navigateSettings(SettingsPath.NewEmailingDomain)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user