[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
-36
@@ -2,21 +2,21 @@ import {
|
||||
ErrorCode,
|
||||
UserInputError,
|
||||
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email';
|
||||
import { getDomainFromEmailOrThrow } from 'src/utils/get-domain-from-email-or-throw';
|
||||
|
||||
describe('getDomainNameByEmail', () => {
|
||||
describe('getDomainFromEmailOrThrow', () => {
|
||||
it('should return the domain name for a valid email', () => {
|
||||
expect(getDomainNameByEmail('user@example.com')).toBe('example.com');
|
||||
expect(getDomainFromEmailOrThrow('user@example.com')).toBe('example.com');
|
||||
});
|
||||
|
||||
it('should throw a UserInputError if email is empty', () => {
|
||||
expect(() => getDomainNameByEmail('')).toThrow(UserInputError);
|
||||
expect(() => getDomainNameByEmail('')).toThrow(
|
||||
expect(() => getDomainFromEmailOrThrow('')).toThrow(UserInputError);
|
||||
expect(() => getDomainFromEmailOrThrow('')).toThrow(
|
||||
'Email is required. Please provide a valid email address.',
|
||||
);
|
||||
|
||||
try {
|
||||
getDomainNameByEmail('');
|
||||
getDomainFromEmailOrThrow('');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(UserInputError);
|
||||
expect(error.extensions.code).toBe(ErrorCode.BAD_USER_INPUT);
|
||||
@@ -27,12 +27,12 @@ describe('getDomainNameByEmail', () => {
|
||||
});
|
||||
|
||||
it('should throw a UserInputError if email does not contain "@"', () => {
|
||||
expect(() => getDomainNameByEmail('userexample.com')).toThrow(
|
||||
expect(() => getDomainFromEmailOrThrow('userexample.com')).toThrow(
|
||||
UserInputError,
|
||||
);
|
||||
|
||||
try {
|
||||
getDomainNameByEmail('userexample.com');
|
||||
getDomainFromEmailOrThrow('userexample.com');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(UserInputError);
|
||||
expect(error.extensions.code).toBe(ErrorCode.BAD_USER_INPUT);
|
||||
@@ -42,27 +42,16 @@ describe('getDomainNameByEmail', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw a UserInputError if email has more than one "@"', () => {
|
||||
expect(() => getDomainNameByEmail('user@example@com')).toThrow(
|
||||
UserInputError,
|
||||
);
|
||||
|
||||
try {
|
||||
getDomainNameByEmail('user@example@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(() => getDomainNameByEmail('user@')).toThrow(UserInputError);
|
||||
expect(() => getDomainFromEmailOrThrow('user@')).toThrow(UserInputError);
|
||||
|
||||
try {
|
||||
getDomainNameByEmail('user@');
|
||||
getDomainFromEmailOrThrow('user@');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(UserInputError);
|
||||
expect(error.extensions.code).toBe(ErrorCode.BAD_USER_INPUT);
|
||||
@@ -74,29 +63,35 @@ describe('getDomainNameByEmail', () => {
|
||||
|
||||
// Edge cases with weird but potentially valid email formats
|
||||
it('should handle email with plus addressing', () => {
|
||||
expect(getDomainNameByEmail('user+tag@example.com')).toBe('example.com');
|
||||
expect(getDomainFromEmailOrThrow('user+tag@example.com')).toBe(
|
||||
'example.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle email with dots in local part', () => {
|
||||
expect(getDomainNameByEmail('user.name@example.com')).toBe('example.com');
|
||||
expect(getDomainFromEmailOrThrow('user.name@example.com')).toBe(
|
||||
'example.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle email with subdomain', () => {
|
||||
expect(getDomainNameByEmail('user@mail.example.com')).toBe(
|
||||
expect(getDomainFromEmailOrThrow('user@mail.example.com')).toBe(
|
||||
'mail.example.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle email with numeric domain', () => {
|
||||
expect(getDomainNameByEmail('user@123.456.1.2')).toBe('123.456.1.2');
|
||||
expect(getDomainFromEmailOrThrow('user@123.456.1.2')).toBe('123.456.1.2');
|
||||
});
|
||||
|
||||
it('should handle email with hyphenated domain', () => {
|
||||
expect(getDomainNameByEmail('user@my-domain.com')).toBe('my-domain.com');
|
||||
expect(getDomainFromEmailOrThrow('user@my-domain.com')).toBe(
|
||||
'my-domain.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle email with international domain (punycode)', () => {
|
||||
expect(getDomainNameByEmail('user@xn--nxasmq6b.com')).toBe(
|
||||
expect(getDomainFromEmailOrThrow('user@xn--nxasmq6b.com')).toBe(
|
||||
'xn--nxasmq6b.com',
|
||||
);
|
||||
});
|
||||
@@ -104,19 +99,23 @@ describe('getDomainNameByEmail', () => {
|
||||
it('should handle email with very long domain', () => {
|
||||
const longDomain = 'a'.repeat(160) + '.com';
|
||||
|
||||
expect(getDomainNameByEmail(`user@${longDomain}`)).toBe(longDomain);
|
||||
expect(getDomainFromEmailOrThrow(`user@${longDomain}`)).toBe(longDomain);
|
||||
});
|
||||
|
||||
it('should handle email with quoted local part containing spaces', () => {
|
||||
expect(getDomainNameByEmail('"user name"@example.com')).toBe('example.com');
|
||||
expect(getDomainFromEmailOrThrow('"user name"@example.com')).toBe(
|
||||
'example.com',
|
||||
);
|
||||
});
|
||||
|
||||
it.skip('should handle email with special characters in quoted local part', () => {
|
||||
expect(getDomainNameByEmail('"user@#$%"@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.skip('should handle email with quoted local part containing @', () => {
|
||||
expect(getDomainNameByEmail('"user@local"@example.com')).toBe(
|
||||
it('should handle email with quoted local part containing @', () => {
|
||||
expect(getDomainFromEmailOrThrow('"user@local"@example.com')).toBe(
|
||||
'example.com',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getDomainFromEmail } from 'src/utils/get-domain-from-email';
|
||||
|
||||
describe('getDomainFromEmail', () => {
|
||||
it('returns the domain of a simple address', () => {
|
||||
expect(getDomainFromEmail('user@example.com')).toBe('example.com');
|
||||
});
|
||||
|
||||
it('returns the domain after the last "@" for a quoted local part', () => {
|
||||
expect(getDomainFromEmail('"a@b"@example.com')).toBe('example.com');
|
||||
});
|
||||
|
||||
it('returns subdomains intact', () => {
|
||||
expect(getDomainFromEmail('user@mail.example.com')).toBe(
|
||||
'mail.example.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves the original case', () => {
|
||||
expect(getDomainFromEmail('User@Example.COM')).toBe('Example.COM');
|
||||
});
|
||||
|
||||
it('returns an empty string when the domain part is missing', () => {
|
||||
expect(getDomainFromEmail('user@')).toBe('');
|
||||
});
|
||||
|
||||
it('returns undefined when there is no "@"', () => {
|
||||
expect(getDomainFromEmail('not-an-email')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,6 @@ describe('isWorkEmail', () => {
|
||||
});
|
||||
|
||||
it('should return false for an email with undefined domain', () => {
|
||||
// Assuming getDomainNameByEmail(email) returns undefined if no domain.
|
||||
expect(isWorkEmail('user@')).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
+5
-6
@@ -2,8 +2,9 @@ import { msg } from '@lingui/core/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
import { getDomainFromEmail } from 'src/utils/get-domain-from-email';
|
||||
|
||||
export const getDomainNameByEmail = (email: string) => {
|
||||
export const getDomainFromEmailOrThrow = (email: string) => {
|
||||
if (!isNonEmptyString(email)) {
|
||||
throw new UserInputError(
|
||||
'Email is required. Please provide a valid email address.',
|
||||
@@ -13,9 +14,7 @@ export const getDomainNameByEmail = (email: string) => {
|
||||
);
|
||||
}
|
||||
|
||||
const fields = email.split('@');
|
||||
|
||||
if (fields.length !== 2) {
|
||||
if (!email.includes('@')) {
|
||||
throw new UserInputError(
|
||||
'The provided email address is not valid. Please use a standard email format (e.g., user@example.com).',
|
||||
{
|
||||
@@ -24,9 +23,9 @@ export const getDomainNameByEmail = (email: string) => {
|
||||
);
|
||||
}
|
||||
|
||||
const domain = fields[1];
|
||||
const domain = getDomainFromEmail(email);
|
||||
|
||||
if (!domain) {
|
||||
if (!isNonEmptyString(domain)) {
|
||||
throw new UserInputError(
|
||||
'The provided email address is missing a domain. Please use a standard email format (e.g., user@example.com).',
|
||||
{
|
||||
@@ -0,0 +1,9 @@
|
||||
export const getDomainFromEmail = (email: string): string | undefined => {
|
||||
const lastAtIndex = email.lastIndexOf('@');
|
||||
|
||||
if (lastAtIndex === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return email.slice(lastAtIndex + 1);
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
import { emailProvidersSet } from 'src/utils/email-providers';
|
||||
import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email';
|
||||
import { getDomainFromEmail } from 'src/utils/get-domain-from-email';
|
||||
|
||||
export const isWorkEmail = (email: string) => {
|
||||
try {
|
||||
return !emailProvidersSet.has(getDomainNameByEmail(email));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const domain = getDomainFromEmail(email);
|
||||
|
||||
return isNonEmptyString(domain) && !emailProvidersSet.has(domain);
|
||||
};
|
||||
|
||||
export const isWorkDomain = (domain: string) => {
|
||||
|
||||
Reference in New Issue
Block a user