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>
123 lines
4.0 KiB
TypeScript
123 lines
4.0 KiB
TypeScript
import {
|
|
ErrorCode,
|
|
UserInputError,
|
|
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
|
import { getDomainFromEmailOrThrow } from 'src/utils/get-domain-from-email-or-throw';
|
|
|
|
describe('getDomainFromEmailOrThrow', () => {
|
|
it('should return the domain name for a valid email', () => {
|
|
expect(getDomainFromEmailOrThrow('user@example.com')).toBe('example.com');
|
|
});
|
|
|
|
it('should throw a UserInputError if email is empty', () => {
|
|
expect(() => getDomainFromEmailOrThrow('')).toThrow(UserInputError);
|
|
expect(() => getDomainFromEmailOrThrow('')).toThrow(
|
|
'Email is required. Please provide a valid email address.',
|
|
);
|
|
|
|
try {
|
|
getDomainFromEmailOrThrow('');
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(UserInputError);
|
|
expect(error.extensions.code).toBe(ErrorCode.BAD_USER_INPUT);
|
|
expect(error.extensions.userFriendlyMessage.message).toContain(
|
|
'Email is required. Please provide a valid email address.',
|
|
);
|
|
}
|
|
});
|
|
|
|
it('should throw a UserInputError if email does not contain "@"', () => {
|
|
expect(() => getDomainFromEmailOrThrow('userexample.com')).toThrow(
|
|
UserInputError,
|
|
);
|
|
|
|
try {
|
|
getDomainFromEmailOrThrow('userexample.com');
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(UserInputError);
|
|
expect(error.extensions.code).toBe(ErrorCode.BAD_USER_INPUT);
|
|
expect(error.extensions.userFriendlyMessage.message).toContain(
|
|
'The provided email address is not valid. Please use a standard email format (e.g., user@example.com).',
|
|
);
|
|
}
|
|
});
|
|
|
|
it('should return the domain after the last "@"', () => {
|
|
expect(getDomainFromEmailOrThrow('"a@b"@example.com')).toBe('example.com');
|
|
expect(getDomainFromEmailOrThrow('user@example@com')).toBe('com');
|
|
});
|
|
|
|
it('should throw a UserInputError if domain part is empty', () => {
|
|
expect(() => getDomainFromEmailOrThrow('user@')).toThrow(UserInputError);
|
|
|
|
try {
|
|
getDomainFromEmailOrThrow('user@');
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(UserInputError);
|
|
expect(error.extensions.code).toBe(ErrorCode.BAD_USER_INPUT);
|
|
expect(error.extensions.userFriendlyMessage.message).toContain(
|
|
'The provided email address is missing a domain. Please use a standard email format (e.g., user@example.com).',
|
|
);
|
|
}
|
|
});
|
|
|
|
// Edge cases with weird but potentially valid email formats
|
|
it('should handle email with plus addressing', () => {
|
|
expect(getDomainFromEmailOrThrow('user+tag@example.com')).toBe(
|
|
'example.com',
|
|
);
|
|
});
|
|
|
|
it('should handle email with dots in local part', () => {
|
|
expect(getDomainFromEmailOrThrow('user.name@example.com')).toBe(
|
|
'example.com',
|
|
);
|
|
});
|
|
|
|
it('should handle email with subdomain', () => {
|
|
expect(getDomainFromEmailOrThrow('user@mail.example.com')).toBe(
|
|
'mail.example.com',
|
|
);
|
|
});
|
|
|
|
it('should handle email with numeric domain', () => {
|
|
expect(getDomainFromEmailOrThrow('user@123.456.1.2')).toBe('123.456.1.2');
|
|
});
|
|
|
|
it('should handle email with hyphenated domain', () => {
|
|
expect(getDomainFromEmailOrThrow('user@my-domain.com')).toBe(
|
|
'my-domain.com',
|
|
);
|
|
});
|
|
|
|
it('should handle email with international domain (punycode)', () => {
|
|
expect(getDomainFromEmailOrThrow('user@xn--nxasmq6b.com')).toBe(
|
|
'xn--nxasmq6b.com',
|
|
);
|
|
});
|
|
|
|
it('should handle email with very long domain', () => {
|
|
const longDomain = 'a'.repeat(160) + '.com';
|
|
|
|
expect(getDomainFromEmailOrThrow(`user@${longDomain}`)).toBe(longDomain);
|
|
});
|
|
|
|
it('should handle email with quoted local part containing spaces', () => {
|
|
expect(getDomainFromEmailOrThrow('"user name"@example.com')).toBe(
|
|
'example.com',
|
|
);
|
|
});
|
|
|
|
it('should handle email with special characters in quoted local part', () => {
|
|
expect(getDomainFromEmailOrThrow('"user@#$%"@example.com')).toBe(
|
|
'example.com',
|
|
);
|
|
});
|
|
|
|
it('should handle email with quoted local part containing @', () => {
|
|
expect(getDomainFromEmailOrThrow('"user@local"@example.com')).toBe(
|
|
'example.com',
|
|
);
|
|
});
|
|
});
|