5d892bdfd0
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>
172 lines
4.7 KiB
TypeScript
172 lines
4.7 KiB
TypeScript
import { gql } from 'graphql-tag';
|
|
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
|
|
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
|
|
import { FeatureFlagKey } from 'twenty-shared/types';
|
|
|
|
const CREATE_UNSUBSCRIBE_TOPIC = gql`
|
|
mutation CreateUnsubscribeTopic($input: CreateUnsubscribeTopicInput!) {
|
|
createUnsubscribeTopic(input: $input) {
|
|
id
|
|
name
|
|
description
|
|
visibility
|
|
}
|
|
}
|
|
`;
|
|
|
|
const UPDATE_UNSUBSCRIBE_TOPIC = gql`
|
|
mutation UpdateUnsubscribeTopic($input: UpdateUnsubscribeTopicInput!) {
|
|
updateUnsubscribeTopic(input: $input) {
|
|
id
|
|
name
|
|
description
|
|
visibility
|
|
}
|
|
}
|
|
`;
|
|
|
|
const DELETE_UNSUBSCRIBE_TOPIC = gql`
|
|
mutation DeleteUnsubscribeTopic($id: String!) {
|
|
deleteUnsubscribeTopic(id: $id)
|
|
}
|
|
`;
|
|
|
|
const UNSUBSCRIBE_TOPICS = gql`
|
|
query UnsubscribeTopics {
|
|
unsubscribeTopics {
|
|
id
|
|
name
|
|
description
|
|
visibility
|
|
}
|
|
}
|
|
`;
|
|
|
|
describe('unsubscribeTopicResolver (integration)', () => {
|
|
const createdTopicIds: string[] = [];
|
|
|
|
beforeAll(async () => {
|
|
await updateFeatureFlag({
|
|
featureFlag: FeatureFlagKey.IS_EMAIL_GROUP_ENABLED,
|
|
value: true,
|
|
expectToFail: false,
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await updateFeatureFlag({
|
|
featureFlag: FeatureFlagKey.IS_EMAIL_GROUP_ENABLED,
|
|
value: false,
|
|
expectToFail: false,
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
for (const id of createdTopicIds) {
|
|
await testDataSource
|
|
.query('DELETE FROM core."unsubscribeTopic" WHERE id = $1', [id])
|
|
.catch(() => {});
|
|
}
|
|
createdTopicIds.length = 0;
|
|
});
|
|
|
|
const createTopic = async (input: {
|
|
name: string;
|
|
description?: string;
|
|
visibility?: 'PUBLIC' | 'PRIVATE';
|
|
}) => {
|
|
const response = await makeMetadataAPIRequest({
|
|
query: CREATE_UNSUBSCRIBE_TOPIC,
|
|
variables: { input },
|
|
});
|
|
|
|
const createdId: string | undefined =
|
|
response.body.data?.createUnsubscribeTopic?.id;
|
|
|
|
if (createdId !== undefined) {
|
|
createdTopicIds.push(createdId);
|
|
}
|
|
|
|
return response;
|
|
};
|
|
|
|
it('should create a topic and default its visibility to PRIVATE', async () => {
|
|
const response = await createTopic({ name: 'Product updates' });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.errors).toBeUndefined();
|
|
expect(response.body.data.createUnsubscribeTopic).toMatchObject({
|
|
name: 'Product updates',
|
|
description: null,
|
|
visibility: 'PRIVATE',
|
|
});
|
|
expect(response.body.data.createUnsubscribeTopic.id).toBeDefined();
|
|
});
|
|
|
|
it('should persist the topic so it is returned by the list query', async () => {
|
|
const createResponse = await createTopic({
|
|
name: 'Newsletter',
|
|
description: 'Monthly news',
|
|
visibility: 'PUBLIC',
|
|
});
|
|
const createdId = createResponse.body.data.createUnsubscribeTopic.id;
|
|
|
|
const listResponse = await makeMetadataAPIRequest({
|
|
query: UNSUBSCRIBE_TOPICS,
|
|
});
|
|
|
|
expect(listResponse.body.errors).toBeUndefined();
|
|
expect(
|
|
listResponse.body.data.unsubscribeTopics.find(
|
|
(topic: { id: string }) => topic.id === createdId,
|
|
),
|
|
).toMatchObject({
|
|
name: 'Newsletter',
|
|
description: 'Monthly news',
|
|
visibility: 'PUBLIC',
|
|
});
|
|
});
|
|
|
|
it('should update an existing topic name and visibility', async () => {
|
|
const createResponse = await createTopic({ name: 'Draft topic' });
|
|
const createdId = createResponse.body.data.createUnsubscribeTopic.id;
|
|
|
|
const updateResponse = await makeMetadataAPIRequest({
|
|
query: UPDATE_UNSUBSCRIBE_TOPIC,
|
|
variables: {
|
|
input: { id: createdId, name: 'Renamed topic', visibility: 'PUBLIC' },
|
|
},
|
|
});
|
|
|
|
expect(updateResponse.body.errors).toBeUndefined();
|
|
expect(updateResponse.body.data.updateUnsubscribeTopic).toMatchObject({
|
|
id: createdId,
|
|
name: 'Renamed topic',
|
|
visibility: 'PUBLIC',
|
|
});
|
|
});
|
|
|
|
it('should delete a topic so it no longer appears in the list', async () => {
|
|
const createResponse = await createTopic({ name: 'Temporary topic' });
|
|
const createdId = createResponse.body.data.createUnsubscribeTopic.id;
|
|
|
|
const deleteResponse = await makeMetadataAPIRequest({
|
|
query: DELETE_UNSUBSCRIBE_TOPIC,
|
|
variables: { id: createdId },
|
|
});
|
|
|
|
expect(deleteResponse.body.errors).toBeUndefined();
|
|
expect(deleteResponse.body.data.deleteUnsubscribeTopic).toBe(true);
|
|
|
|
const listResponse = await makeMetadataAPIRequest({
|
|
query: UNSUBSCRIBE_TOPICS,
|
|
});
|
|
|
|
expect(
|
|
listResponse.body.data.unsubscribeTopics.some(
|
|
(topic: { id: string }) => topic.id === createdId,
|
|
),
|
|
).toBe(false);
|
|
});
|
|
});
|