@aglyn/shared-util-email 1.0.0-beta.217 → 1.0.0-beta.218
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/lib/email-deliverability.d.ts +9 -2
- package/src/lib/email-deliverability.js.map +1 -1
- package/src/lib/risk-notice-catalog.js +7 -1
- package/src/lib/risk-notice-catalog.js.map +1 -1
- package/src/lib/send-email.d.ts +7 -0
- package/src/lib/send-email.js +4 -2
- package/src/lib/send-email.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/shared-util-email",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.218",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
28
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.218",
|
|
29
|
+
"@aglyn/shared-util-http": "1.0.0-beta.218",
|
|
30
30
|
"@msgpack/msgpack": "^3.1.3",
|
|
31
31
|
"@swc/helpers": "0.5.23"
|
|
32
32
|
},
|
|
@@ -99,7 +99,9 @@ export declare function resolveMailDomain(resolver: MailDnsResolver, domain: str
|
|
|
99
99
|
* somebody who has not written to the sender.
|
|
100
100
|
*/
|
|
101
101
|
export type EmailSendPurpose = 'transactional' | 'bulk' | 'cold';
|
|
102
|
-
export type EmailDeliverabilityCode = 'invalid_syntax' | 'no_mx' | 'null_mx' | 'implicit_mx' | 'typo_domain' | 'disposable_domain' | 'role_address' | 'gateway_held'
|
|
102
|
+
export type EmailDeliverabilityCode = 'invalid_syntax' | 'no_mx' | 'null_mx' | 'implicit_mx' | 'typo_domain' | 'disposable_domain' | 'role_address' | 'gateway_held'
|
|
103
|
+
/** The account holding the address is banned (AGL-3420). */
|
|
104
|
+
| 'account_banned';
|
|
103
105
|
export type EmailDeliverabilitySeverity = 'refuse' | 'hold' | 'warn';
|
|
104
106
|
export interface EmailDeliverabilityFinding {
|
|
105
107
|
code: EmailDeliverabilityCode;
|
|
@@ -169,9 +171,14 @@ export interface EmailDeliverabilityPreflightRequest {
|
|
|
169
171
|
sendingSource: 'custom' | 'shared' | 'platform';
|
|
170
172
|
context?: string;
|
|
171
173
|
hostId?: string | null;
|
|
174
|
+
/** The send is a lock or lift notice, which reaches a banned account. */
|
|
175
|
+
accountBanExempt?: boolean;
|
|
172
176
|
}
|
|
173
177
|
export interface EmailDeliverabilityPreflightVerdict {
|
|
174
|
-
/**
|
|
178
|
+
/**
|
|
179
|
+
* Recipients whose domain takes no mail, or whose account is banned.
|
|
180
|
+
* Never sent, whatever the purpose.
|
|
181
|
+
*/
|
|
175
182
|
refused: Array<{
|
|
176
183
|
email: string;
|
|
177
184
|
code: EmailDeliverabilityCode;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/email-deliverability.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * WILL THIS ADDRESS BOUNCE? — ANSWERED BEFORE THE SEND (AGL-3328).\n *\n * The one engine every email path asks: the Resend sender through\n * `sendEmail`'s preflight seam, campaigns before their review count, the\n * CRM at capture time, and Sequences at enrollment and before each send.\n * It answers from three free facts and nothing else:\n *\n * 1. **The address itself** — its syntax, a typo'd provider domain\n * (`gmial.com`, which is SUGGESTED, never corrected), a disposable\n * domain, a role address (`info@`), which warns for bulk and cold mail\n * only.\n * 2. **The domain's MX** — no MX at all, RFC 7505's null MX (`.`), or no MX\n * but an address record, which RFC 5321 §5.1 still delivers to and so\n * only warns.\n * 3. **The gateway's history with the sending domain** — see\n * `mail-gateway.ts`: a gateway that refused this sender twice lately\n * holds the next BULK send into it.\n *\n * Never an SMTP `RCPT TO` probe. A probe is a connection to the recipient's\n * server that sends nothing, which is exactly what a directory-harvest\n * attack looks like, and gateways list the probing IP for it. Nor a paid\n * verification API: the free checks are the whole of this module.\n *\n * ## The two severities that stop mail, and who they may stop\n *\n * - **refuse** — the domain takes no mail. Every purpose, transactional\n * included: a password reset to a domain with no MX is a hard bounce\n * charged to the sending domain, and nobody receives it either way.\n * - **hold** — the gateway refused this sender twice. BULK and COLD mail\n * only. Mail the recipient asked for (a password reset, a receipt, a\n * booking confirmation) is never held for a gateway: the person is\n * waiting for it, and one more refusal costs less than their not getting\n * it.\n *\n * A public mailbox provider is never refused for its MX. `gmail.com` has MX\n * records; a resolver that says otherwise is having a bad minute, and a\n * refusal there would suppress real people.\n *\n * ## Pure\n *\n * DNS is handed in as a {@link MailDnsResolver}. This library's barrel\n * reaches the browser, so `node:dns` belongs to the server layer that\n * installs the preflight — `@aglyn/tenant-data-admin`.\n *==========================================*/\n\nimport {\n classifyMailGateway,\n MAIL_DOMAIN_INTEL_TTL_MS,\n MAIL_DOMAIN_NEGATIVE_TTL_MS,\n type MailGateway,\n type MailGatewayStanding,\n mailGatewayHolds,\n mailGatewayHoldSentence,\n normalizeMailHost,\n} from './mail-gateway'\n\n/*==========================================\n * THE ADDRESS\n *==========================================*/\n\n/** RFC 1035's ceiling on a name, which no real domain reaches. */\nconst DOMAIN_MAX = 253\n\n/** A label: letters, digits and hyphens, not starting or ending on a hyphen. */\nconst LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/\n\n/** The local part a mailbox provider accepts in practice: RFC 5322's dot-atom. */\nconst LOCAL_PART = /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*$/\n\n/** An address as the checks read it: trimmed and lowercased, or `null` when it is not one. */\nexport function normalizeDeliverabilityEmail(value: unknown): string | null {\n const address = String(value ?? '')\n .trim()\n .toLowerCase()\n const at = address.lastIndexOf('@')\n if (at < 1 || at === address.length - 1 || address.length > 254) return null\n const local = address.slice(0, at)\n const domain = address.slice(at + 1)\n if (local.length > 64 || !LOCAL_PART.test(local)) return null\n if (!isDeliverabilityDomain(domain)) return null\n return address\n}\n\n/** Whether a value is a domain with at least two labels and a non-numeric top level. */\nexport function isDeliverabilityDomain(value: unknown): boolean {\n const domain = String(value ?? '')\n if (!domain || domain.length > DOMAIN_MAX) return false\n const labels = domain.split('.')\n if (labels.length < 2 || !labels.every((label) => LABEL.test(label))) return false\n return !/^\\d+$/.test(labels[labels.length - 1])\n}\n\n/** The domain of an address, lowercased, or `null` for a value that is not one. */\nexport function emailAddressDomain(email: unknown): string | null {\n const address = normalizeDeliverabilityEmail(email)\n return address ? address.slice(address.lastIndexOf('@') + 1) : null\n}\n\n/**\n * The mailboxes a company's shared inbox answers as rather than a person.\n * Legal to mail and often read, but a cold email or a campaign sent there\n * reaches nobody who asked for it, and a spam report from a shared inbox is\n * a report from the whole company.\n */\nconst ROLE_LOCAL_PARTS: ReadonlySet<string> = new Set(\n // A word list rather than quoted strings: `marketing` is also a plugin's\n // id, and `check:plugin-domain-in-core` reads a quoted one as a plugin's\n // domain leaking into shared code — which a mailbox name is not.\n (\n 'abuse accounts admin administrator billing careers contact enquiries ' +\n 'help hello hr info inquiries jobs mail marketing media news no-reply ' +\n 'noreply office postmaster press privacy sales security service support ' +\n 'team webmaster'\n ).split(' '),\n)\n\n/** Whether an address is a role mailbox (`info@`, `sales@`), read before any `+tag`. */\nexport function isRoleEmailAddress(email: unknown): boolean {\n const address = normalizeDeliverabilityEmail(email)\n if (!address) return false\n const local = address.slice(0, address.lastIndexOf('@')).split('+')[0]\n return ROLE_LOCAL_PARTS.has(local)\n}\n\n/**\n * Domains that hand out a mailbox that expires. Not a complete list — no\n * list is — but the ones a signup form actually meets.\n */\nconst DISPOSABLE_DOMAINS: ReadonlySet<string> = new Set([\n '10minutemail.com',\n '20minutemail.com',\n 'burnermail.io',\n 'discard.email',\n 'dispostable.com',\n 'emailondeck.com',\n 'fakeinbox.com',\n 'getairmail.com',\n 'getnada.com',\n 'guerrillamail.com',\n 'guerrillamail.net',\n 'guerrillamail.org',\n 'guerrillamailblock.com',\n 'maildrop.cc',\n 'mailinator.com',\n 'mailinator.net',\n 'mailnesia.com',\n 'mintemail.com',\n 'mohmal.com',\n 'mytemp.email',\n 'sharklasers.com',\n 'spamgourmet.com',\n 'temp-mail.org',\n 'tempail.com',\n 'tempmail.com',\n 'tempmailo.com',\n 'tempr.email',\n 'throwawaymail.com',\n 'trashmail.com',\n 'trashmail.de',\n 'yopmail.com',\n 'yopmail.net',\n])\n\n/** Whether a domain is a disposable-mailbox provider. */\nexport function isDisposableEmailDomain(domain: unknown): boolean {\n return DISPOSABLE_DOMAINS.has(normalizeMailHost(domain))\n}\n\n/**\n * The providers a mistyped domain is compared against. Long labels only: a\n * three-letter label (`aol`, `msn`) is one keystroke from dozens of real\n * domains, and a suggestion there would be noise.\n */\nconst TYPO_TARGETS: readonly string[] = [\n 'gmail.com',\n 'googlemail.com',\n 'yahoo.com',\n 'hotmail.com',\n 'outlook.com',\n 'icloud.com',\n 'comcast.net',\n 'protonmail.com',\n 'verizon.net',\n 'sbcglobal.net',\n 'bellsouth.net',\n]\n\n/** Real providers one keystroke from a target, which must never be \"corrected\". */\nconst TYPO_EXEMPT: ReadonlySet<string> = new Set([\n 'mail.com',\n 'gmx.com',\n 'ymail.com',\n 'email.com',\n 'hotmail.co.uk',\n 'yahoo.co.uk',\n 'outlook.fr',\n])\n\n/** Top-level domains that are a slip of `com` or `net`. */\nconst TLD_SLIPS: Readonly<Record<string, string>> = {\n co: 'com',\n con: 'com',\n cm: 'com',\n om: 'com',\n comm: 'com',\n cmo: 'com',\n ocm: 'com',\n vom: 'com',\n xom: 'com',\n cpm: 'com',\n coom: 'com',\n ne: 'net',\n nte: 'net',\n ner: 'net',\n nett: 'net',\n}\n\n/** Damerau–Levenshtein distance, stopping early above 1: only \"one slip\" matters here. */\nfunction withinOneEdit(a: string, b: string): boolean {\n if (a === b) return true\n if (Math.abs(a.length - b.length) > 1) return false\n if (a.length === b.length) {\n const diffs: number[] = []\n for (let index = 0; index < a.length; index += 1) {\n if (a[index] !== b[index]) diffs.push(index)\n if (diffs.length > 2) return false\n }\n if (diffs.length === 1) return true\n // One transposition of neighbors: `gmial` for `gmail`.\n return (\n diffs.length === 2 &&\n diffs[1] === diffs[0] + 1 &&\n a[diffs[0]] === b[diffs[1]] &&\n a[diffs[1]] === b[diffs[0]]\n )\n }\n const [longer, shorter] = a.length > b.length ? [a, b] : [b, a]\n for (let index = 0; index < longer.length; index += 1) {\n if (longer.slice(0, index) + longer.slice(index + 1) === shorter) return true\n }\n return false\n}\n\n/**\n * The provider a domain most likely meant, or `null` — a SUGGESTION a\n * surface may offer, never a correction applied: `gmial.com` may be a real\n * domain somebody owns, and changing an address a person typed is sending\n * their mail to someone else.\n */\nexport function suggestEmailDomain(domain: unknown): string | null {\n const name = normalizeMailHost(domain)\n if (!name || TYPO_EXEMPT.has(name) || TYPO_TARGETS.includes(name)) return null\n const dot = name.lastIndexOf('.')\n if (dot < 1) return null\n const label = name.slice(0, dot)\n const tld = name.slice(dot + 1)\n for (const target of TYPO_TARGETS) {\n const targetDot = target.lastIndexOf('.')\n const targetLabel = target.slice(0, targetDot)\n const targetTld = target.slice(targetDot + 1)\n if (label === targetLabel && TLD_SLIPS[tld] === targetTld) return target\n if (tld === targetTld && label.length >= 4 && withinOneEdit(label, targetLabel)) return target\n }\n return null\n}\n\n/*==========================================\n * THE DOMAIN'S MX\n *==========================================*/\n\n/** One MX record as a resolver answers it. */\nexport interface MailMxRecord {\n exchange: string\n priority: number\n}\n\n/**\n * The DNS the checks read, handed in by the server layer.\n *\n * `resolveMx` resolves the domain's MX records. A domain that publishes\n * none resolves `[]` or rejects with a `code` of `ENOTFOUND`, `ENODATA` or\n * `NXDOMAIN` — Node's own convention — and any other rejection is a\n * resolver that could not answer, which the checks read as \"unknown\" and\n * never as \"no MX\".\n *\n * `resolveAddress`, when given, answers whether the domain has an address\n * record (A or AAAA): the implicit MX a domain with no MX is still\n * delivered to. Omitted, a domain with no MX reads as `no_mx`.\n */\nexport interface MailDnsResolver {\n resolveMx(domain: string): Promise<ReadonlyArray<MailMxRecord>>\n resolveAddress?(domain: string): Promise<boolean>\n}\n\n/**\n * What a domain's DNS says about receiving mail.\n *\n * - `mx` — it publishes MX records.\n * - `implicit_mx` — no MX, but an address record, which RFC 5321 §5.1 still\n * delivers to. Deliverable; warned, because it is usually a domain whose\n * mail was never set up.\n * - `null_mx` — RFC 7505's `MX 0 .`: the domain says outright it takes no mail.\n * - `no_mx` — no MX and no address record: nothing to deliver to.\n */\nexport type MailDomainStatus = 'mx' | 'implicit_mx' | 'null_mx' | 'no_mx'\n\n/** One domain's answer, as the platform cache stores it. */\nexport interface MailDomainIntel {\n domain: string\n status: MailDomainStatus\n /** The MX exchanges, lowest preference first; empty for every status but `mx`. */\n mx: string[]\n gateway: MailGateway\n /** When the domain was looked up; trusted for {@link mailDomainIntelTtlMs}. */\n resolvedAtMs: number\n}\n\nexport const MAIL_DOMAIN_STATUSES: readonly MailDomainStatus[] = ['mx', 'implicit_mx', 'null_mx', 'no_mx']\n\n/** Whether a status means the domain takes no mail. */\nexport function mailDomainRefusesMail(status: MailDomainStatus | null | undefined): boolean {\n return status === 'no_mx' || status === 'null_mx'\n}\n\n/** How long an answer is trusted: a week for a domain that takes mail, a day for one that does not. */\nexport function mailDomainIntelTtlMs(status: MailDomainStatus): number {\n return mailDomainRefusesMail(status) ? MAIL_DOMAIN_NEGATIVE_TTL_MS : MAIL_DOMAIN_INTEL_TTL_MS\n}\n\n/** Whether a cached answer is still trusted at `nowMs`. */\nexport function isMailDomainIntelFresh(intel: MailDomainIntel | null | undefined, nowMs: number): boolean {\n return Boolean(intel) && intel.resolvedAtMs + mailDomainIntelTtlMs(intel.status) > nowMs\n}\n\n/** The resolver's codes that mean \"this domain has no such record\", as opposed to \"I could not ask\". */\nconst NO_RECORD_CODES = new Set(['ENOTFOUND', 'ENODATA', 'NXDOMAIN', 'NOTFOUND'])\n\n/** Whether a resolver rejection is an answer (\"no such record\") rather than a failure to get one. */\nexport function isNoRecordDnsError(error: unknown): boolean {\n return NO_RECORD_CODES.has(String((error as { code?: unknown })?.code ?? '').toUpperCase())\n}\n\n/** A stored answer in its model shape, or `null` for no document or an unreadable one. */\nexport function readStoredMailDomainIntel(\n id: string,\n data: Record<string, unknown> | null | undefined,\n): MailDomainIntel | null {\n if (!data) return null\n const status = (MAIL_DOMAIN_STATUSES as readonly unknown[]).includes(data['status'])\n ? (data['status'] as MailDomainStatus)\n : null\n if (!status) return null\n const mx = Array.isArray(data['mx']) ? data['mx'].filter((entry): entry is string => typeof entry === 'string') : []\n const resolvedAtMs = typeof data['resolvedAtMs'] === 'number' && Number.isFinite(data['resolvedAtMs']) ? data['resolvedAtMs'] : 0\n return {\n domain: typeof data['domain'] === 'string' && data['domain'] ? data['domain'] : id,\n status,\n mx,\n gateway: status === 'mx' ? classifyMailGateway(mx) : status === 'implicit_mx' ? 'other' : 'none',\n resolvedAtMs,\n }\n}\n\n/**\n * A domain's mail setup, looked up — see {@link MailDomainStatus}. Rejects\n * when the resolver could not answer, and the caller decides what a missing\n * answer means; every path here reads it as \"unknown\" and sends.\n */\nexport async function resolveMailDomain(\n resolver: MailDnsResolver,\n domain: string,\n nowMs: number,\n): Promise<MailDomainIntel> {\n let records: ReadonlyArray<MailMxRecord>\n try {\n records = await resolver.resolveMx(domain)\n } catch (error) {\n if (!isNoRecordDnsError(error)) throw error\n records = []\n }\n const exchanges = [...(records ?? [])]\n .filter((record) => record && typeof record.exchange === 'string')\n .sort((a, b) => (Number(a.priority) || 0) - (Number(b.priority) || 0))\n .map((record) => normalizeMailHost(record.exchange))\n const real = exchanges.filter((exchange) => exchange && exchange !== '.')\n if (real.length) {\n return { domain, status: 'mx', mx: real, gateway: classifyMailGateway(real), resolvedAtMs: nowMs }\n }\n // Records that are all \".\" (or empty, as Node reports a null MX) are RFC\n // 7505's null MX: the domain says it takes no mail, whatever its A says.\n if (exchanges.length) return { domain, status: 'null_mx', mx: [], gateway: 'none', resolvedAtMs: nowMs }\n if (resolver.resolveAddress) {\n let addressed: boolean\n try {\n addressed = await resolver.resolveAddress(domain)\n } catch (error) {\n if (!isNoRecordDnsError(error)) throw error\n addressed = false\n }\n if (addressed) return { domain, status: 'implicit_mx', mx: [], gateway: 'other', resolvedAtMs: nowMs }\n }\n return { domain, status: 'no_mx', mx: [], gateway: 'none', resolvedAtMs: nowMs }\n}\n\n/*==========================================\n * THE VERDICT\n *==========================================*/\n\n/**\n * Who a message is for, which decides what may stop it — see the module\n * note. `transactional` is mail the recipient asked for; `bulk` is a\n * campaign, a workflow's marketing step, a sweep; `cold` is a sequence to\n * somebody who has not written to the sender.\n */\nexport type EmailSendPurpose = 'transactional' | 'bulk' | 'cold'\n\nexport type EmailDeliverabilityCode =\n | 'invalid_syntax'\n | 'no_mx'\n | 'null_mx'\n | 'implicit_mx'\n | 'typo_domain'\n | 'disposable_domain'\n | 'role_address'\n | 'gateway_held'\n\nexport type EmailDeliverabilitySeverity = 'refuse' | 'hold' | 'warn'\n\nexport interface EmailDeliverabilityFinding {\n code: EmailDeliverabilityCode\n severity: EmailDeliverabilitySeverity\n /** A sentence a surface shows as is. */\n message: string\n /** `typo_domain` only: the address with the suggested domain. Never applied for the person. */\n suggestion?: string\n}\n\n/**\n * What the checks conclude, worst first: `refused` (never sent), `held`\n * (bulk and cold mail wait for a person), `deliverable` (nothing stops it,\n * warnings or not), `unchecked` (the MX could not be read, so nothing is\n * known — and nothing is stopped).\n */\nexport type EmailDeliverabilityOutcome = 'refused' | 'held' | 'deliverable' | 'unchecked'\n\nexport interface EmailDeliverabilityVerdict {\n /** The address, normalized, or `null` when it is not one. */\n email: string | null\n domain: string | null\n outcome: EmailDeliverabilityOutcome\n findings: EmailDeliverabilityFinding[]\n intel: MailDomainIntel | null\n gateway: MailGatewayStanding | null\n}\n\nexport interface EmailDeliverabilityInput {\n email: unknown\n purpose: EmailSendPurpose\n /** The domain's MX answer; `null` or absent when it could not be read. */\n intel?: MailDomainIntel | null\n /** The gateway's standing with the sending domain, when the caller read the ledger. */\n gateway?: MailGatewayStanding | null\n /**\n * The domain is a public mailbox provider (`gmail.com`), which is never\n * refused for its MX. The list lives with the CRM's, so the caller asks.\n */\n publicMailbox?: boolean\n /** A person already released the gateway hold for this address. */\n holdReleased?: boolean\n}\n\nconst SEVERITY_RANK: Record<EmailDeliverabilitySeverity, number> = { refuse: 0, hold: 1, warn: 2 }\n\n/** The whole verdict for one address — see the module note. */\nexport function assessEmailDeliverability(input: EmailDeliverabilityInput): EmailDeliverabilityVerdict {\n const email = normalizeDeliverabilityEmail(input.email)\n if (!email) {\n return {\n email: null,\n domain: null,\n outcome: 'refused',\n findings: [\n {\n code: 'invalid_syntax',\n severity: 'refuse',\n message: `${String(input.email ?? '').trim() || 'This'} is not an email address.`,\n },\n ],\n intel: null,\n gateway: null,\n }\n }\n const domain = email.slice(email.lastIndexOf('@') + 1)\n const findings: EmailDeliverabilityFinding[] = []\n const bulk = input.purpose !== 'transactional'\n const intel = input.intel ?? null\n\n if (intel && !input.publicMailbox) {\n if (intel.status === 'no_mx') {\n findings.push({\n code: 'no_mx',\n severity: 'refuse',\n message: `${domain} has no mail server, so ${email} would bounce.`,\n })\n } else if (intel.status === 'null_mx') {\n findings.push({\n code: 'null_mx',\n severity: 'refuse',\n message: `${domain} publishes a null MX record: it says it accepts no email, so ${email} would bounce.`,\n })\n } else if (intel.status === 'implicit_mx') {\n findings.push({\n code: 'implicit_mx',\n severity: 'warn',\n message: `${domain} has no MX record. Mail may still reach its web server's address, but most domains like this never set up email.`,\n })\n }\n }\n\n const suggestion = suggestEmailDomain(domain)\n if (suggestion) {\n const local = email.slice(0, email.lastIndexOf('@'))\n findings.push({\n code: 'typo_domain',\n severity: 'warn',\n message: `Did you mean ${local}@${suggestion}? ${domain} looks like a typo.`,\n suggestion: `${local}@${suggestion}`,\n })\n }\n if (isDisposableEmailDomain(domain)) {\n findings.push({\n code: 'disposable_domain',\n severity: 'warn',\n message: `${domain} hands out mailboxes that expire, so this address may stop working soon.`,\n })\n }\n if (bulk && isRoleEmailAddress(email)) {\n findings.push({\n code: 'role_address',\n severity: 'warn',\n message: `${email} is a shared role inbox rather than a person, which marketing and cold mail should avoid.`,\n })\n }\n\n const gateway = input.gateway ?? null\n if (bulk && gateway && input.holdReleased !== true && mailGatewayHolds(gateway)) {\n findings.push({\n code: 'gateway_held',\n severity: 'hold',\n message: `${mailGatewayHoldSentence(gateway.gateway, gateway.blocked30)}, so bulk mail to ${domain} is held.`,\n })\n }\n\n findings.sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity])\n const outcome: EmailDeliverabilityOutcome = findings.some((finding) => finding.severity === 'refuse')\n ? 'refused'\n : findings.some((finding) => finding.severity === 'hold')\n ? 'held'\n : intel\n ? 'deliverable'\n : 'unchecked'\n return { email, domain, outcome, findings, intel, gateway }\n}\n\n/** The finding that decided a refusal or a hold, or `null` when nothing stops the address. */\nexport function decidingDeliverabilityFinding(verdict: EmailDeliverabilityVerdict): EmailDeliverabilityFinding | null {\n return verdict.findings.find((finding) => finding.severity !== 'warn') ?? null\n}\n\n/**\n * What a bulk send's review says about the addresses the checks took out,\n * or `null` when they took out nobody: \"3 recipients excluded: no mail\n * server · 2 behind a gateway that refused this sender\".\n */\nexport function deliverabilityExclusionSummary(counts: {\n noMailServer?: number | null\n gatewayHeld?: number | null\n}): string | null {\n const noMailServer = Math.max(0, Math.floor(Number(counts?.noMailServer) || 0))\n const gatewayHeld = Math.max(0, Math.floor(Number(counts?.gatewayHeld) || 0))\n const total = noMailServer + gatewayHeld\n if (!total) return null\n const parts = [\n ...(noMailServer ? [`${noMailServer.toLocaleString('en-US')} no mail server`] : []),\n ...(gatewayHeld ? [`${gatewayHeld.toLocaleString('en-US')} behind a gateway that refused this sender`] : []),\n ]\n return `${total.toLocaleString('en-US')} ${total === 1 ? 'recipient' : 'recipients'} excluded: ${parts.join(' · ')}`\n}\n\n/*==========================================\n * THE SEAM ON `sendEmail`\n *\n * `sendEmail` lives in this library and the store it would need lives in\n * `@aglyn/tenant-data-admin`, which imports this one — so the durable half\n * reaches the send by injection, the way the send-rate governor and the\n * marketing gate do. Nothing installed is nothing checked: a deployment\n * without the server layer sends as it always did.\n *==========================================*/\n\n/** What `sendEmail` asks before a message leaves. */\nexport interface EmailDeliverabilityPreflightRequest {\n /** The recipients, normalized and lowercased. */\n recipients: string[]\n purpose: EmailSendPurpose\n /** The domain the message leaves from, or `null` when it could not be read. */\n sendingDomain: string | null\n /**\n * Whose reputation the sending domain is: a site's own verified domain\n * (`custom`), the pooled tenant identity (`shared`), or Aglyn's own\n * (`platform`). The last two are learned at platform level, where a block\n * hurts every tenant.\n */\n sendingSource: 'custom' | 'shared' | 'platform'\n context?: string\n hostId?: string | null\n}\n\nexport interface EmailDeliverabilityPreflightVerdict {\n /** Recipients whose domain takes no mail. Never sent, whatever the purpose. */\n refused: Array<{ email: string; code: EmailDeliverabilityCode; reason: string }>\n /** Recipients behind a gateway that refused this sender twice. Bulk and cold mail only. */\n held: Array<{ email: string; gateway: MailGateway; reason: string }>\n}\n\nexport type EmailDeliverabilityPreflight = (\n request: EmailDeliverabilityPreflightRequest,\n) => Promise<EmailDeliverabilityPreflightVerdict>\n\nlet installedPreflight: EmailDeliverabilityPreflight | null = null\n\n/** Installs the durable preflight. Called once, from `@aglyn/tenant-data-admin`. */\nexport function setEmailDeliverabilityPreflight(preflight: EmailDeliverabilityPreflight | null): void {\n installedPreflight = preflight\n}\n\n/** The installed preflight, or `null` when nothing has been installed. */\nexport function getEmailDeliverabilityPreflight(): EmailDeliverabilityPreflight | null {\n return installedPreflight\n}\n\n/** Test seam: forget any installed preflight. */\nexport function resetEmailDeliverabilityPreflightForTests(): void {\n installedPreflight = null\n}\n\n/**\n * How long `sendEmail` waits for the preflight before sending anyway. A\n * cold lookup is one DNS round trip and one document read; a preflight\n * slower than this is an outage on the control, and a control outage must\n * not become a mail outage.\n */\nexport const EMAIL_DELIVERABILITY_PREFLIGHT_TIMEOUT_MS = 2_500\n"],"names":["classifyMailGateway","MAIL_DOMAIN_INTEL_TTL_MS","MAIL_DOMAIN_NEGATIVE_TTL_MS","mailGatewayHolds","mailGatewayHoldSentence","normalizeMailHost","DOMAIN_MAX","LABEL","LOCAL_PART","normalizeDeliverabilityEmail","value","address","String","trim","toLowerCase","at","lastIndexOf","length","local","slice","domain","test","isDeliverabilityDomain","labels","split","every","label","emailAddressDomain","email","ROLE_LOCAL_PARTS","Set","isRoleEmailAddress","has","DISPOSABLE_DOMAINS","isDisposableEmailDomain","TYPO_TARGETS","TYPO_EXEMPT","TLD_SLIPS","co","con","cm","om","comm","cmo","ocm","vom","xom","cpm","coom","ne","nte","ner","nett","withinOneEdit","a","b","Math","abs","diffs","index","push","longer","shorter","suggestEmailDomain","name","includes","dot","tld","target","targetDot","targetLabel","targetTld","MAIL_DOMAIN_STATUSES","mailDomainRefusesMail","status","mailDomainIntelTtlMs","isMailDomainIntelFresh","intel","nowMs","Boolean","resolvedAtMs","NO_RECORD_CODES","isNoRecordDnsError","error","code","toUpperCase","readStoredMailDomainIntel","id","data","mx","Array","isArray","filter","entry","Number","isFinite","gateway","resolveMailDomain","resolver","records","resolveMx","exchanges","record","exchange","sort","priority","map","real","resolveAddress","addressed","SEVERITY_RANK","refuse","hold","warn","assessEmailDeliverability","input","outcome","findings","severity","message","bulk","purpose","publicMailbox","suggestion","holdReleased","blocked30","some","finding","decidingDeliverabilityFinding","verdict","find","deliverabilityExclusionSummary","counts","noMailServer","max","floor","gatewayHeld","total","parts","toLocaleString","join","installedPreflight","setEmailDeliverabilityPreflight","preflight","getEmailDeliverabilityPreflight","resetEmailDeliverabilityPreflightForTests","EMAIL_DELIVERABILITY_PREFLIGHT_TIMEOUT_MS"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/email-deliverability.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * WILL THIS ADDRESS BOUNCE? — ANSWERED BEFORE THE SEND (AGL-3328).\n *\n * The one engine every email path asks: the Resend sender through\n * `sendEmail`'s preflight seam, campaigns before their review count, the\n * CRM at capture time, and Sequences at enrollment and before each send.\n * It answers from three free facts and nothing else:\n *\n * 1. **The address itself** — its syntax, a typo'd provider domain\n * (`gmial.com`, which is SUGGESTED, never corrected), a disposable\n * domain, a role address (`info@`), which warns for bulk and cold mail\n * only.\n * 2. **The domain's MX** — no MX at all, RFC 7505's null MX (`.`), or no MX\n * but an address record, which RFC 5321 §5.1 still delivers to and so\n * only warns.\n * 3. **The gateway's history with the sending domain** — see\n * `mail-gateway.ts`: a gateway that refused this sender twice lately\n * holds the next BULK send into it.\n *\n * Never an SMTP `RCPT TO` probe. A probe is a connection to the recipient's\n * server that sends nothing, which is exactly what a directory-harvest\n * attack looks like, and gateways list the probing IP for it. Nor a paid\n * verification API: the free checks are the whole of this module.\n *\n * ## The two severities that stop mail, and who they may stop\n *\n * - **refuse** — the domain takes no mail. Every purpose, transactional\n * included: a password reset to a domain with no MX is a hard bounce\n * charged to the sending domain, and nobody receives it either way.\n * - **hold** — the gateway refused this sender twice. BULK and COLD mail\n * only. Mail the recipient asked for (a password reset, a receipt, a\n * booking confirmation) is never held for a gateway: the person is\n * waiting for it, and one more refusal costs less than their not getting\n * it.\n *\n * A public mailbox provider is never refused for its MX. `gmail.com` has MX\n * records; a resolver that says otherwise is having a bad minute, and a\n * refusal there would suppress real people.\n *\n * ## Pure\n *\n * DNS is handed in as a {@link MailDnsResolver}. This library's barrel\n * reaches the browser, so `node:dns` belongs to the server layer that\n * installs the preflight — `@aglyn/tenant-data-admin`.\n *==========================================*/\n\nimport {\n classifyMailGateway,\n MAIL_DOMAIN_INTEL_TTL_MS,\n MAIL_DOMAIN_NEGATIVE_TTL_MS,\n type MailGateway,\n type MailGatewayStanding,\n mailGatewayHolds,\n mailGatewayHoldSentence,\n normalizeMailHost,\n} from './mail-gateway'\n\n/*==========================================\n * THE ADDRESS\n *==========================================*/\n\n/** RFC 1035's ceiling on a name, which no real domain reaches. */\nconst DOMAIN_MAX = 253\n\n/** A label: letters, digits and hyphens, not starting or ending on a hyphen. */\nconst LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/\n\n/** The local part a mailbox provider accepts in practice: RFC 5322's dot-atom. */\nconst LOCAL_PART = /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*$/\n\n/** An address as the checks read it: trimmed and lowercased, or `null` when it is not one. */\nexport function normalizeDeliverabilityEmail(value: unknown): string | null {\n const address = String(value ?? '')\n .trim()\n .toLowerCase()\n const at = address.lastIndexOf('@')\n if (at < 1 || at === address.length - 1 || address.length > 254) return null\n const local = address.slice(0, at)\n const domain = address.slice(at + 1)\n if (local.length > 64 || !LOCAL_PART.test(local)) return null\n if (!isDeliverabilityDomain(domain)) return null\n return address\n}\n\n/** Whether a value is a domain with at least two labels and a non-numeric top level. */\nexport function isDeliverabilityDomain(value: unknown): boolean {\n const domain = String(value ?? '')\n if (!domain || domain.length > DOMAIN_MAX) return false\n const labels = domain.split('.')\n if (labels.length < 2 || !labels.every((label) => LABEL.test(label))) return false\n return !/^\\d+$/.test(labels[labels.length - 1])\n}\n\n/** The domain of an address, lowercased, or `null` for a value that is not one. */\nexport function emailAddressDomain(email: unknown): string | null {\n const address = normalizeDeliverabilityEmail(email)\n return address ? address.slice(address.lastIndexOf('@') + 1) : null\n}\n\n/**\n * The mailboxes a company's shared inbox answers as rather than a person.\n * Legal to mail and often read, but a cold email or a campaign sent there\n * reaches nobody who asked for it, and a spam report from a shared inbox is\n * a report from the whole company.\n */\nconst ROLE_LOCAL_PARTS: ReadonlySet<string> = new Set(\n // A word list rather than quoted strings: `marketing` is also a plugin's\n // id, and `check:plugin-domain-in-core` reads a quoted one as a plugin's\n // domain leaking into shared code — which a mailbox name is not.\n (\n 'abuse accounts admin administrator billing careers contact enquiries ' +\n 'help hello hr info inquiries jobs mail marketing media news no-reply ' +\n 'noreply office postmaster press privacy sales security service support ' +\n 'team webmaster'\n ).split(' '),\n)\n\n/** Whether an address is a role mailbox (`info@`, `sales@`), read before any `+tag`. */\nexport function isRoleEmailAddress(email: unknown): boolean {\n const address = normalizeDeliverabilityEmail(email)\n if (!address) return false\n const local = address.slice(0, address.lastIndexOf('@')).split('+')[0]\n return ROLE_LOCAL_PARTS.has(local)\n}\n\n/**\n * Domains that hand out a mailbox that expires. Not a complete list — no\n * list is — but the ones a signup form actually meets.\n */\nconst DISPOSABLE_DOMAINS: ReadonlySet<string> = new Set([\n '10minutemail.com',\n '20minutemail.com',\n 'burnermail.io',\n 'discard.email',\n 'dispostable.com',\n 'emailondeck.com',\n 'fakeinbox.com',\n 'getairmail.com',\n 'getnada.com',\n 'guerrillamail.com',\n 'guerrillamail.net',\n 'guerrillamail.org',\n 'guerrillamailblock.com',\n 'maildrop.cc',\n 'mailinator.com',\n 'mailinator.net',\n 'mailnesia.com',\n 'mintemail.com',\n 'mohmal.com',\n 'mytemp.email',\n 'sharklasers.com',\n 'spamgourmet.com',\n 'temp-mail.org',\n 'tempail.com',\n 'tempmail.com',\n 'tempmailo.com',\n 'tempr.email',\n 'throwawaymail.com',\n 'trashmail.com',\n 'trashmail.de',\n 'yopmail.com',\n 'yopmail.net',\n])\n\n/** Whether a domain is a disposable-mailbox provider. */\nexport function isDisposableEmailDomain(domain: unknown): boolean {\n return DISPOSABLE_DOMAINS.has(normalizeMailHost(domain))\n}\n\n/**\n * The providers a mistyped domain is compared against. Long labels only: a\n * three-letter label (`aol`, `msn`) is one keystroke from dozens of real\n * domains, and a suggestion there would be noise.\n */\nconst TYPO_TARGETS: readonly string[] = [\n 'gmail.com',\n 'googlemail.com',\n 'yahoo.com',\n 'hotmail.com',\n 'outlook.com',\n 'icloud.com',\n 'comcast.net',\n 'protonmail.com',\n 'verizon.net',\n 'sbcglobal.net',\n 'bellsouth.net',\n]\n\n/** Real providers one keystroke from a target, which must never be \"corrected\". */\nconst TYPO_EXEMPT: ReadonlySet<string> = new Set([\n 'mail.com',\n 'gmx.com',\n 'ymail.com',\n 'email.com',\n 'hotmail.co.uk',\n 'yahoo.co.uk',\n 'outlook.fr',\n])\n\n/** Top-level domains that are a slip of `com` or `net`. */\nconst TLD_SLIPS: Readonly<Record<string, string>> = {\n co: 'com',\n con: 'com',\n cm: 'com',\n om: 'com',\n comm: 'com',\n cmo: 'com',\n ocm: 'com',\n vom: 'com',\n xom: 'com',\n cpm: 'com',\n coom: 'com',\n ne: 'net',\n nte: 'net',\n ner: 'net',\n nett: 'net',\n}\n\n/** Damerau–Levenshtein distance, stopping early above 1: only \"one slip\" matters here. */\nfunction withinOneEdit(a: string, b: string): boolean {\n if (a === b) return true\n if (Math.abs(a.length - b.length) > 1) return false\n if (a.length === b.length) {\n const diffs: number[] = []\n for (let index = 0; index < a.length; index += 1) {\n if (a[index] !== b[index]) diffs.push(index)\n if (diffs.length > 2) return false\n }\n if (diffs.length === 1) return true\n // One transposition of neighbors: `gmial` for `gmail`.\n return (\n diffs.length === 2 &&\n diffs[1] === diffs[0] + 1 &&\n a[diffs[0]] === b[diffs[1]] &&\n a[diffs[1]] === b[diffs[0]]\n )\n }\n const [longer, shorter] = a.length > b.length ? [a, b] : [b, a]\n for (let index = 0; index < longer.length; index += 1) {\n if (longer.slice(0, index) + longer.slice(index + 1) === shorter) return true\n }\n return false\n}\n\n/**\n * The provider a domain most likely meant, or `null` — a SUGGESTION a\n * surface may offer, never a correction applied: `gmial.com` may be a real\n * domain somebody owns, and changing an address a person typed is sending\n * their mail to someone else.\n */\nexport function suggestEmailDomain(domain: unknown): string | null {\n const name = normalizeMailHost(domain)\n if (!name || TYPO_EXEMPT.has(name) || TYPO_TARGETS.includes(name)) return null\n const dot = name.lastIndexOf('.')\n if (dot < 1) return null\n const label = name.slice(0, dot)\n const tld = name.slice(dot + 1)\n for (const target of TYPO_TARGETS) {\n const targetDot = target.lastIndexOf('.')\n const targetLabel = target.slice(0, targetDot)\n const targetTld = target.slice(targetDot + 1)\n if (label === targetLabel && TLD_SLIPS[tld] === targetTld) return target\n if (tld === targetTld && label.length >= 4 && withinOneEdit(label, targetLabel)) return target\n }\n return null\n}\n\n/*==========================================\n * THE DOMAIN'S MX\n *==========================================*/\n\n/** One MX record as a resolver answers it. */\nexport interface MailMxRecord {\n exchange: string\n priority: number\n}\n\n/**\n * The DNS the checks read, handed in by the server layer.\n *\n * `resolveMx` resolves the domain's MX records. A domain that publishes\n * none resolves `[]` or rejects with a `code` of `ENOTFOUND`, `ENODATA` or\n * `NXDOMAIN` — Node's own convention — and any other rejection is a\n * resolver that could not answer, which the checks read as \"unknown\" and\n * never as \"no MX\".\n *\n * `resolveAddress`, when given, answers whether the domain has an address\n * record (A or AAAA): the implicit MX a domain with no MX is still\n * delivered to. Omitted, a domain with no MX reads as `no_mx`.\n */\nexport interface MailDnsResolver {\n resolveMx(domain: string): Promise<ReadonlyArray<MailMxRecord>>\n resolveAddress?(domain: string): Promise<boolean>\n}\n\n/**\n * What a domain's DNS says about receiving mail.\n *\n * - `mx` — it publishes MX records.\n * - `implicit_mx` — no MX, but an address record, which RFC 5321 §5.1 still\n * delivers to. Deliverable; warned, because it is usually a domain whose\n * mail was never set up.\n * - `null_mx` — RFC 7505's `MX 0 .`: the domain says outright it takes no mail.\n * - `no_mx` — no MX and no address record: nothing to deliver to.\n */\nexport type MailDomainStatus = 'mx' | 'implicit_mx' | 'null_mx' | 'no_mx'\n\n/** One domain's answer, as the platform cache stores it. */\nexport interface MailDomainIntel {\n domain: string\n status: MailDomainStatus\n /** The MX exchanges, lowest preference first; empty for every status but `mx`. */\n mx: string[]\n gateway: MailGateway\n /** When the domain was looked up; trusted for {@link mailDomainIntelTtlMs}. */\n resolvedAtMs: number\n}\n\nexport const MAIL_DOMAIN_STATUSES: readonly MailDomainStatus[] = ['mx', 'implicit_mx', 'null_mx', 'no_mx']\n\n/** Whether a status means the domain takes no mail. */\nexport function mailDomainRefusesMail(status: MailDomainStatus | null | undefined): boolean {\n return status === 'no_mx' || status === 'null_mx'\n}\n\n/** How long an answer is trusted: a week for a domain that takes mail, a day for one that does not. */\nexport function mailDomainIntelTtlMs(status: MailDomainStatus): number {\n return mailDomainRefusesMail(status) ? MAIL_DOMAIN_NEGATIVE_TTL_MS : MAIL_DOMAIN_INTEL_TTL_MS\n}\n\n/** Whether a cached answer is still trusted at `nowMs`. */\nexport function isMailDomainIntelFresh(intel: MailDomainIntel | null | undefined, nowMs: number): boolean {\n return Boolean(intel) && intel.resolvedAtMs + mailDomainIntelTtlMs(intel.status) > nowMs\n}\n\n/** The resolver's codes that mean \"this domain has no such record\", as opposed to \"I could not ask\". */\nconst NO_RECORD_CODES = new Set(['ENOTFOUND', 'ENODATA', 'NXDOMAIN', 'NOTFOUND'])\n\n/** Whether a resolver rejection is an answer (\"no such record\") rather than a failure to get one. */\nexport function isNoRecordDnsError(error: unknown): boolean {\n return NO_RECORD_CODES.has(String((error as { code?: unknown })?.code ?? '').toUpperCase())\n}\n\n/** A stored answer in its model shape, or `null` for no document or an unreadable one. */\nexport function readStoredMailDomainIntel(\n id: string,\n data: Record<string, unknown> | null | undefined,\n): MailDomainIntel | null {\n if (!data) return null\n const status = (MAIL_DOMAIN_STATUSES as readonly unknown[]).includes(data['status'])\n ? (data['status'] as MailDomainStatus)\n : null\n if (!status) return null\n const mx = Array.isArray(data['mx']) ? data['mx'].filter((entry): entry is string => typeof entry === 'string') : []\n const resolvedAtMs = typeof data['resolvedAtMs'] === 'number' && Number.isFinite(data['resolvedAtMs']) ? data['resolvedAtMs'] : 0\n return {\n domain: typeof data['domain'] === 'string' && data['domain'] ? data['domain'] : id,\n status,\n mx,\n gateway: status === 'mx' ? classifyMailGateway(mx) : status === 'implicit_mx' ? 'other' : 'none',\n resolvedAtMs,\n }\n}\n\n/**\n * A domain's mail setup, looked up — see {@link MailDomainStatus}. Rejects\n * when the resolver could not answer, and the caller decides what a missing\n * answer means; every path here reads it as \"unknown\" and sends.\n */\nexport async function resolveMailDomain(\n resolver: MailDnsResolver,\n domain: string,\n nowMs: number,\n): Promise<MailDomainIntel> {\n let records: ReadonlyArray<MailMxRecord>\n try {\n records = await resolver.resolveMx(domain)\n } catch (error) {\n if (!isNoRecordDnsError(error)) throw error\n records = []\n }\n const exchanges = [...(records ?? [])]\n .filter((record) => record && typeof record.exchange === 'string')\n .sort((a, b) => (Number(a.priority) || 0) - (Number(b.priority) || 0))\n .map((record) => normalizeMailHost(record.exchange))\n const real = exchanges.filter((exchange) => exchange && exchange !== '.')\n if (real.length) {\n return { domain, status: 'mx', mx: real, gateway: classifyMailGateway(real), resolvedAtMs: nowMs }\n }\n // Records that are all \".\" (or empty, as Node reports a null MX) are RFC\n // 7505's null MX: the domain says it takes no mail, whatever its A says.\n if (exchanges.length) return { domain, status: 'null_mx', mx: [], gateway: 'none', resolvedAtMs: nowMs }\n if (resolver.resolveAddress) {\n let addressed: boolean\n try {\n addressed = await resolver.resolveAddress(domain)\n } catch (error) {\n if (!isNoRecordDnsError(error)) throw error\n addressed = false\n }\n if (addressed) return { domain, status: 'implicit_mx', mx: [], gateway: 'other', resolvedAtMs: nowMs }\n }\n return { domain, status: 'no_mx', mx: [], gateway: 'none', resolvedAtMs: nowMs }\n}\n\n/*==========================================\n * THE VERDICT\n *==========================================*/\n\n/**\n * Who a message is for, which decides what may stop it — see the module\n * note. `transactional` is mail the recipient asked for; `bulk` is a\n * campaign, a workflow's marketing step, a sweep; `cold` is a sequence to\n * somebody who has not written to the sender.\n */\nexport type EmailSendPurpose = 'transactional' | 'bulk' | 'cold'\n\nexport type EmailDeliverabilityCode =\n | 'invalid_syntax'\n | 'no_mx'\n | 'null_mx'\n | 'implicit_mx'\n | 'typo_domain'\n | 'disposable_domain'\n | 'role_address'\n | 'gateway_held'\n /** The account holding the address is banned (AGL-3420). */\n | 'account_banned'\n\nexport type EmailDeliverabilitySeverity = 'refuse' | 'hold' | 'warn'\n\nexport interface EmailDeliverabilityFinding {\n code: EmailDeliverabilityCode\n severity: EmailDeliverabilitySeverity\n /** A sentence a surface shows as is. */\n message: string\n /** `typo_domain` only: the address with the suggested domain. Never applied for the person. */\n suggestion?: string\n}\n\n/**\n * What the checks conclude, worst first: `refused` (never sent), `held`\n * (bulk and cold mail wait for a person), `deliverable` (nothing stops it,\n * warnings or not), `unchecked` (the MX could not be read, so nothing is\n * known — and nothing is stopped).\n */\nexport type EmailDeliverabilityOutcome = 'refused' | 'held' | 'deliverable' | 'unchecked'\n\nexport interface EmailDeliverabilityVerdict {\n /** The address, normalized, or `null` when it is not one. */\n email: string | null\n domain: string | null\n outcome: EmailDeliverabilityOutcome\n findings: EmailDeliverabilityFinding[]\n intel: MailDomainIntel | null\n gateway: MailGatewayStanding | null\n}\n\nexport interface EmailDeliverabilityInput {\n email: unknown\n purpose: EmailSendPurpose\n /** The domain's MX answer; `null` or absent when it could not be read. */\n intel?: MailDomainIntel | null\n /** The gateway's standing with the sending domain, when the caller read the ledger. */\n gateway?: MailGatewayStanding | null\n /**\n * The domain is a public mailbox provider (`gmail.com`), which is never\n * refused for its MX. The list lives with the CRM's, so the caller asks.\n */\n publicMailbox?: boolean\n /** A person already released the gateway hold for this address. */\n holdReleased?: boolean\n}\n\nconst SEVERITY_RANK: Record<EmailDeliverabilitySeverity, number> = { refuse: 0, hold: 1, warn: 2 }\n\n/** The whole verdict for one address — see the module note. */\nexport function assessEmailDeliverability(input: EmailDeliverabilityInput): EmailDeliverabilityVerdict {\n const email = normalizeDeliverabilityEmail(input.email)\n if (!email) {\n return {\n email: null,\n domain: null,\n outcome: 'refused',\n findings: [\n {\n code: 'invalid_syntax',\n severity: 'refuse',\n message: `${String(input.email ?? '').trim() || 'This'} is not an email address.`,\n },\n ],\n intel: null,\n gateway: null,\n }\n }\n const domain = email.slice(email.lastIndexOf('@') + 1)\n const findings: EmailDeliverabilityFinding[] = []\n const bulk = input.purpose !== 'transactional'\n const intel = input.intel ?? null\n\n if (intel && !input.publicMailbox) {\n if (intel.status === 'no_mx') {\n findings.push({\n code: 'no_mx',\n severity: 'refuse',\n message: `${domain} has no mail server, so ${email} would bounce.`,\n })\n } else if (intel.status === 'null_mx') {\n findings.push({\n code: 'null_mx',\n severity: 'refuse',\n message: `${domain} publishes a null MX record: it says it accepts no email, so ${email} would bounce.`,\n })\n } else if (intel.status === 'implicit_mx') {\n findings.push({\n code: 'implicit_mx',\n severity: 'warn',\n message: `${domain} has no MX record. Mail may still reach its web server's address, but most domains like this never set up email.`,\n })\n }\n }\n\n const suggestion = suggestEmailDomain(domain)\n if (suggestion) {\n const local = email.slice(0, email.lastIndexOf('@'))\n findings.push({\n code: 'typo_domain',\n severity: 'warn',\n message: `Did you mean ${local}@${suggestion}? ${domain} looks like a typo.`,\n suggestion: `${local}@${suggestion}`,\n })\n }\n if (isDisposableEmailDomain(domain)) {\n findings.push({\n code: 'disposable_domain',\n severity: 'warn',\n message: `${domain} hands out mailboxes that expire, so this address may stop working soon.`,\n })\n }\n if (bulk && isRoleEmailAddress(email)) {\n findings.push({\n code: 'role_address',\n severity: 'warn',\n message: `${email} is a shared role inbox rather than a person, which marketing and cold mail should avoid.`,\n })\n }\n\n const gateway = input.gateway ?? null\n if (bulk && gateway && input.holdReleased !== true && mailGatewayHolds(gateway)) {\n findings.push({\n code: 'gateway_held',\n severity: 'hold',\n message: `${mailGatewayHoldSentence(gateway.gateway, gateway.blocked30)}, so bulk mail to ${domain} is held.`,\n })\n }\n\n findings.sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity])\n const outcome: EmailDeliverabilityOutcome = findings.some((finding) => finding.severity === 'refuse')\n ? 'refused'\n : findings.some((finding) => finding.severity === 'hold')\n ? 'held'\n : intel\n ? 'deliverable'\n : 'unchecked'\n return { email, domain, outcome, findings, intel, gateway }\n}\n\n/** The finding that decided a refusal or a hold, or `null` when nothing stops the address. */\nexport function decidingDeliverabilityFinding(verdict: EmailDeliverabilityVerdict): EmailDeliverabilityFinding | null {\n return verdict.findings.find((finding) => finding.severity !== 'warn') ?? null\n}\n\n/**\n * What a bulk send's review says about the addresses the checks took out,\n * or `null` when they took out nobody: \"3 recipients excluded: no mail\n * server · 2 behind a gateway that refused this sender\".\n */\nexport function deliverabilityExclusionSummary(counts: {\n noMailServer?: number | null\n gatewayHeld?: number | null\n}): string | null {\n const noMailServer = Math.max(0, Math.floor(Number(counts?.noMailServer) || 0))\n const gatewayHeld = Math.max(0, Math.floor(Number(counts?.gatewayHeld) || 0))\n const total = noMailServer + gatewayHeld\n if (!total) return null\n const parts = [\n ...(noMailServer ? [`${noMailServer.toLocaleString('en-US')} no mail server`] : []),\n ...(gatewayHeld ? [`${gatewayHeld.toLocaleString('en-US')} behind a gateway that refused this sender`] : []),\n ]\n return `${total.toLocaleString('en-US')} ${total === 1 ? 'recipient' : 'recipients'} excluded: ${parts.join(' · ')}`\n}\n\n/*==========================================\n * THE SEAM ON `sendEmail`\n *\n * `sendEmail` lives in this library and the store it would need lives in\n * `@aglyn/tenant-data-admin`, which imports this one — so the durable half\n * reaches the send by injection, the way the send-rate governor and the\n * marketing gate do. Nothing installed is nothing checked: a deployment\n * without the server layer sends as it always did.\n *==========================================*/\n\n/** What `sendEmail` asks before a message leaves. */\nexport interface EmailDeliverabilityPreflightRequest {\n /** The recipients, normalized and lowercased. */\n recipients: string[]\n purpose: EmailSendPurpose\n /** The domain the message leaves from, or `null` when it could not be read. */\n sendingDomain: string | null\n /**\n * Whose reputation the sending domain is: a site's own verified domain\n * (`custom`), the pooled tenant identity (`shared`), or Aglyn's own\n * (`platform`). The last two are learned at platform level, where a block\n * hurts every tenant.\n */\n sendingSource: 'custom' | 'shared' | 'platform'\n context?: string\n hostId?: string | null\n /** The send is a lock or lift notice, which reaches a banned account. */\n accountBanExempt?: boolean\n}\n\nexport interface EmailDeliverabilityPreflightVerdict {\n /**\n * Recipients whose domain takes no mail, or whose account is banned.\n * Never sent, whatever the purpose.\n */\n refused: Array<{ email: string; code: EmailDeliverabilityCode; reason: string }>\n /** Recipients behind a gateway that refused this sender twice. Bulk and cold mail only. */\n held: Array<{ email: string; gateway: MailGateway; reason: string }>\n}\n\nexport type EmailDeliverabilityPreflight = (\n request: EmailDeliverabilityPreflightRequest,\n) => Promise<EmailDeliverabilityPreflightVerdict>\n\nlet installedPreflight: EmailDeliverabilityPreflight | null = null\n\n/** Installs the durable preflight. Called once, from `@aglyn/tenant-data-admin`. */\nexport function setEmailDeliverabilityPreflight(preflight: EmailDeliverabilityPreflight | null): void {\n installedPreflight = preflight\n}\n\n/** The installed preflight, or `null` when nothing has been installed. */\nexport function getEmailDeliverabilityPreflight(): EmailDeliverabilityPreflight | null {\n return installedPreflight\n}\n\n/** Test seam: forget any installed preflight. */\nexport function resetEmailDeliverabilityPreflightForTests(): void {\n installedPreflight = null\n}\n\n/**\n * How long `sendEmail` waits for the preflight before sending anyway. A\n * cold lookup is one DNS round trip and one document read; a preflight\n * slower than this is an outage on the control, and a control outage must\n * not become a mail outage.\n */\nexport const EMAIL_DELIVERABILITY_PREFLIGHT_TIMEOUT_MS = 2_500\n"],"names":["classifyMailGateway","MAIL_DOMAIN_INTEL_TTL_MS","MAIL_DOMAIN_NEGATIVE_TTL_MS","mailGatewayHolds","mailGatewayHoldSentence","normalizeMailHost","DOMAIN_MAX","LABEL","LOCAL_PART","normalizeDeliverabilityEmail","value","address","String","trim","toLowerCase","at","lastIndexOf","length","local","slice","domain","test","isDeliverabilityDomain","labels","split","every","label","emailAddressDomain","email","ROLE_LOCAL_PARTS","Set","isRoleEmailAddress","has","DISPOSABLE_DOMAINS","isDisposableEmailDomain","TYPO_TARGETS","TYPO_EXEMPT","TLD_SLIPS","co","con","cm","om","comm","cmo","ocm","vom","xom","cpm","coom","ne","nte","ner","nett","withinOneEdit","a","b","Math","abs","diffs","index","push","longer","shorter","suggestEmailDomain","name","includes","dot","tld","target","targetDot","targetLabel","targetTld","MAIL_DOMAIN_STATUSES","mailDomainRefusesMail","status","mailDomainIntelTtlMs","isMailDomainIntelFresh","intel","nowMs","Boolean","resolvedAtMs","NO_RECORD_CODES","isNoRecordDnsError","error","code","toUpperCase","readStoredMailDomainIntel","id","data","mx","Array","isArray","filter","entry","Number","isFinite","gateway","resolveMailDomain","resolver","records","resolveMx","exchanges","record","exchange","sort","priority","map","real","resolveAddress","addressed","SEVERITY_RANK","refuse","hold","warn","assessEmailDeliverability","input","outcome","findings","severity","message","bulk","purpose","publicMailbox","suggestion","holdReleased","blocked30","some","finding","decidingDeliverabilityFinding","verdict","find","deliverabilityExclusionSummary","counts","noMailServer","max","floor","gatewayHeld","total","parts","toLocaleString","join","installedPreflight","setEmailDeliverabilityPreflight","preflight","getEmailDeliverabilityPreflight","resetEmailDeliverabilityPreflightForTests","EMAIL_DELIVERABILITY_PREFLIGHT_TIMEOUT_MS"],"mappings":"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"}
|
|
@@ -278,6 +278,12 @@ export const RISK_STAFF_ACTIONS = {
|
|
|
278
278
|
const ASK_FOR_REVIEW = 'If you think this is a mistake, choose Request a review and tell us about it. A person reads every request.';
|
|
279
279
|
const REVIEW_TIME = 'Reviews are usually answered within one business day. You will get a notice either way.';
|
|
280
280
|
const CANNOT_SIGN_IN = 'If you cannot sign in, reply to this email or write to {{support.email}} with the reference {{reference}}.';
|
|
281
|
+
/**
|
|
282
|
+
* How a locked ACCOUNT appeals (AGL-3420). It cannot sign in, so the way in
|
|
283
|
+
* is mail: a reply to the notice reaches support (the notice's Reply-To),
|
|
284
|
+
* and support answers from its own mailbox — which a ban does not refuse,
|
|
285
|
+
* because a ban refuses what the platform sends, not what a person does.
|
|
286
|
+
*/ const APPEAL_BY_REPLY = 'If you believe this is a mistake, you can appeal: reply to this email, or write to {{support.email}} with the reference {{reference}}. Tell us anything that explains what happened. A person reviews every appeal and answers from that address.';
|
|
281
287
|
/*
|
|
282
288
|
* The catalog. Exhaustive by type: a kind added to RISK_EVENT_KINDS is a
|
|
283
289
|
* compile error here until somebody writes what each audience is told.
|
|
@@ -1421,7 +1427,7 @@ const CANNOT_SIGN_IN = 'If you cannot sign in, reply to this email or write to {
|
|
|
1421
1427
|
meaning: 'While it is locked: {{lock.affected}}',
|
|
1422
1428
|
steps: [
|
|
1423
1429
|
'Read the message above; it says what we need from you.',
|
|
1424
|
-
|
|
1430
|
+
APPEAL_BY_REPLY
|
|
1425
1431
|
],
|
|
1426
1432
|
actions: [
|
|
1427
1433
|
'contact-support'
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/risk-notice-catalog.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * THE RISK NOTICE CATALOG (AGL-3368).\n *\n * Every event where the platform holds, flags, locks or pauses something on\n * a workspace, and what each audience is told about it. One entry per kind,\n * read by three consumers that must never disagree:\n *\n * - `notifyRiskEvent` (`@aglyn/tenant-data-admin/server/risk-notice`), which\n * renders the entry into the in-app notification, the owner email and the\n * staff alert;\n * - the console: the workspace's Holds & reviews page renders an owner's\n * notices from it, and the staff abuse queue renders a row's staff half;\n * - the customer help page and the System emails catalog, which list every\n * kind.\n *\n * ## Two audiences, two vocabularies\n *\n * The OWNER half is read by the workspace's owners and admins — and a\n * fraud actor is a workspace owner too. So it describes the OUTCOME and the\n * way to a human (what was held, what that means, how to ask for a review),\n * never the detection: no rule, no threshold, no brand the screen matched,\n * no count of anything. `risk-notice-catalog.spec.ts` sweeps it for those.\n *\n * The STAFF half is read by staff only and names the evidence plainly.\n *\n * ## Owners can never release\n *\n * No owner action releases a hold or lifts a lock. An owner's \"waive\" is a\n * review request, which appends a note to the same abuse-queue row. The\n * spec pins that no owner action id is a staff decision.\n *\n * ## Text is written against merge tokens\n *\n * `{{item.label}}`, `{{workspace.name}}`, `{{brand.productName}}` and the rest\n * are filled at send time. The same strings seed the System emails catalog\n * entry each kind gets (`risk-notice-emails.ts`), so staff can edit the email\n * a kind sends on the System emails page.\n *\n * Pure and dependency-free: the console, the docs, the specs and the server\n * all import it.\n *=========================================*/\n\n/** Every kind of risk event the platform tells people about. */\nexport const RISK_EVENT_KINDS = [\n // Outbound email held by the safety screen: a campaign, an automation\n // step, any site email at the send seam, an outreach message.\n 'email-held',\n 'email-released',\n 'email-rejected',\n // A published page held before a visitor saw it.\n 'page-held',\n 'page-released',\n 'page-rejected',\n // A page ALREADY LIVE that the safety review flagged: it keeps serving,\n // and staff look — the account may have been compromised.\n 'page-flagged',\n // A marketplace submission (a listing, a publisher profile) held before\n // it was listed.\n 'listing-held',\n 'listing-released',\n 'listing-rejected',\n // A custom site domain or sending domain flagged for a look.\n 'domain-flagged',\n 'domain-cleared',\n 'domain-rejected',\n // A fraud signal on the workspace's own subscription payment.\n 'billing-payment-flagged',\n // A link a merchant configured (a download, a feed, a supplier hook) that\n // wears another brand's name, refused wherever it is followed.\n 'link-blocked',\n // A burst of card payments on one site: possibly card testing.\n 'card-testing',\n // Gift cards an order bought, frozen while its payment is in question.\n 'gift-card-hold',\n // A fraud signal on a sale the workspace took from its own customer.\n 'sale-fraud-warning',\n 'sale-payment-review',\n 'sale-dispute',\n // Several of a seller's sales drew fraud reports: staff are reviewing.\n 'seller-review',\n // A marketplace sale a publisher received is under review, a card signal\n // arrived on one, and a new publisher's payouts wait (or stop waiting).\n 'marketplace-sale-review',\n 'marketplace-sale-warning',\n 'publisher-payouts-held',\n 'publisher-payouts-standard',\n // A review with no held item of its own (a payment, a seller review)\n // closed: cleared, or upheld.\n 'review-cleared',\n 'review-upheld',\n // Security locks, by scope, and their lifts.\n 'workspace-locked',\n 'workspace-unlocked',\n 'site-locked',\n 'site-unlocked',\n 'domain-locked',\n 'domain-unlocked',\n 'account-locked',\n 'account-unlocked',\n // One capability switched off for one workspace (AI, uploads, checkout…).\n 'feature-locked',\n 'feature-unlocked',\n // Staff canceled the workspace's subscription from its staff page. (A\n // lock's own cancellation, and the renewals and payouts a lock pauses and\n // its lift resumes, are told in the lock and lift notices themselves, as\n // what the lock affects — one email per staff action, not four.)\n 'subscription-canceled',\n] as const\n\nexport type RiskEventKind = (typeof RISK_EVENT_KINDS)[number]\n\n/** How loud a notice is. `urgent` kinds are emailed and alert staff at once. */\nexport type RiskNoticeSeverity = 'info' | 'warning' | 'urgent'\n\n/** The actions an OWNER or ADMIN is offered. None of them releases anything. */\nexport const RISK_OWNER_ACTION_IDS = [\n 'view-details',\n 'edit-and-resubmit',\n 'request-review',\n 'refund-order',\n 'keep-order',\n 'answer-dispute',\n 'update-payment-method',\n 'view-billing',\n 'view-holds',\n 'view-marketplace-sales',\n 'release-gift-cards',\n 'contact-support',\n] as const\nexport type RiskOwnerActionId = (typeof RISK_OWNER_ACTION_IDS)[number]\n\n/** The actions STAFF are offered: the real controls, deep-linked. */\nexport const RISK_STAFF_ACTION_IDS = [\n 'staff-open-row',\n 'staff-release',\n 'staff-reject',\n 'staff-lock-workspace',\n 'staff-lock-site',\n 'staff-unlock',\n 'staff-cancel-subscription',\n 'staff-open-stripe',\n 'staff-view-workspace',\n] as const\nexport type RiskStaffActionId = (typeof RISK_STAFF_ACTION_IDS)[number]\n\n/**\n * Where each action goes, as a console path TEMPLATE. `{name}` placeholders\n * are filled by {@link resolveRiskActionHref} from the event; an action whose\n * placeholder has no value is dropped rather than rendered dead.\n *\n * Owner paths are in the stored-notification shape the console's\n * `normalizeNotificationLink` rewrites when followed: `/org/…` becomes the\n * reader's `/{orgSlug}/…` and `/{hostId}/…` becomes `/{orgSlug}/hosts/{sub}/…`.\n * `{itemPath}` is the held item's own page, supplied by the signal source.\n *\n * `risk-notice-routes.spec.ts` (console) resolves every template to a page\n * that exists under `apps/console/app`.\n */\nexport interface RiskActionDefinition {\n label: string\n /** What happens when it is followed, for the help page and a tooltip. */\n hint: string\n href: string\n}\n\nexport const RISK_OWNER_ACTIONS: Record<RiskOwnerActionId, RiskActionDefinition> = {\n 'view-details': {\n label: 'View details',\n hint: 'Opens the item this notice is about.',\n href: '{itemPath}',\n },\n 'edit-and-resubmit': {\n label: 'Edit and resubmit',\n hint: 'Opens the item so you can change it. A changed version is checked again on its own.',\n href: '{itemPath}',\n },\n 'request-review': {\n label: 'Request a review',\n hint: 'Sends a note to our review team on the same case. It does not release anything by itself.',\n href: '/org/settings/holds?notice={noticeId}',\n },\n 'refund-order': {\n label: 'Refund this order',\n hint: 'Opens the order, where Refund returns the payment to the card.',\n href: '{itemPath}',\n },\n 'keep-order': {\n label: 'Keep the order',\n hint: 'Opens the order. Nothing needs doing to keep it; fulfill it only once you are sure.',\n href: '{itemPath}',\n },\n 'answer-dispute': {\n label: 'Answer the dispute',\n hint: 'Opens the order with its dispute and the date your evidence is due.',\n href: '{itemPath}',\n },\n 'update-payment-method': {\n label: 'Update payment method',\n hint: 'Opens billing settings, where the card your subscription is paid from lives.',\n href: '/org/billing/settings',\n },\n 'view-billing': {\n label: 'View billing',\n hint: 'Opens your plan and subscription.',\n href: '/org/billing',\n },\n 'release-gift-cards': {\n label: 'Review gift cards',\n hint: 'Opens Gift cards under Promotions, where a held card can be released once you trust the payment.',\n href: '/{hostId}/products/promotions',\n },\n 'view-marketplace-sales': {\n label: 'View marketplace payouts',\n hint: 'Opens your marketplace payouts, where each sale and when it pays out are listed.',\n href: '/org/marketplace/payouts',\n },\n 'view-holds': {\n label: 'View holds and reviews',\n hint: 'Lists everything on this workspace that is held or under review, with its status.',\n href: '/org/settings/holds',\n },\n 'contact-support': {\n label: 'Contact support',\n hint: 'Opens a support ticket. If you cannot sign in, reply to this email instead.',\n href: '/org/support/tickets',\n },\n}\n\nexport const RISK_STAFF_ACTIONS: Record<RiskStaffActionId, RiskActionDefinition> = {\n 'staff-open-row': {\n label: 'Open in abuse queue',\n hint: 'The abuse-queue row with the evidence and any review requests.',\n href: '/admin/abuse-reports?report={reviewId}',\n },\n 'staff-release': {\n label: 'Waive / release',\n hint: 'Dismiss the row. For a held email or page this releases exactly the held content.',\n href: '/admin/abuse-reports?report={reviewId}&decide=dismissed',\n },\n 'staff-reject': {\n label: 'Reject',\n hint: 'Mark the row actioned. A held email or page stays unsent or unserved.',\n href: '/admin/abuse-reports?report={reviewId}&decide=actioned',\n },\n 'staff-lock-workspace': {\n label: 'Lock workspace',\n hint: 'Opens Lockdown with this workspace filled in.',\n href: '/admin/lockdown?scope=org&targetId={orgId}',\n },\n 'staff-lock-site': {\n label: 'Lock site',\n hint: 'Opens Lockdown with this site filled in.',\n href: '/admin/lockdown?scope=host&targetId={hostId}',\n },\n 'staff-unlock': {\n label: 'Review the lock',\n hint: 'Opens Lockdown on the locked target, where it can be lifted.',\n href: '/admin/lockdown?scope={lockScope}&targetId={lockTargetId}',\n },\n 'staff-cancel-subscription': {\n label: 'Cancel subscription',\n hint: \"The workspace's Subscription card on its staff page.\",\n href: '/admin/orgs/{orgId}#subscription',\n },\n 'staff-open-stripe': {\n label: 'Open in Stripe',\n hint: 'The charge, review, dispute or connected account in the Stripe Dashboard.',\n href: '{stripeUrl}',\n },\n 'staff-view-workspace': {\n label: 'View workspace',\n hint: \"The workspace's staff page.\",\n href: '/admin/orgs/{orgId}',\n },\n}\n\n/** One audience's half of a kind. */\nexport interface RiskNoticeCopy {\n /** Short and specific. Also the email subject. */\n title: string\n /** What we saw, on what, and when. */\n summary: string\n /** What it means for them. */\n meaning: string\n /** What to do next, in order. */\n steps: readonly string[]\n}\n\nexport interface RiskNoticeDefinition {\n kind: RiskEventKind\n severity: RiskNoticeSeverity\n /**\n * Whether the owners and admins are emailed as well as notified in the\n * console. The email is transactional account mail from the platform's own\n * sender: it ignores notification preferences and marketing consent, and\n * it reaches a locked workspace.\n */\n emailOwners: boolean\n /** Whether staff are alerted. False for events staff themselves caused. */\n alertStaff: boolean\n /**\n * Whether the notice always goes out, even while the workspace is over its\n * burst allowance. Locks, lifts and cancellations are never folded into a\n * digest: each one changes what the owner can do.\n */\n neverDigest: boolean\n /**\n * An owner can ask for a review. Only kinds with an abuse-queue row to\n * append to are reviewable.\n */\n reviewable: boolean\n /** The kinds staff decisions on this kind's row close it with. */\n closesWith?: { released: RiskEventKind; rejected: RiskEventKind }\n /** Also tell the managers of the site the event is on, not just the workspace's. */\n includeSiteManagers: boolean\n /** The help page section that explains it. */\n helpAnchor: string\n owner: RiskNoticeCopy & { actions: readonly RiskOwnerActionId[] }\n staff: Pick<RiskNoticeCopy, 'title' | 'summary'> & {\n actions: readonly RiskStaffActionId[]\n }\n}\n\n/**\n * The customer help page every notice links to. A path on the docs site;\n * the server prefixes the docs origin.\n */\nexport const RISK_NOTICE_HELP_PATH = '/help/holds-and-reviews'\n\n/**\n * Merge tokens a notice may use. The server fills each from the event; a\n * token the event does not carry renders as the fallback noted here, never\n * as a hole in a sentence.\n */\nexport const RISK_NOTICE_TOKENS = {\n 'workspace.name': 'The workspace name, or \"your workspace\".',\n 'item.label': 'What the notice is about, in the owner\\'s words: the campaign \"Spring sale\", the page /pricing, order 1042.',\n 'item.url': 'The absolute console link to the item.',\n occurredAt: 'When it happened, in UTC.',\n reference: 'The case reference staff know it by (HS-…, PF-…).',\n 'holds.url': \"The workspace's Holds & reviews page.\",\n 'help.url': 'The help page for holds and reviews.',\n 'support.email': \"The operator's support address.\",\n 'lock.message': 'The message staff wrote when they applied the lock.',\n 'lock.affected': 'What the lock or pause covers: sites, sessions, billing.',\n amount: 'The payment amount, when there is one.',\n 'evidence.dueBy': 'The date dispute evidence is due, when Stripe gave one.',\n 'staff.evidence': 'Staff only: what the screen or Stripe reported.',\n} as const\n\nexport type RiskNoticeToken = keyof typeof RISK_NOTICE_TOKENS\n\nconst ASK_FOR_REVIEW =\n 'If you think this is a mistake, choose Request a review and tell us about it. A person reads every request.'\n\nconst REVIEW_TIME =\n 'Reviews are usually answered within one business day. You will get a notice either way.'\n\nconst CANNOT_SIGN_IN =\n 'If you cannot sign in, reply to this email or write to {{support.email}} with the reference {{reference}}.'\n\n/*\n * The catalog. Exhaustive by type: a kind added to RISK_EVENT_KINDS is a\n * compile error here until somebody writes what each audience is told.\n */\nexport const RISK_NOTICE_CATALOG: Readonly<Record<RiskEventKind, RiskNoticeDefinition>> = {\n 'email-held': {\n kind: 'email-held',\n severity: 'warning',\n emailOwners: true,\n alertStaff: true,\n neverDigest: false,\n reviewable: true,\n closesWith: { released: 'email-released', rejected: 'email-rejected' },\n includeSiteManagers: false,\n helpAnchor: 'email-held',\n owner: {\n title: 'An email is on hold for review',\n summary:\n 'Our automated safety review held {{item.label}} from {{workspace.name}} on {{occurredAt}} before it was sent.',\n meaning:\n 'This email was not sent. Nobody on your list received it, and nothing else on your account has changed.',\n steps: [\n 'Open the email and check its links, its sender name and its wording.',\n 'If you change it, it is checked again when it next sends.',\n ASK_FOR_REVIEW,\n REVIEW_TIME,\n ],\n actions: ['view-details', 'edit-and-resubmit', 'request-review', 'contact-support'],\n },\n staff: {\n title: 'Outbound email held for review — possible phishing',\n summary:\n '{{item.label}} from {{workspace.name}} was held on {{occurredAt}}. {{staff.evidence}} Reference {{reference}}.',\n actions: [\n 'staff-open-row',\n 'staff-release',\n 'staff-reject',\n 'staff-lock-workspace',\n 'staff-view-workspace',\n ],\n },\n },\n 'email-released': {\n kind: 'email-released',\n severity: 'info',\n emailOwners: true,\n alertStaff: false,\n neverDigest: false,\n reviewable: false,\n includeSiteManagers: false,\n helpAnchor: 'email-held',\n owner: {\n title: 'Your held email was released',\n summary: 'Our review team released {{item.label}} on {{occurredAt}}.',\n meaning:\n 'A held campaign goes back on the schedule and sends shortly. An automated email sends the next time it runs.',\n steps: [\n 'Nothing to do. If you edit the email before it sends, the edited version is checked on its own.',\n ],\n actions: ['view-details'],\n },\n staff: {\n title: 'Held email released',\n summary: '{{item.label}} from {{workspace.name}} was released. Reference {{reference}}.',\n actions: ['staff-open-row', 'staff-view-workspace'],\n },\n },\n 'email-rejected': {\n kind: 'email-rejected',\n severity: 'warning',\n emailOwners: true,\n alertStaff: false,\n neverDigest: false,\n reviewable: true,\n includeSiteManagers: false,\n helpAnchor: 'email-held',\n owner: {\n title: 'A held email will not be sent',\n summary: 'After review, {{item.label}} was not approved on {{occurredAt}}.',\n meaning:\n 'This email will not be sent in its current form. A held campaign is canceled; an automated email step stays stopped.',\n steps: [\n 'Read our acceptable use rules before you send similar mail again.',\n 'If you think the decision is wrong, choose Request a review and explain what the email is for.',\n ],\n actions: ['view-details', 'request-review', 'contact-support'],\n },\n staff: {\n title: 'Held email rejected',\n summary: '{{item.label}} from {{workspace.name}} was rejected. Reference {{reference}}.',\n actions: ['staff-open-row', 'staff-lock-workspace', 'staff-view-workspace'],\n },\n },\n 'page-held': {\n kind: 'page-held',\n severity: 'warning',\n emailOwners: true,\n alertStaff: true,\n neverDigest: false,\n reviewable: true,\n closesWith: { released: 'page-released', rejected: 'page-rejected' },\n includeSiteManagers: true,\n helpAnchor: 'page-held',\n owner: {\n title: 'A published page is on hold for review',\n summary:\n 'Our automated safety review held the latest version of {{item.label}} on {{occurredAt}}.',\n meaning:\n 'Visitors still see the previous version of this page. If it has never been published before, visitors see a not-found page until the review is done.',\n steps: [\n 'Open the page and check its links, embeds and any fields that ask visitors for information.',\n 'If you change and publish it again, the new version is checked on its own.',\n ASK_FOR_REVIEW,\n REVIEW_TIME,\n ],\n actions: ['view-details', 'edit-and-resubmit', 'request-review', 'contact-support'],\n },\n staff: {\n title: 'Published page held for review — possible phishing',\n summary:\n '{{item.label}} of {{workspace.name}} was held on {{occurredAt}}. {{staff.evidence}} Reference {{reference}}.',\n actions: [\n 'staff-open-row',\n 'staff-release',\n 'staff-reject',\n 'staff-lock-site',\n 'staff-lock-workspace',\n 'staff-view-workspace',\n ],\n },\n },\n 'page-released': {\n kind: 'page-released',\n severity: 'info',\n emailOwners: true,\n alertStaff: false,\n neverDigest: false,\n reviewable: false,\n includeSiteManagers: true,\n helpAnchor: 'page-held',\n owner: {\n title: 'Your held page is live',\n summary: 'Our review team released {{item.label}} on {{occurredAt}}.',\n meaning: 'Visitors now see the version that was held.',\n steps: ['Nothing to do. Open the page to confirm it looks right.'],\n actions: ['view-details'],\n },\n staff: {\n title: 'Held page released',\n summary: '{{item.label}} of {{workspace.name}} was released. Reference {{reference}}.',\n actions: ['staff-open-row', 'staff-view-workspace'],\n },\n },\n 'page-flagged': {\n kind: 'page-flagged',\n severity: 'warning',\n emailOwners: true,\n alertStaff: true,\n neverDigest: false,\n reviewable: true,\n closesWith: { released: 'review-cleared', rejected: 'review-upheld' },\n includeSiteManagers: true,\n helpAnchor: 'page-flagged',\n owner: {\n title: 'A live page is being reviewed',\n summary:\n 'Our automated safety review flagged {{item.label}} on {{occurredAt}}. The page is still live.',\n meaning:\n 'Visitors still see the page while a person looks. If you did not publish what is on it, someone else may have access to your account.',\n steps: [\n 'Open the page and check its links, embeds and any fields that ask visitors for information.',\n 'If you did not make these changes, change your password, remove members you do not recognize, and contact support.',\n ASK_FOR_REVIEW,\n REVIEW_TIME,\n ],\n actions: ['view-details', 'edit-and-resubmit', 'request-review', 'contact-support'],\n },\n staff: {\n title: 'Live page flagged — possible phishing or a compromised account',\n summary:\n '{{item.label}} of {{workspace.name}} is live and was flagged on {{occurredAt}}. {{staff.evidence}} It is still serving; lock the site if it is impersonation. Reference {{reference}}.',\n actions: [\n 'staff-open-row',\n 'staff-release',\n 'staff-lock-site',\n 'staff-lock-workspace',\n 'staff-view-workspace',\n ],\n },\n },\n 'page-rejected': {\n kind: 'page-rejected',\n severity: 'warning',\n emailOwners: true,\n alertStaff: false,\n neverDigest: false,\n reviewable: true,\n includeSiteManagers: true,\n helpAnchor: 'page-held',\n owner: {\n title: 'A held page will not be published',\n summary: 'After review, the held version of {{item.label}} was not approved on {{occurredAt}}.',\n meaning:\n 'Visitors keep seeing the previous version, or a not-found page if there is none. Publishing the same content again is held again.',\n steps: [\n 'Change the page and publish a new version, or restore an earlier version from its history.',\n 'If you think the decision is wrong, choose Request a review and explain what the page is for.',\n ],\n actions: ['edit-and-resubmit', 'request-review', 'contact-support'],\n },\n staff: {\n title: 'Held page rejected',\n summary: '{{item.label}} of {{workspace.name}} was rejected. Reference {{reference}}.',\n actions: ['staff-open-row', 'staff-lock-site', 'staff-view-workspace'],\n },\n },\n 'listing-held': {\n kind: 'listing-held',\n severity: 'warning',\n emailOwners: true,\n alertStaff: true,\n neverDigest: false,\n reviewable: true,\n closesWith: { released: 'listing-released', rejected: 'listing-rejected' },\n includeSiteManagers: false,\n helpAnchor: 'listing-held',\n owner: {\n title: 'A marketplace submission is on hold for review',\n summary:\n 'Our automated safety review held {{item.label}} from {{workspace.name}} on {{occurredAt}} before it was listed.',\n meaning: 'Nothing was published. Buyers cannot see or install it while it waits.',\n steps: [\n 'Check its name, description, links and any fields it asks people to fill in.',\n 'If you change it, the new submission is checked on its own.',\n ASK_FOR_REVIEW,\n REVIEW_TIME,\n ],\n actions: ['request-review', 'view-details', 'contact-support'],\n },\n staff: {\n title: 'Marketplace submission held for review — possible phishing',\n summary:\n '{{item.label}} from publisher workspace {{workspace.name}} was held on {{occurredAt}}. {{staff.evidence}} Reference {{reference}}.',\n actions: [\n 'staff-open-row',\n 'staff-release',\n 'staff-reject',\n 'staff-lock-workspace',\n 'staff-view-workspace',\n ],\n },\n },\n 'listing-released': {\n kind: 'listing-released',\n severity: 'info',\n emailOwners: true,\n alertStaff: false,\n neverDigest: false,\n reviewable: false,\n includeSiteManagers: false,\n helpAnchor: 'listing-held',\n owner: {\n title: 'Your marketplace submission was released',\n summary: 'Our review team released {{item.label}} on {{occurredAt}}.',\n meaning: 'It was not published automatically.',\n steps: ['Submit it again; it will go through this time.'],\n actions: ['view-details'],\n },\n staff: {\n title: 'Held marketplace submission released',\n summary: '{{item.label}} from {{workspace.name}} was released. Reference {{reference}}.',\n actions: ['staff-open-row', 'staff-view-workspace'],\n },\n },\n 'listing-rejected': {\n kind: 'listing-rejected',\n severity: 'warning',\n emailOwners: true,\n alertStaff: false,\n neverDigest: false,\n reviewable: true,\n includeSiteManagers: false,\n helpAnchor: 'listing-held',\n owner: {\n title: 'A marketplace submission will not be listed',\n summary: 'After review, {{item.label}} was not approved on {{occurredAt}}.',\n meaning: 'It will not be published in its current form. Submitting the same content again is held again.',\n steps: [\n 'Read the marketplace listing rules before you submit something similar.',\n 'If you think the decision is wrong, choose Request a review and explain what it is for.',\n ],\n actions: ['request-review', 'contact-support'],\n },\n staff: {\n title: 'Held marketplace submission rejected',\n summary: '{{item.label}} from {{workspace.name}} was rejected. Reference {{reference}}.',\n actions: ['staff-open-row', 'staff-lock-workspace', 'staff-view-workspace'],\n },\n },\n 'domain-flagged': {\n kind: 'domain-flagged',\n severity: 'warning',\n emailOwners: true,\n alertStaff: true,\n neverDigest: false,\n reviewable: true,\n closesWith: { released: 'domain-cleared', rejected: 'domain-rejected' },\n includeSiteManagers: false,\n helpAnchor: 'domain-flagged',\n owner: {\n title: 'A domain you added is being reviewed',\n summary: '{{item.label}} was added to {{workspace.name}} on {{occurredAt}} and is waiting on a routine review.',\n meaning:\n 'The domain stays connected while we look. Email sent from it may wait for the review to finish.',\n steps: [\n 'Nothing is needed if the domain and the name on it are yours.',\n 'To speed it up, choose Request a review and tell us who owns the domain and what it is for.',\n REVIEW_TIME,\n ],\n actions: ['view-details', 'request-review', 'contact-support'],\n },\n staff: {\n title: 'Domain flagged — possible brand impersonation',\n summary:\n '{{item.label}} was added by {{workspace.name}} on {{occurredAt}}. {{staff.evidence}} Reference {{reference}}.',\n actions: [\n 'staff-open-row',\n 'staff-release',\n 'staff-reject',\n 'staff-lock-workspace',\n 'staff-view-workspace',\n ],\n },\n },\n 'domain-cleared': {\n kind: 'domain-cleared',\n severity: 'info',\n emailOwners: true,\n alertStaff: false,\n neverDigest: false,\n reviewable: false,\n includeSiteManagers: false,\n helpAnchor: 'domain-flagged',\n owner: {\n title: 'Your domain review is complete',\n summary: '{{item.label}} was reviewed and cleared on {{occurredAt}}.',\n meaning: 'The domain works as normal. Email that was waiting on it sends on its next attempt.',\n steps: ['Nothing to do.'],\n actions: ['view-details'],\n },\n staff: {\n title: 'Flagged domain cleared',\n summary: '{{item.label}} on {{workspace.name}} was cleared. Reference {{reference}}.',\n actions: ['staff-open-row', 'staff-view-workspace'],\n },\n },\n 'domain-rejected': {\n kind: 'domain-rejected',\n severity: 'warning',\n emailOwners: true,\n alertStaff: false,\n neverDigest: false,\n reviewable: true,\n includeSiteManagers: false,\n helpAnchor: 'domain-flagged',\n owner: {\n title: 'A domain on your workspace was not approved',\n summary: 'After review, {{item.label}} was not approved on {{occurredAt}}.',\n meaning:\n 'Email from this domain will not be sent, and pages served on it may be restricted.',\n steps: [\n 'Remove the domain from your workspace, or show us that it is yours.',\n 'If you think the decision is wrong, choose Request a review with proof of ownership.',\n ],\n actions: ['view-details', 'request-review', 'contact-support'],\n },\n staff: {\n title: 'Flagged domain rejected',\n summary: '{{item.label}} on {{workspace.name}} was rejected. Reference {{reference}}.',\n actions: ['staff-open-row', 'staff-lock-workspace', 'staff-view-workspace'],\n },\n },\n 'billing-payment-flagged': {\n kind: 'billing-payment-flagged',\n severity: 'urgent',\n emailOwners: true,\n alertStaff: true,\n neverDigest: false,\n reviewable: true,\n closesWith: { released: 'review-cleared', rejected: 'review-upheld' },\n includeSiteManagers: false,\n helpAnchor: 'billing-payment-flagged',\n owner: {\n title: 'A payment on your subscription needs attention',\n summary:\n 'On {{occurredAt}}, a subscription payment of {{amount}} for {{workspace.name}} was flagged for a fraud check.',\n meaning:\n 'The payment may be reversed by the cardholder\\'s bank. If it is, your subscription may need a new payment method, and your plan can change.',\n steps: [\n 'If you made this payment, choose Request a review and confirm it, so we can answer the bank.',\n 'Check that the card on your account is yours and current.',\n 'If you did not make this payment, contact support now.',\n ],\n actions: ['request-review', 'update-payment-method', 'view-billing', 'contact-support'],\n },\n staff: {\n title: 'Platform billing fraud signal',\n summary:\n '{{staff.evidence}} Workspace {{workspace.name}}, {{amount}}, on {{occurredAt}}. Nothing has been refunded or canceled. Reference {{reference}}.',\n actions: [\n 'staff-open-row',\n 'staff-open-stripe',\n 'staff-cancel-subscription',\n 'staff-lock-workspace',\n 'staff-release',\n 'staff-reject',\n ],\n },\n },\n 'link-blocked': {\n kind: 'link-blocked',\n severity: 'warning',\n emailOwners: true,\n alertStaff: true,\n neverDigest: false,\n reviewable: true,\n closesWith: { released: 'review-cleared', rejected: 'review-upheld' },\n includeSiteManagers: true,\n helpAnchor: 'link-blocked',\n owner: {\n title: 'A link on your store was blocked',\n summary:\n 'On {{occurredAt}}, we stopped sending anyone to {{item.label}}, because the address looks like another company\\'s website.',\n meaning: 'Nobody who follows that link is sent there. Everything else on your store works as normal.',\n steps: [\n 'Replace it with a file from your media library or a link on your own domain.',\n 'If the address really is yours, choose Request a review and tell us about it.',\n ],\n actions: ['view-details', 'request-review', 'contact-support'],\n },\n staff: {\n title: 'Commerce link flagged — possible brand impersonation',\n summary:\n '{{item.label}} on {{workspace.name}}. {{staff.evidence}} Reference {{reference}}.',\n actions: [\n 'staff-open-row',\n 'staff-release',\n 'staff-reject',\n 'staff-lock-site',\n 'staff-view-workspace',\n ],\n },\n },\n 'card-testing': {\n kind: 'card-testing',\n severity: 'urgent',\n emailOwners: true,\n alertStaff: true,\n neverDigest: false,\n reviewable: true,\n closesWith: { released: 'review-cleared', rejected: 'review-upheld' },\n includeSiteManagers: true,\n helpAnchor: 'card-testing',\n owner: {\n title: 'Unusual checkout activity on your site',\n summary:\n 'On {{occurredAt}}, the checkout on {{item.label}} saw an unusual burst of payment attempts, which can be someone testing stolen cards.',\n meaning:\n 'Checkout is still open. Repeated attempts from one source are being slowed. Payments from stolen cards may be reversed by the cardholder\\'s bank later.',\n steps: [\n 'Review your recent orders before you fulfill them.',\n 'Refund any order you do not recognize.',\n 'If this was a launch or a sale, nothing is needed; you can tell us with Request a review.',\n ],\n actions: ['view-details', 'request-review', 'contact-support'],\n },\n staff: {\n title: 'Card-testing velocity on a site',\n summary:\n '{{item.label}} of {{workspace.name}}. {{staff.evidence}} Nothing has been refused site-wide or refunded. Reference {{reference}}.',\n actions: [\n 'staff-open-row',\n 'staff-lock-site',\n 'staff-lock-workspace',\n 'staff-view-workspace',\n 'staff-release',\n 'staff-reject',\n ],\n },\n },\n 'gift-card-hold': {\n kind: 'gift-card-hold',\n severity: 'warning',\n emailOwners: true,\n alertStaff: true,\n neverDigest: false,\n reviewable: false,\n includeSiteManagers: true,\n helpAnchor: 'gift-card-hold',\n owner: {\n title: 'Gift cards from an order are on hold',\n summary:\n 'On {{occurredAt}}, the gift cards bought with {{item.label}} were put on hold because the payment for it is in question.',\n meaning:\n 'They cannot be redeemed for now. Their balance is kept. If the payment is refunded or the dispute is lost, the balance is voided for you.',\n steps: [\n 'Check the order and the buyer.',\n 'If you are satisfied the payment is genuine, release the cards from Gift cards under Promotions.',\n 'If you are not, refund the order.',\n ],\n actions: ['release-gift-cards', 'refund-order', 'contact-support'],\n },\n staff: {\n title: 'Gift cards frozen on a questioned payment',\n summary: '{{item.label}} on {{workspace.name}}. {{staff.evidence}}',\n actions: ['staff-view-workspace', 'staff-lock-site'],\n },\n },\n 'sale-fraud-warning': {\n kind: 'sale-fraud-warning',\n severity: 'warning',\n emailOwners: true,\n alertStaff: false,\n neverDigest: false,\n reviewable: false,\n includeSiteManagers: true,\n helpAnchor: 'sale-fraud-warning',\n owner: {\n title: 'A payment you received may be fraudulent',\n summary:\n 'On {{occurredAt}}, the card issuer reported the payment for {{item.label}} ({{amount}}) as possibly not made by the cardholder.',\n meaning:\n 'This payment may be reversed by the cardholder\\'s bank. It is not a chargeback yet. Nothing has been refunded or canceled.',\n steps: [\n 'Hold anything not yet shipped or delivered until you are sure the buyer is genuine.',\n 'Refunding now usually prevents a chargeback and its fee.',\n 'If you know the buyer and the sale is genuine, you can keep the order.',\n ],\n actions: ['refund-order', 'keep-order', 'view-details', 'contact-support'],\n },\n staff: {\n title: 'Early fraud warning on a site sale',\n summary: '{{item.label}} on {{workspace.name}}, {{amount}}. {{staff.evidence}}',\n actions: ['staff-open-stripe', 'staff-view-workspace', 'staff-lock-site'],\n },\n },\n 'sale-payment-review': {\n kind: 'sale-payment-review',\n severity: 'warning',\n emailOwners: true,\n alertStaff: false,\n neverDigest: false,\n reviewable: false,\n includeSiteManagers: true,\n helpAnchor: 'sale-payment-review',\n owner: {\n title: 'A payment you received is on hold for review',\n summary: 'On {{occurredAt}}, the payment for {{item.label}} ({{amount}}) was held for a fraud review.',\n meaning:\n 'The payment has not reached your balance yet and may be refunded if it is not approved. Nothing has been refunded or canceled.',\n steps: [\n 'Hold anything not yet shipped or delivered until the payment clears.',\n 'If you know the buyer is not genuine, refund the order.',\n 'If you know the buyer, keep the order; the review usually clears on its own.',\n ],\n actions: ['refund-order', 'keep-order', 'view-details', 'contact-support'],\n },\n staff: {\n title: 'Payment review on a site sale',\n summary: '{{item.label}} on {{workspace.name}}, {{amount}}. {{staff.evidence}}',\n actions: ['staff-open-stripe', 'staff-view-workspace'],\n },\n },\n 'sale-dispute': {\n kind: 'sale-dispute',\n severity: 'urgent',\n emailOwners: true,\n alertStaff: false,\n neverDigest: false,\n reviewable: false,\n includeSiteManagers: true,\n helpAnchor: 'sale-dispute',\n owner: {\n title: 'A customer disputed a payment',\n summary:\n 'On {{occurredAt}}, the cardholder disputed the payment for {{item.label}} ({{amount}}) with their bank.',\n meaning:\n 'The payment may be reversed by the cardholder\\'s bank. An unanswered dispute is decided for the cardholder. Your evidence is due by {{evidence.dueBy}}.',\n steps: [\n 'Open the order and gather what shows the sale was genuine: receipts, delivery tracking, messages with the buyer.',\n 'Send your evidence before {{evidence.dueBy}}. Contact support if you need help submitting it.',\n 'If the buyer is right, accept the dispute instead of answering it.',\n ],\n actions: ['answer-dispute', 'view-details', 'contact-support'],\n },\n staff: {\n title: 'Dispute on a site sale',\n summary: '{{item.label}} on {{workspace.name}}, {{amount}}. Evidence due {{evidence.dueBy}}. {{staff.evidence}}',\n actions: ['staff-open-stripe', 'staff-view-workspace'],\n },\n },\n 'seller-review': {\n kind: 'seller-review',\n severity: 'urgent',\n emailOwners: true,\n alertStaff: true,\n neverDigest: false,\n reviewable: true,\n closesWith: { released: 'review-cleared', rejected: 'review-upheld' },\n includeSiteManagers: false,\n helpAnchor: 'seller-review',\n owner: {\n title: 'We are reviewing recent payments on your store',\n summary:\n 'On {{occurredAt}}, our team opened a routine review of recent card payments taken by {{workspace.name}}.',\n meaning:\n 'Your store keeps selling while we look. We may contact you for information about your recent orders.',\n steps: [\n 'Check your recent orders and refund any you believe were not placed by the cardholder.',\n 'Choose Request a review to tell us about your business and your customers; it helps the review finish sooner.',\n REVIEW_TIME,\n ],\n actions: ['request-review', 'view-holds', 'contact-support'],\n },\n staff: {\n title: 'Seller fraud pattern on one connected account',\n summary:\n '{{staff.evidence}} Workspace {{workspace.name}}. Nothing has been refunded, canceled or paused. Reference {{reference}}.',\n actions: [\n 'staff-open-row',\n 'staff-open-stripe',\n 'staff-lock-workspace',\n 'staff-view-workspace',\n 'staff-release',\n 'staff-reject',\n ],\n },\n },\n 'marketplace-sale-review': {\n kind: 'marketplace-sale-review',\n severity: 'warning',\n emailOwners: true,\n alertStaff: true,\n neverDigest: false,\n reviewable: true,\n closesWith: { released: 'review-cleared', rejected: 'review-upheld' },\n includeSiteManagers: false,\n helpAnchor: 'marketplace-sale-review',\n owner: {\n title: 'A marketplace sale is under review',\n summary: 'On {{occurredAt}}, our team opened a routine review of {{item.label}} ({{amount}}).',\n meaning:\n 'The sale is recorded as usual, and its payout may take longer than normal. Nothing has been refunded.',\n steps: [\n 'Nothing is needed from you.',\n 'To tell us about the buyer or the sale, choose Request a review.',\n REVIEW_TIME,\n ],\n actions: ['request-review', 'view-marketplace-sales', 'contact-support'],\n },\n staff: {\n title: 'Marketplace sale flagged — possible self-dealing or card fraud',\n summary:\n '{{item.label}} ({{amount}}) paid publisher workspace {{workspace.name}}. {{staff.evidence}} Nothing has been refunded or paused. Reference {{reference}}.',\n actions: [\n 'staff-open-row',\n 'staff-open-stripe',\n 'staff-lock-workspace',\n 'staff-view-workspace',\n 'staff-release',\n 'staff-reject',\n ],\n },\n },\n 'marketplace-sale-warning': {\n kind: 'marketplace-sale-warning',\n severity: 'warning',\n emailOwners: true,\n alertStaff: false,\n neverDigest: false,\n reviewable: false,\n includeSiteManagers: false,\n helpAnchor: 'marketplace-sale-review',\n owner: {\n title: 'A marketplace sale may be reversed',\n summary:\n 'On {{occurredAt}}, the payment for {{item.label}} was reported as possibly not made by the cardholder, or held for a fraud check.',\n meaning:\n 'This payment may be reversed by the cardholder\\'s bank. If it is, your share of it is taken back from your payouts. Nothing has been refunded yet.',\n steps: [\n 'Nothing is needed from you: the marketplace handles the payment with the buyer\\'s bank.',\n 'If you know the buyer, contact support; it can help us answer the bank.',\n ],\n actions: ['view-marketplace-sales', 'contact-support'],\n },\n staff: {\n title: 'Card signal on a marketplace sale',\n summary: '{{item.label}} paid publisher workspace {{workspace.name}}. {{staff.evidence}}',\n actions: ['staff-open-stripe', 'staff-view-workspace'],\n },\n },\n 'publisher-payouts-held': {\n kind: 'publisher-payouts-held',\n severity: 'info',\n emailOwners: true,\n alertStaff: false,\n neverDigest: true,\n reviewable: false,\n includeSiteManagers: false,\n helpAnchor: 'marketplace-payouts',\n owner: {\n title: 'Your marketplace payouts are on an extended schedule',\n summary: 'On {{occurredAt}}, payouts for {{workspace.name}}\\'s marketplace sales moved to an extended schedule.',\n meaning:\n 'Payouts to new publishers wait for a period before they reach your bank. Your sales are recorded as usual and pay out automatically when the period ends.',\n steps: ['Nothing to do. You will get a notice when payouts return to the standard schedule.'],\n actions: ['view-marketplace-sales', 'contact-support'],\n },\n staff: {\n title: 'Publisher payouts on the extended schedule',\n summary: 'Publisher workspace {{workspace.name}} payouts moved to the extended schedule.',\n actions: ['staff-view-workspace'],\n },\n },\n 'publisher-payouts-standard': {\n kind: 'publisher-payouts-standard',\n severity: 'info',\n emailOwners: true,\n alertStaff: false,\n neverDigest: true,\n reviewable: false,\n includeSiteManagers: false,\n helpAnchor: 'marketplace-payouts',\n owner: {\n title: 'Your marketplace payouts are back on the standard schedule',\n summary: 'On {{occurredAt}}, payouts for {{workspace.name}}\\'s marketplace sales returned to the standard schedule.',\n meaning: 'Your sales now pay out on your payout account\\'s standard schedule.',\n steps: ['Nothing to do.'],\n actions: ['view-marketplace-sales'],\n },\n staff: {\n title: 'Publisher payouts back on the standard schedule',\n summary: 'Publisher workspace {{workspace.name}} payouts returned to the standard schedule.',\n actions: ['staff-view-workspace'],\n },\n },\n 'review-cleared': {\n kind: 'review-cleared',\n severity: 'info',\n emailOwners: true,\n alertStaff: false,\n neverDigest: false,\n reviewable: false,\n includeSiteManagers: false,\n helpAnchor: 'requesting-a-review',\n owner: {\n title: 'Our review is complete',\n summary: 'On {{occurredAt}}, our team finished reviewing {{item.label}} and closed it with no action.',\n meaning: 'Nothing on your account changes because of it.',\n steps: ['Nothing to do.'],\n actions: ['view-holds'],\n },\n staff: {\n title: 'Review closed with no action',\n summary: '{{item.label}} on {{workspace.name}} was dismissed. Reference {{reference}}.',\n actions: ['staff-open-row', 'staff-view-workspace'],\n },\n },\n 'review-upheld': {\n kind: 'review-upheld',\n severity: 'warning',\n emailOwners: true,\n alertStaff: false,\n neverDigest: false,\n reviewable: true,\n includeSiteManagers: false,\n helpAnchor: 'requesting-a-review',\n owner: {\n title: 'Our review found a problem',\n summary: 'On {{occurredAt}}, our team finished reviewing {{item.label}} and took action on it.',\n meaning:\n 'You may receive a separate notice about what changed on your account, such as a lock or a canceled subscription.',\n steps: [\n 'Read any other notices you received today; they say what changed.',\n 'If you think the decision is wrong, choose Request a review or contact support with the reference {{reference}}.',\n ],\n actions: ['request-review', 'view-holds', 'contact-support'],\n },\n staff: {\n title: 'Review closed with action',\n summary: '{{item.label}} on {{workspace.name}} was actioned. Reference {{reference}}.',\n actions: ['staff-open-row', 'staff-view-workspace'],\n },\n },\n 'workspace-locked': {\n kind: 'workspace-locked',\n severity: 'urgent',\n emailOwners: true,\n alertStaff: false,\n neverDigest: true,\n reviewable: false,\n includeSiteManagers: false,\n helpAnchor: 'locked',\n owner: {\n title: '{{workspace.name}} has been locked',\n summary: 'On {{occurredAt}}, our team locked {{workspace.name}}. The message from our team: {{lock.message}}',\n meaning: 'While it is locked: {{lock.affected}}',\n steps: [\n 'Read the message above; it says what we need from you.',\n CANNOT_SIGN_IN,\n 'Tell us who you are, what the workspace is for, and anything that explains what happened. A person reads every appeal.',\n ],\n actions: ['contact-support'],\n },\n staff: {\n title: 'Workspace locked',\n summary: '{{workspace.name}} was locked on {{occurredAt}}. Affected: {{lock.affected}}',\n actions: ['staff-unlock', 'staff-view-workspace'],\n },\n },\n 'workspace-unlocked': {\n kind: 'workspace-unlocked',\n severity: 'info',\n emailOwners: true,\n alertStaff: false,\n neverDigest: true,\n reviewable: false,\n includeSiteManagers: false,\n helpAnchor: 'locked',\n owner: {\n title: '{{workspace.name}} has been restored',\n summary: 'On {{occurredAt}}, our team lifted the lock on {{workspace.name}}.',\n meaning: 'What happens now: {{lock.affected}}',\n steps: [\n 'Sign in again; sessions that were signed out stay signed out.',\n 'Check your sites, scheduled sends and billing. Anything that was canceled stays canceled until you restart it.',\n ],\n actions: ['view-holds', 'view-billing', 'contact-support'],\n },\n staff: {\n title: 'Workspace lock lifted',\n summary: '{{workspace.name}} was unlocked on {{occurredAt}}.',\n actions: ['staff-view-workspace'],\n },\n },\n 'site-locked': {\n kind: 'site-locked',\n severity: 'urgent',\n emailOwners: true,\n alertStaff: false,\n neverDigest: true,\n reviewable: false,\n includeSiteManagers: true,\n helpAnchor: 'locked',\n owner: {\n title: 'A site on {{workspace.name}} has been locked',\n summary: 'On {{occurredAt}}, our team locked {{item.label}}. The message from our team: {{lock.message}}',\n meaning: 'While it is locked: {{lock.affected}}',\n steps: [\n 'Read the message above; it says what we need from you.',\n 'Contact support to appeal, with the reference {{reference}}. A person reads every appeal.',\n CANNOT_SIGN_IN,\n ],\n actions: ['contact-support', 'view-holds'],\n },\n staff: {\n title: 'Site locked',\n summary: '{{item.label}} of {{workspace.name}} was locked on {{occurredAt}}. Affected: {{lock.affected}}',\n actions: ['staff-unlock', 'staff-view-workspace'],\n },\n },\n 'site-unlocked': {\n kind: 'site-unlocked',\n severity: 'info',\n emailOwners: true,\n alertStaff: false,\n neverDigest: true,\n reviewable: false,\n includeSiteManagers: true,\n helpAnchor: 'locked',\n owner: {\n title: 'A site on {{workspace.name}} has been restored',\n summary: 'On {{occurredAt}}, our team lifted the lock on {{item.label}}.',\n meaning: 'What happens now: {{lock.affected}}',\n steps: ['Open the site and check that its pages, forms and checkout work as you expect.'],\n actions: ['view-details', 'contact-support'],\n },\n staff: {\n title: 'Site lock lifted',\n summary: '{{item.label}} of {{workspace.name}} was unlocked on {{occurredAt}}.',\n actions: ['staff-view-workspace'],\n },\n },\n 'domain-locked': {\n kind: 'domain-locked',\n severity: 'urgent',\n emailOwners: true,\n alertStaff: false,\n neverDigest: true,\n reviewable: false,\n includeSiteManagers: true,\n helpAnchor: 'locked',\n owner: {\n title: 'A domain on {{workspace.name}} has been locked',\n summary: 'On {{occurredAt}}, our team locked {{item.label}}. The message from our team: {{lock.message}}',\n meaning: 'While it is locked: {{lock.affected}}',\n steps: [\n 'Read the message above; it says what we need from you.',\n 'Contact support to appeal, with the reference {{reference}}.',\n ],\n actions: ['contact-support'],\n },\n staff: {\n title: 'Domain locked',\n summary: '{{item.label}} of {{workspace.name}} was locked on {{occurredAt}}.',\n actions: ['staff-unlock', 'staff-view-workspace'],\n },\n },\n 'domain-unlocked': {\n kind: 'domain-unlocked',\n severity: 'info',\n emailOwners: true,\n alertStaff: false,\n neverDigest: true,\n reviewable: false,\n includeSiteManagers: true,\n helpAnchor: 'locked',\n owner: {\n title: 'A domain on {{workspace.name}} has been restored',\n summary: 'On {{occurredAt}}, our team lifted the lock on {{item.label}}.',\n meaning: 'What happens now: {{lock.affected}}',\n steps: ['Visit the domain to confirm it serves your site again.'],\n actions: ['contact-support'],\n },\n staff: {\n title: 'Domain lock lifted',\n summary: '{{item.label}} of {{workspace.name}} was unlocked on {{occurredAt}}.',\n actions: ['staff-view-workspace'],\n },\n },\n 'account-locked': {\n kind: 'account-locked',\n severity: 'urgent',\n emailOwners: true,\n alertStaff: false,\n neverDigest: true,\n reviewable: false,\n includeSiteManagers: false,\n helpAnchor: 'locked',\n owner: {\n title: 'Your account has been locked',\n summary: 'On {{occurredAt}}, our team locked your {{brand.productName}} account. The message from our team: {{lock.message}}',\n meaning: 'While it is locked: {{lock.affected}}',\n steps: [\n 'Read the message above; it says what we need from you.',\n CANNOT_SIGN_IN,\n ],\n actions: ['contact-support'],\n },\n staff: {\n title: 'Account locked',\n summary: 'An account was locked on {{occurredAt}}. Affected: {{lock.affected}}',\n actions: ['staff-unlock'],\n },\n },\n 'account-unlocked': {\n kind: 'account-unlocked',\n severity: 'info',\n emailOwners: true,\n alertStaff: false,\n neverDigest: true,\n reviewable: false,\n includeSiteManagers: false,\n helpAnchor: 'locked',\n owner: {\n title: 'Your account has been restored',\n summary: 'On {{occurredAt}}, our team lifted the lock on your {{brand.productName}} account.',\n meaning: 'What happens now: {{lock.affected}}',\n steps: ['Sign in again. If anything still does not work, contact support.'],\n actions: ['contact-support'],\n },\n staff: {\n title: 'Account lock lifted',\n summary: 'An account was unlocked on {{occurredAt}}.',\n actions: ['staff-unlock'],\n },\n },\n 'feature-locked': {\n kind: 'feature-locked',\n severity: 'warning',\n emailOwners: true,\n alertStaff: false,\n neverDigest: true,\n reviewable: false,\n includeSiteManagers: false,\n helpAnchor: 'locked',\n owner: {\n title: '{{item.label}} is paused on {{workspace.name}}',\n summary: 'On {{occurredAt}}, our team paused {{item.label}} for {{workspace.name}}. The message from our team: {{lock.message}}',\n meaning: 'Everything else on your workspace keeps working. {{lock.affected}}',\n steps: [\n 'Read the message above; it says what we need from you.',\n 'Contact support with the reference {{reference}} if you have questions.',\n ],\n actions: ['contact-support'],\n },\n staff: {\n title: 'Workspace feature paused',\n summary: '{{item.label}} was paused for {{workspace.name}} on {{occurredAt}}.',\n actions: ['staff-unlock', 'staff-view-workspace'],\n },\n },\n 'feature-unlocked': {\n kind: 'feature-unlocked',\n severity: 'info',\n emailOwners: true,\n alertStaff: false,\n neverDigest: true,\n reviewable: false,\n includeSiteManagers: false,\n helpAnchor: 'locked',\n owner: {\n title: '{{item.label}} is back on {{workspace.name}}',\n summary: 'On {{occurredAt}}, our team turned {{item.label}} back on for {{workspace.name}}.',\n meaning: 'What happens now: {{lock.affected}}',\n steps: ['Nothing to do.'],\n actions: ['contact-support'],\n },\n staff: {\n title: 'Workspace feature restored',\n summary: '{{item.label}} was restored for {{workspace.name}} on {{occurredAt}}.',\n actions: ['staff-view-workspace'],\n },\n },\n 'subscription-canceled': {\n kind: 'subscription-canceled',\n severity: 'urgent',\n emailOwners: true,\n alertStaff: false,\n neverDigest: true,\n reviewable: false,\n includeSiteManagers: false,\n helpAnchor: 'subscription-canceled',\n owner: {\n title: 'The subscription for {{workspace.name}} was canceled',\n summary: 'On {{occurredAt}}, our team canceled the subscription for {{workspace.name}}.',\n meaning: 'What changes: {{lock.affected}}',\n steps: [\n 'Export anything you need from your workspace.',\n 'Contact support if you believe this was a mistake, with the reference {{reference}}.',\n ],\n actions: ['view-billing', 'contact-support'],\n },\n staff: {\n title: 'Subscription canceled by staff',\n summary: 'The subscription for {{workspace.name}} was canceled on {{occurredAt}}.',\n actions: ['staff-cancel-subscription', 'staff-view-workspace'],\n },\n },\n}\n\n/**\n * The kinds whose email wears the WORKSPACE's brand: routine notices about\n * the workspace's own customers' payments, which read as the workspace's own\n * operations. Every other kind — a hold, a lock, a lift, a cancellation, a\n * review — is the platform acting, and is sent in the platform's brand.\n */\nexport const RISK_NOTICE_WORKSPACE_BRANDED: ReadonlySet<RiskEventKind> = new Set<RiskEventKind>([\n 'sale-fraud-warning',\n 'sale-payment-review',\n 'sale-dispute',\n 'gift-card-hold',\n 'marketplace-sale-warning',\n])\n\n/**\n * The kinds that CLOSE another: a release, a rejection, a review's end. A\n * closing notice never re-stamps its row, so the row keeps naming the kind\n * it was opened as — and a second decision closes it again correctly.\n */\nexport const RISK_NOTICE_CLOSING_KINDS: ReadonlySet<RiskEventKind> = new Set<RiskEventKind>(\n Object.values(RISK_NOTICE_CATALOG).flatMap((definition) =>\n definition.closesWith ? [definition.closesWith.released, definition.closesWith.rejected] : [],\n ),\n)\n\n/** The catalog entry for a kind. */\nexport function riskNoticeDefinition(kind: RiskEventKind): RiskNoticeDefinition {\n return RISK_NOTICE_CATALOG[kind]\n}\n\nexport function isRiskEventKind(value: unknown): value is RiskEventKind {\n return typeof value === 'string' && (RISK_EVENT_KINDS as readonly string[]).includes(value)\n}\n\n/** The values a notice is rendered with. Every value is optional. */\nexport type RiskNoticeValues = Partial<Record<RiskNoticeToken | 'brand.productName', string | null | undefined>>\n\n/**\n * What a token renders as when the event did not carry it: always words, so\n * a sentence never has a hole in it.\n */\nconst TOKEN_FALLBACKS: Partial<Record<RiskNoticeToken | 'brand.productName', string>> = {\n 'workspace.name': 'your workspace',\n 'item.label': 'an item on your workspace',\n occurredAt: 'a recent date',\n reference: 'shown on this notice',\n 'support.email': 'our support team',\n 'lock.message': 'None was given.',\n 'lock.affected': 'see your account for details.',\n amount: 'an amount shown on the payment',\n 'evidence.dueBy': 'the date shown on the order',\n 'staff.evidence': '',\n 'brand.productName': 'the platform',\n}\n\n/** Fill `{{token}}`s from `values`, falling back to words, never to a hole. */\nexport function renderRiskNoticeText(template: string, values: RiskNoticeValues): string {\n return template\n .replace(/\\{\\{\\s*([\\w.]+)\\s*\\}\\}/g, (_match, token: string) => {\n const value = values[token as RiskNoticeToken]\n if (typeof value === 'string' && value.trim()) return value.trim()\n return TOKEN_FALLBACKS[token as RiskNoticeToken] ?? ''\n })\n .replace(/[ \\t]{2,}/g, ' ')\n .trim()\n}\n\n/** The values an action href may use. */\nexport interface RiskActionParams {\n itemPath?: string | null\n noticeId?: string | null\n reviewId?: string | null\n orgId?: string | null\n hostId?: string | null\n stripeUrl?: string | null\n lockScope?: string | null\n lockTargetId?: string | null\n}\n\n/**\n * An action's href with its placeholders filled, or null when one of them\n * has no value — an action that would lead nowhere is not offered.\n */\nexport function resolveRiskActionHref(\n action: RiskActionDefinition,\n params: RiskActionParams,\n): string | null {\n let missing = false\n const href = action.href.replace(/\\{(\\w+)\\}/g, (_match, name: string) => {\n const value = params[name as keyof RiskActionParams]\n if (typeof value !== 'string' || !value) {\n missing = true\n return ''\n }\n // A whole-path placeholder is taken as written; a path segment or query\n // value is encoded.\n return name === 'itemPath' || name === 'stripeUrl' ? value : encodeURIComponent(value)\n })\n if (missing) return null\n if (href.startsWith('https://')) return href\n return href.startsWith('/') ? href : null\n}\n\n/** One action, ready to render. */\nexport interface ResolvedRiskAction {\n id: RiskOwnerActionId | RiskStaffActionId\n label: string\n hint: string\n href: string\n}\n\n/** A kind's owner actions that lead somewhere, in catalog order. */\nexport function resolveOwnerRiskActions(\n kind: RiskEventKind,\n params: RiskActionParams,\n): ResolvedRiskAction[] {\n return RISK_NOTICE_CATALOG[kind].owner.actions.flatMap((id) => {\n const definition = RISK_OWNER_ACTIONS[id]\n const href = resolveRiskActionHref(definition, params)\n return href ? [{ id, label: definition.label, hint: definition.hint, href }] : []\n })\n}\n\n/** A kind's staff actions that lead somewhere, in catalog order. */\nexport function resolveStaffRiskActions(\n kind: RiskEventKind,\n params: RiskActionParams,\n): ResolvedRiskAction[] {\n return RISK_NOTICE_CATALOG[kind].staff.actions.flatMap((id) => {\n const definition = RISK_STAFF_ACTIONS[id]\n const href = resolveRiskActionHref(definition, params)\n return href ? [{ id, label: definition.label, hint: definition.hint, href }] : []\n })\n}\n\n/** An owner notice as plain text: title, summary, meaning, numbered steps. */\nexport function renderOwnerRiskNotice(\n kind: RiskEventKind,\n values: RiskNoticeValues,\n): { title: string; summary: string; meaning: string; steps: string[] } {\n const copy = RISK_NOTICE_CATALOG[kind].owner\n return {\n title: renderRiskNoticeText(copy.title, values),\n summary: renderRiskNoticeText(copy.summary, values),\n meaning: renderRiskNoticeText(copy.meaning, values),\n steps: copy.steps.map((step) => renderRiskNoticeText(step, values)),\n }\n}\n\n/** A staff alert as plain text. */\nexport function renderStaffRiskNotice(\n kind: RiskEventKind,\n values: RiskNoticeValues,\n): { title: string; summary: string } {\n const copy = RISK_NOTICE_CATALOG[kind].staff\n return {\n title: renderRiskNoticeText(copy.title, values),\n summary: renderRiskNoticeText(copy.summary, values),\n }\n}\n\n/** The System emails catalog key a kind's owner email is designed under. */\nexport function riskNoticeEmailKey(kind: RiskEventKind): string {\n return `risk-${kind}`\n}\n\n/** The key the burst digest is designed under. */\nexport const RISK_NOTICE_DIGEST_EMAIL_KEY = 'risk-digest'\n\n/**\n * The kind an abuse-queue row is about, so the staff queue renders the\n * catalog's text for EVERY row a risk source filed — including rows filed\n * before the row carried its notice. The stamp `notifyRiskEvent` writes\n * (`riskNotice.kind`) wins; otherwise the row's own shape says it. Null for\n * a row from the public report form, which is not a risk event.\n */\nexport function riskKindForAbuseRow(row: {\n source?: unknown\n heldSend?: { kind?: unknown } | null\n riskNotice?: { kind?: unknown } | null\n}): RiskEventKind | null {\n const stamped = row.riskNotice?.kind\n if (isRiskEventKind(stamped)) return stamped\n switch (row.source) {\n case 'outbound-screen':\n if (row.heldSend?.kind === 'page') return 'page-held'\n if (row.heldSend?.kind === 'listing') return 'listing-held'\n return row.heldSend ? 'email-held' : 'domain-flagged'\n case 'stripe-fraud-signal':\n return 'billing-payment-flagged'\n case 'stripe-seller-fraud-pattern':\n return 'seller-review'\n case 'marketplace-sale-risk':\n return 'marketplace-sale-review'\n case 'payment-velocity':\n return 'card-testing'\n default:\n return null\n }\n}\n"],"names":["RISK_EVENT_KINDS","RISK_OWNER_ACTION_IDS","RISK_STAFF_ACTION_IDS","RISK_OWNER_ACTIONS","label","hint","href","RISK_STAFF_ACTIONS","RISK_NOTICE_HELP_PATH","RISK_NOTICE_TOKENS","occurredAt","reference","amount","ASK_FOR_REVIEW","REVIEW_TIME","CANNOT_SIGN_IN","RISK_NOTICE_CATALOG","kind","severity","emailOwners","alertStaff","neverDigest","reviewable","closesWith","released","rejected","includeSiteManagers","helpAnchor","owner","title","summary","meaning","steps","actions","staff","RISK_NOTICE_WORKSPACE_BRANDED","Set","RISK_NOTICE_CLOSING_KINDS","Object","values","flatMap","definition","riskNoticeDefinition","isRiskEventKind","value","includes","TOKEN_FALLBACKS","renderRiskNoticeText","template","replace","_match","token","trim","resolveRiskActionHref","action","params","missing","name","encodeURIComponent","startsWith","resolveOwnerRiskActions","id","resolveStaffRiskActions","renderOwnerRiskNotice","copy","map","step","renderStaffRiskNotice","riskNoticeEmailKey","RISK_NOTICE_DIGEST_EMAIL_KEY","riskKindForAbuseRow","row","stamped","riskNotice","source","heldSend"],"mappings":"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AAU;QAAiB;QACrEC,qBAAqB;QACrBC,YAAY;QACZC,OAAO;YACLC,OAAO;YACPC,SACE;YACFC,SACE;YACFC,OAAO;gBACL;gBACA;gBACAnB;gBACAC;aACD;YACDmB,SAAS;gBAAC;gBAAgB;gBAAqB;gBAAkB;aAAkB;QACrF;QACAC,OAAO;YACLL,OAAO;YACPC,SACE;YACFG,SAAS;gBACP;gBACA;gBACA;gBACA;gBACA;aACD;QACH;IACF;IACA,kBAAkB;QAChBhB,MAAM;QACNC,UAAU;QACVC,aAAa;QACbC,YAAY;QACZC,aAAa;QACbC,YAAY;QACZI,qBAAqB;QACrBC,YAAY;QACZC,OAAO;YACLC,OAAO;YACPC,SAAS;YACTC,SACE;YACFC,OAAO;gBACL;aACD;YACDC,SAAS;gBAAC;aAAe;QAC3B;QACAC,OAAO;YACLL,OAAO;YACPC,SAAS;YACTG,SAAS;gBAAC;gBAAkB;aAAuB;QACrD;IACF;IACA,kBAAkB;QAChBhB,MAAM;QACNC,UAAU;QACVC,aAAa;QACbC,YAAY;QACZC,aAAa;QACbC,YAAY;QACZI,qBAAqB;QACrBC,YAAY;QACZC,OAAO;YACLC,OAAO;YACPC,SAAS;YACTC,SACE;YACFC,OAAO;gBACL;gBACA;aACD;YACDC,SAAS;gBAAC;gBAAgB;gBAAkB;aAAkB;QAChE;QACAC,OAAO;YACLL,OAAO;YACPC,SAAS;YACTG,SAAS;gBAAC;gBAAkB;gBAAwB;aAAuB;QAC7E;IACF;IACA,aAAa;QACXhB,MAAM;QACNC,UAAU;QACVC,aAAa;QACbC,YAAY;QACZC,aAAa;QACbC,YAAY;QACZC,YAAY;YAAEC,UAAU;YAAiBC,UAAU;QAAgB;QACnEC,qBAAqB;QACrBC,YAAY;QACZC,OAAO;YACLC,OAAO;YACPC,SACE;YACFC,SACE;YACFC,OAAO;gBACL;gBACA;gBACAnB;gBACAC;aACD;YACDmB,SAAS;gBAAC;gBAAgB;gBAAqB;gBAAkB;aAAkB;QACrF;QACAC,OAAO;YACLL,OAAO;YACPC,SACE;YACFG,SAAS;gBACP;gBACA;gBACA;gBACA;gBACA;gBACA;aA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AY;QACZC,aAAa;QACbC,YAAY;QACZC,YAAY;YAAEC,UAAU;YAAkBC,UAAU;QAAgB;QACpEC,qBAAqB;QACrBC,YAAY;QACZC,OAAO;YACLC,OAAO;YACPC,SACE;YACFC,SACE;YACFC,OAAO;gBACL;gBACA;gBACA;aACD;YACDC,SAAS;gBAAC;gBAAgB;gBAAkB;aAAkB;QAChE;QACAC,OAAO;YACLL,OAAO;YACPC,SACE;YACFG,SAAS;gBACP;gBACA;gBACA;gBACA;gBACA;gBACA;aACD;QACH;IACF;IACA,kBAAkB;QAChBhB,MAAM;QACNC,UAAU;QACVC,aAAa;QACbC,YAAY;QACZC,aAAa;QACbC,YAAY;QACZI,qBAAqB;QACrBC,YAAY;QACZC,OAAO;YACLC,OAAO;YACPC,SACE;YACFC,SACE;YACFC,OAAO;gBACL;gBACA;gBACA;aACD;YACDC,SAAS;gBAAC;gBAAsB;gBAAgB;aAAkB;QACpE;QACAC,OAAO;YACLL,OAAO;YACPC,SAAS;YACTG,SAAS;gBAAC;gBAAwB;aAAkB;QACtD;IACF;IACA,sBAAsB;QACpBhB,MAAM;QACNC,UAAU;QACVC,aAAa;QACbC,YAAY;QACZC,aAAa;QACbC,YAAY;QACZI,qBAAqB;QACrBC,YAAY;QACZC,OAAO;YACLC,OAAO;YACPC,SACE;YACFC,SACE;YACFC,OAAO;gBACL;gBACA;gBACA;aACD;YACDC,SAAS;gBAAC;gBAAgB;gBAAc;gBAAgB;aAAkB;QAC5E;QACAC,OAAO;YACLL,OAAO;YACPC,SAAS;YACTG,SAAS;gBAAC;gBAAqB;gBAAwB;aAAkB;QAC3E;IACF;IACA,uBAAuB;QACrBhB,MAAM;QACNC,UAAU;QACVC,aAAa;QACbC,YAAY;QACZC,aAAa;QACbC,YAAY;QACZI,qBAAqB;QACrBC,YAAY;QACZC,OAAO;YACLC,OAAO;YACPC,SAAS;YACTC,SACE;YACFC,OAAO;gBACL;gBACA;gBACA;aACD;YACDC,SAAS;gBAAC;gBAAgB;gBAAc;gBAAgB;aAAkB;QAC5E;QACAC,OAAO;YACLL,OAAO;YACPC,SAAS;YACTG,SAAS;gBAAC;gBAAqB;aAAuB;QACxD;IACF;IACA,gBAAgB;QACdhB,MAAM;QACNC,UAAU;QACVC,aAAa;QACbC,YAAY;QACZC,aAAa;QACbC,YAAY;QACZI,qBAAqB;QACrBC,YAAY;QACZC,OAAO;YACLC,OAAO;YACPC,SACE;YACFC,SACE;YACFC,OAAO;gBACL;gBACA;gBACA;aACD;YACDC,SAAS;gBAAC;gBAAkB;gBAAgB;aAAkB;QAChE;QACAC,OAAO;YACLL,OAAO;YACPC,SAAS;YACTG,SAAS;gBAAC;gBAAqB;aAAuB;QACxD;IACF;IACA,iBAAiB;QACfhB,MAAM;QACNC,UAAU;QACVC,aAAa;QACbC,YAAY;QACZC,aAAa;QACbC,YAAY;QACZC,YAAY;YAAEC,UAAU;YAAkBC,UAAU;QAAgB;QACpEC,qBAAqB;QACrBC,YAAY;QACZC,OAAO;YACLC,OAAO;YACPC,SACE;YACFC,SACE;YACFC,OAAO;gBACL;gBACA;gBACAlB;aACD;YACDmB,SAAS;gBAAC;gBAAkB;gBAAc;aAAkB;QAC9D;QACAC,OAAO;YACLL,OAAO;YACPC,SACE;YACFG,SAAS;gBACP;gBACA;gBACA;gBACA;gBACA;gBACA;aACD;QACH;IACF;IACA,2BAA2B;QACzBhB,MAAM;QACNC,UAAU;QACVC,aAAa;QACbC,YAAY;QACZC,aAAa;QACbC,YAAY;QACZC,YAAY;YAAEC,UAAU;YAAkBC,UAAU;QAAgB;QACpEC,qBAAqB;QACrBC,YAAY;QACZC,OAAO;YACLC,OAAO;YACPC,SAAS;YACTC,SACE;YACFC,OAAO;gBACL;gBACA;gBACAlB;aACD;YACDmB,SAAS;gBAAC;gBAAkB;gBAA0B;aAAkB;QAC1E;QACAC,OAAO;YACLL,OAAO;YACPC,SACE;YACFG,SAAS;gBACP;gBACA;gBACA;gBACA;gBACA;gBACA;aACD;QACH;IACF;IACA,4BAA4B;QAC1BhB,MAAM;QACNC,UAAU;QACVC,aAAa;QACbC,YAAY;QACZC,aAAa;QACbC,YAAY;QACZI,qBAAqB;QACrBC,YAAY;QACZC,OAAO;YACLC,OAAO;YACPC,SACE;YACFC,SAC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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/risk-notice-catalog.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * THE RISK NOTICE CATALOG (AGL-3368).\n *\n * Every event where the platform holds, flags, locks or pauses something on\n * a workspace, and what each audience is told about it. One entry per kind,\n * read by three consumers that must never disagree:\n *\n * - `notifyRiskEvent` (`@aglyn/tenant-data-admin/server/risk-notice`), which\n * renders the entry into the in-app notification, the owner email and the\n * staff alert;\n * - the console: the workspace's Holds & reviews page renders an owner's\n * notices from it, and the staff abuse queue renders a row's staff half;\n * - the customer help page and the System emails catalog, which list every\n * kind.\n *\n * ## Two audiences, two vocabularies\n *\n * The OWNER half is read by the workspace's owners and admins — and a\n * fraud actor is a workspace owner too. So it describes the OUTCOME and the\n * way to a human (what was held, what that means, how to ask for a review),\n * never the detection: no rule, no threshold, no brand the screen matched,\n * no count of anything. `risk-notice-catalog.spec.ts` sweeps it for those.\n *\n * The STAFF half is read by staff only and names the evidence plainly.\n *\n * ## Owners can never release\n *\n * No owner action releases a hold or lifts a lock. An owner's \"waive\" is a\n * review request, which appends a note to the same abuse-queue row. The\n * spec pins that no owner action id is a staff decision.\n *\n * ## Text is written against merge tokens\n *\n * `{{item.label}}`, `{{workspace.name}}`, `{{brand.productName}}` and the rest\n * are filled at send time. The same strings seed the System emails catalog\n * entry each kind gets (`risk-notice-emails.ts`), so staff can edit the email\n * a kind sends on the System emails page.\n *\n * Pure and dependency-free: the console, the docs, the specs and the server\n * all import it.\n *=========================================*/\n\n/** Every kind of risk event the platform tells people about. */\nexport const RISK_EVENT_KINDS = [\n // Outbound email held by the safety screen: a campaign, an automation\n // step, any site email at the send seam, an outreach message.\n 'email-held',\n 'email-released',\n 'email-rejected',\n // A published page held before a visitor saw it.\n 'page-held',\n 'page-released',\n 'page-rejected',\n // A page ALREADY LIVE that the safety review flagged: it keeps serving,\n // and staff look — the account may have been compromised.\n 'page-flagged',\n // A marketplace submission (a listing, a publisher profile) held before\n // it was listed.\n 'listing-held',\n 'listing-released',\n 'listing-rejected',\n // A custom site domain or sending domain flagged for a look.\n 'domain-flagged',\n 'domain-cleared',\n 'domain-rejected',\n // A fraud signal on the workspace's own subscription payment.\n 'billing-payment-flagged',\n // A link a merchant configured (a download, a feed, a supplier hook) that\n // wears another brand's name, refused wherever it is followed.\n 'link-blocked',\n // A burst of card payments on one site: possibly card testing.\n 'card-testing',\n // Gift cards an order bought, frozen while its payment is in question.\n 'gift-card-hold',\n // A fraud signal on a sale the workspace took from its own customer.\n 'sale-fraud-warning',\n 'sale-payment-review',\n 'sale-dispute',\n // Several of a seller's sales drew fraud reports: staff are reviewing.\n 'seller-review',\n // A marketplace sale a publisher received is under review, a card signal\n // arrived on one, and a new publisher's payouts wait (or stop waiting).\n 'marketplace-sale-review',\n 'marketplace-sale-warning',\n 'publisher-payouts-held',\n 'publisher-payouts-standard',\n // A review with no held item of its own (a payment, a seller review)\n // closed: cleared, or upheld.\n 'review-cleared',\n 'review-upheld',\n // Security locks, by scope, and their lifts.\n 'workspace-locked',\n 'workspace-unlocked',\n 'site-locked',\n 'site-unlocked',\n 'domain-locked',\n 'domain-unlocked',\n 'account-locked',\n 'account-unlocked',\n // One capability switched off for one workspace (AI, uploads, checkout…).\n 'feature-locked',\n 'feature-unlocked',\n // Staff canceled the workspace's subscription from its staff page. (A\n // lock's own cancellation, and the renewals and payouts a lock pauses and\n // its lift resumes, are told in the lock and lift notices themselves, as\n // what the lock affects — one email per staff action, not four.)\n 'subscription-canceled',\n] as const\n\nexport type RiskEventKind = (typeof RISK_EVENT_KINDS)[number]\n\n/** How loud a notice is. `urgent` kinds are emailed and alert staff at once. */\nexport type RiskNoticeSeverity = 'info' | 'warning' | 'urgent'\n\n/** The actions an OWNER or ADMIN is offered. None of them releases anything. */\nexport const RISK_OWNER_ACTION_IDS = [\n 'view-details',\n 'edit-and-resubmit',\n 'request-review',\n 'refund-order',\n 'keep-order',\n 'answer-dispute',\n 'update-payment-method',\n 'view-billing',\n 'view-holds',\n 'view-marketplace-sales',\n 'release-gift-cards',\n 'contact-support',\n] as const\nexport type RiskOwnerActionId = (typeof RISK_OWNER_ACTION_IDS)[number]\n\n/** The actions STAFF are offered: the real controls, deep-linked. */\nexport const RISK_STAFF_ACTION_IDS = [\n 'staff-open-row',\n 'staff-release',\n 'staff-reject',\n 'staff-lock-workspace',\n 'staff-lock-site',\n 'staff-unlock',\n 'staff-cancel-subscription',\n 'staff-open-stripe',\n 'staff-view-workspace',\n] as const\nexport type RiskStaffActionId = (typeof RISK_STAFF_ACTION_IDS)[number]\n\n/**\n * Where each action goes, as a console path TEMPLATE. `{name}` placeholders\n * are filled by {@link resolveRiskActionHref} from the event; an action whose\n * placeholder has no value is dropped rather than rendered dead.\n *\n * Owner paths are in the stored-notification shape the console's\n * `normalizeNotificationLink` rewrites when followed: `/org/…` becomes the\n * reader's `/{orgSlug}/…` and `/{hostId}/…` becomes `/{orgSlug}/hosts/{sub}/…`.\n * `{itemPath}` is the held item's own page, supplied by the signal source.\n *\n * `risk-notice-routes.spec.ts` (console) resolves every template to a page\n * that exists under `apps/console/app`.\n */\nexport interface RiskActionDefinition {\n label: string\n /** What happens when it is followed, for the help page and a tooltip. */\n hint: string\n href: string\n}\n\nexport const RISK_OWNER_ACTIONS: Record<RiskOwnerActionId, RiskActionDefinition> = {\n 'view-details': {\n label: 'View details',\n hint: 'Opens the item this notice is about.',\n href: '{itemPath}',\n },\n 'edit-and-resubmit': {\n label: 'Edit and resubmit',\n hint: 'Opens the item so you can change it. A changed version is checked again on its own.',\n href: '{itemPath}',\n },\n 'request-review': {\n label: 'Request a review',\n hint: 'Sends a note to our review team on the same case. It does not release anything by itself.',\n href: '/org/settings/holds?notice={noticeId}',\n },\n 'refund-order': {\n label: 'Refund this order',\n hint: 'Opens the order, where Refund returns the payment to the card.',\n href: '{itemPath}',\n },\n 'keep-order': {\n label: 'Keep the order',\n hint: 'Opens the order. Nothing needs doing to keep it; fulfill it only once you are sure.',\n href: '{itemPath}',\n },\n 'answer-dispute': {\n label: 'Answer the dispute',\n hint: 'Opens the order with its dispute and the date your evidence is due.',\n href: '{itemPath}',\n },\n 'update-payment-method': {\n label: 'Update payment method',\n hint: 'Opens billing settings, where the card your subscription is paid from lives.',\n href: '/org/billing/settings',\n },\n 'view-billing': {\n label: 'View billing',\n hint: 'Opens your plan and subscription.',\n href: '/org/billing',\n },\n 'release-gift-cards': {\n label: 'Review gift cards',\n hint: 'Opens Gift cards under Promotions, where a held card can be released once you trust the payment.',\n href: '/{hostId}/products/promotions',\n },\n 'view-marketplace-sales': {\n label: 'View marketplace payouts',\n hint: 'Opens your marketplace payouts, where each sale and when it pays out are listed.',\n href: '/org/marketplace/payouts',\n },\n 'view-holds': {\n label: 'View holds and reviews',\n hint: 'Lists everything on this workspace that is held or under review, with its status.',\n href: '/org/settings/holds',\n },\n 'contact-support': {\n label: 'Contact support',\n hint: 'Opens a support ticket. If you cannot sign in, reply to this email instead.',\n href: '/org/support/tickets',\n },\n}\n\nexport const RISK_STAFF_ACTIONS: Record<RiskStaffActionId, RiskActionDefinition> = {\n 'staff-open-row': {\n label: 'Open in abuse queue',\n hint: 'The abuse-queue row with the evidence and any review requests.',\n href: '/admin/abuse-reports?report={reviewId}',\n },\n 'staff-release': {\n label: 'Waive / release',\n hint: 'Dismiss the row. For a held email or page this releases exactly the held content.',\n href: '/admin/abuse-reports?report={reviewId}&decide=dismissed',\n },\n 'staff-reject': {\n label: 'Reject',\n hint: 'Mark the row actioned. A held email or page stays unsent or unserved.',\n href: '/admin/abuse-reports?report={reviewId}&decide=actioned',\n },\n 'staff-lock-workspace': {\n label: 'Lock workspace',\n hint: 'Opens Lockdown with this workspace filled in.',\n href: '/admin/lockdown?scope=org&targetId={orgId}',\n },\n 'staff-lock-site': {\n label: 'Lock site',\n hint: 'Opens Lockdown with this site filled in.',\n href: '/admin/lockdown?scope=host&targetId={hostId}',\n },\n 'staff-unlock': {\n label: 'Review the lock',\n hint: 'Opens Lockdown on the locked target, where it can be lifted.',\n href: '/admin/lockdown?scope={lockScope}&targetId={lockTargetId}',\n },\n 'staff-cancel-subscription': {\n label: 'Cancel subscription',\n hint: \"The workspace's Subscription card on its staff page.\",\n href: '/admin/orgs/{orgId}#subscription',\n },\n 'staff-open-stripe': {\n label: 'Open in Stripe',\n hint: 'The charge, review, dispute or connected account in the Stripe Dashboard.',\n href: '{stripeUrl}',\n },\n 'staff-view-workspace': {\n label: 'View workspace',\n hint: \"The workspace's staff page.\",\n href: '/admin/orgs/{orgId}',\n },\n}\n\n/** One audience's half of a kind. */\nexport interface RiskNoticeCopy {\n /** Short and specific. Also the email subject. */\n title: string\n /** What we saw, on what, and when. */\n summary: string\n /** What it means for them. */\n meaning: string\n /** What to do next, in order. */\n steps: readonly string[]\n}\n\nexport interface RiskNoticeDefinition {\n kind: RiskEventKind\n severity: RiskNoticeSeverity\n /**\n * Whether the owners and admins are emailed as well as notified in the\n * console. The email is transactional account mail from the platform's own\n * sender: it ignores notification preferences and marketing consent, and\n * it reaches a locked workspace.\n */\n emailOwners: boolean\n /** Whether staff are alerted. False for events staff themselves caused. */\n alertStaff: boolean\n /**\n * Whether the notice always goes out, even while the workspace is over its\n * burst allowance. Locks, lifts and cancellations are never folded into a\n * digest: each one changes what the owner can do.\n */\n neverDigest: boolean\n /**\n * An owner can ask for a review. Only kinds with an abuse-queue row to\n * append to are reviewable.\n */\n reviewable: boolean\n /** The kinds staff decisions on this kind's row close it with. */\n closesWith?: { released: RiskEventKind; rejected: RiskEventKind }\n /** Also tell the managers of the site the event is on, not just the workspace's. */\n includeSiteManagers: boolean\n /** The help page section that explains it. */\n helpAnchor: string\n owner: RiskNoticeCopy & { actions: readonly RiskOwnerActionId[] }\n staff: Pick<RiskNoticeCopy, 'title' | 'summary'> & {\n actions: readonly RiskStaffActionId[]\n }\n}\n\n/**\n * The customer help page every notice links to. A path on the docs site;\n * the server prefixes the docs origin.\n */\nexport const RISK_NOTICE_HELP_PATH = '/help/holds-and-reviews'\n\n/**\n * Merge tokens a notice may use. The server fills each from the event; a\n * token the event does not carry renders as the fallback noted here, never\n * as a hole in a sentence.\n */\nexport const RISK_NOTICE_TOKENS = {\n 'workspace.name': 'The workspace name, or \"your workspace\".',\n 'item.label': 'What the notice is about, in the owner\\'s words: the campaign \"Spring sale\", the page /pricing, order 1042.',\n 'item.url': 'The absolute console link to the item.',\n occurredAt: 'When it happened, in UTC.',\n reference: 'The case reference staff know it by (HS-…, PF-…).',\n 'holds.url': \"The workspace's Holds & reviews page.\",\n 'help.url': 'The help page for holds and reviews.',\n 'support.email': \"The operator's support address.\",\n 'lock.message': 'The message staff wrote when they applied the lock.',\n 'lock.affected': 'What the lock or pause covers: sites, sessions, billing.',\n amount: 'The payment amount, when there is one.',\n 'evidence.dueBy': 'The date dispute evidence is due, when Stripe gave one.',\n 'staff.evidence': 'Staff only: what the screen or Stripe reported.',\n} as const\n\nexport type RiskNoticeToken = keyof typeof RISK_NOTICE_TOKENS\n\nconst ASK_FOR_REVIEW =\n 'If you think this is a mistake, choose Request a review and tell us about it. A person reads every request.'\n\nconst REVIEW_TIME =\n 'Reviews are usually answered within one business day. You will get a notice either way.'\n\nconst CANNOT_SIGN_IN =\n 'If you cannot sign in, reply to this email or write to {{support.email}} with the reference {{reference}}.'\n\n/**\n * How a locked ACCOUNT appeals (AGL-3420). It cannot sign in, so the way in\n * is mail: a reply to the notice reaches support (the notice's Reply-To),\n * and support answers from its own mailbox — which a ban does not refuse,\n * because a ban refuses what the platform sends, not what a person does.\n */\nconst APPEAL_BY_REPLY =\n 'If you believe this is a mistake, you can appeal: reply to this email, or write to {{support.email}} with the reference {{reference}}. Tell us anything that explains what happened. A person reviews every appeal and answers from that address.'\n\n/*\n * The catalog. Exhaustive by type: a kind added to RISK_EVENT_KINDS is a\n * compile error here until somebody writes what each audience is told.\n */\nexport const RISK_NOTICE_CATALOG: Readonly<Record<RiskEventKind, RiskNoticeDefinition>> = {\n 'email-held': {\n kind: 'email-held',\n severity: 'warning',\n emailOwners: true,\n alertStaff: true,\n neverDigest: false,\n reviewable: true,\n closesWith: { released: 'email-released', rejected: 'email-rejected' },\n includeSiteManagers: false,\n helpAnchor: 'email-held',\n owner: {\n title: 'An email is on hold for review',\n summary:\n 'Our automated safety review held {{item.label}} from {{workspace.name}} on {{occurredAt}} before it was sent.',\n meaning:\n 'This email was not sent. Nobody on your list received it, and nothing else on your account has changed.',\n steps: [\n 'Open the email and check its links, its sender name and its wording.',\n 'If you change it, it is checked again when it next sends.',\n ASK_FOR_REVIEW,\n REVIEW_TIME,\n ],\n actions: ['view-details', 'edit-and-resubmit', 'request-review', 'contact-support'],\n },\n staff: {\n title: 'Outbound email held for review — possible phishing',\n summary:\n '{{item.label}} from {{workspace.name}} was held on {{occurredAt}}. {{staff.evidence}} Reference {{reference}}.',\n actions: [\n 'staff-open-row',\n 'staff-release',\n 'staff-reject',\n 'staff-lock-workspace',\n 'staff-view-workspace',\n ],\n },\n },\n 'email-released': {\n kind: 'email-released',\n severity: 'info',\n emailOwners: true,\n alertStaff: false,\n neverDigest: false,\n reviewable: false,\n includeSiteManagers: false,\n helpAnchor: 'email-held',\n owner: {\n title: 'Your held email was released',\n summary: 'Our review team released {{item.label}} on {{occurredAt}}.',\n meaning:\n 'A held campaign goes back on the schedule and sends shortly. An automated email sends the next time it runs.',\n steps: [\n 'Nothing to do. If you edit the email before it sends, the edited version is checked on its own.',\n ],\n actions: ['view-details'],\n },\n staff: {\n title: 'Held email released',\n summary: '{{item.label}} from {{workspace.name}} was released. Reference {{reference}}.',\n actions: ['staff-open-row', 'staff-view-workspace'],\n },\n },\n 'email-rejected': {\n kind: 'email-rejected',\n severity: 'warning',\n emailOwners: true,\n alertStaff: false,\n neverDigest: false,\n reviewable: true,\n includeSiteManagers: false,\n helpAnchor: 'email-held',\n owner: {\n title: 'A held email will not be sent',\n summary: 'After review, {{item.label}} was not approved on {{occurredAt}}.',\n meaning:\n 'This email will not be sent in its current form. A held campaign is canceled; an automated email step stays stopped.',\n steps: [\n 'Read our acceptable use rules before you send similar mail again.',\n 'If you think the decision is wrong, choose Request a review and explain what the email is for.',\n ],\n actions: ['view-details', 'request-review', 'contact-support'],\n },\n staff: {\n title: 'Held email rejected',\n summary: '{{item.label}} from {{workspace.name}} was rejected. Reference {{reference}}.',\n actions: ['staff-open-row', 'staff-lock-workspace', 'staff-view-workspace'],\n },\n },\n 'page-held': {\n kind: 'page-held',\n severity: 'warning',\n emailOwners: true,\n alertStaff: true,\n neverDigest: false,\n reviewable: true,\n closesWith: { released: 'page-released', rejected: 'page-rejected' },\n includeSiteManagers: true,\n helpAnchor: 'page-held',\n owner: {\n title: 'A published page is on hold for review',\n summary:\n 'Our automated safety review held the latest version of {{item.label}} on {{occurredAt}}.',\n meaning:\n 'Visitors still see the previous version of this page. If it has never been published before, visitors see a not-found page until the review is done.',\n steps: [\n 'Open the page and check its links, embeds and any fields that ask visitors for information.',\n 'If you change and publish it again, the new version is checked on its own.',\n ASK_FOR_REVIEW,\n REVIEW_TIME,\n ],\n actions: ['view-details', 'edit-and-resubmit', 'request-review', 'contact-support'],\n },\n staff: {\n title: 'Published page held for review — possible phishing',\n summary:\n '{{item.label}} of {{workspace.name}} was held on {{occurredAt}}. {{staff.evidence}} Reference {{reference}}.',\n actions: [\n 'staff-open-row',\n 'staff-release',\n 'staff-reject',\n 'staff-lock-site',\n 'staff-lock-workspace',\n 'staff-view-workspace',\n ],\n },\n },\n 'page-released': {\n kind: 'page-released',\n severity: 'info',\n emailOwners: true,\n alertStaff: false,\n neverDigest: false,\n reviewable: false,\n includeSiteManagers: true,\n helpAnchor: 'page-held',\n owner: {\n title: 'Your held page is live',\n summary: 'Our review team released {{item.label}} on {{occurredAt}}.',\n meaning: 'Visitors now see the version that was held.',\n steps: ['Nothing to do. Open the page to confirm it looks right.'],\n actions: ['view-details'],\n },\n staff: {\n title: 'Held page released',\n summary: '{{item.label}} of {{workspace.name}} was released. Reference {{reference}}.',\n actions: ['staff-open-row', 'staff-view-workspace'],\n },\n },\n 'page-flagged': {\n kind: 'page-flagged',\n severity: 'warning',\n emailOwners: true,\n alertStaff: true,\n neverDigest: false,\n reviewable: true,\n closesWith: { released: 'review-cleared', rejected: 'review-upheld' },\n includeSiteManagers: true,\n helpAnchor: 'page-flagged',\n owner: {\n title: 'A live page is being reviewed',\n summary:\n 'Our automated safety review flagged {{item.label}} on {{occurredAt}}. The page is still live.',\n meaning:\n 'Visitors still see the page while a person looks. If you did not publish what is on it, someone else may have access to your account.',\n steps: [\n 'Open the page and check its links, embeds and any fields that ask visitors for information.',\n 'If you did not make these changes, change your password, remove members you do not recognize, and contact support.',\n ASK_FOR_REVIEW,\n REVIEW_TIME,\n ],\n actions: ['view-details', 'edit-and-resubmit', 'request-review', 'contact-support'],\n },\n staff: {\n title: 'Live page flagged — possible phishing or a compromised account',\n summary:\n '{{item.label}} of {{workspace.name}} is live and was flagged on {{occurredAt}}. {{staff.evidence}} It is still serving; lock the site if it is impersonation. Reference {{reference}}.',\n actions: [\n 'staff-open-row',\n 'staff-release',\n 'staff-lock-site',\n 'staff-lock-workspace',\n 'staff-view-workspace',\n ],\n },\n },\n 'page-rejected': {\n kind: 'page-rejected',\n severity: 'warning',\n emailOwners: true,\n alertStaff: false,\n neverDigest: false,\n reviewable: true,\n includeSiteManagers: true,\n helpAnchor: 'page-held',\n owner: {\n title: 'A held page will not be published',\n summary: 'After review, the held version of {{item.label}} was not approved on {{occurredAt}}.',\n meaning:\n 'Visitors keep seeing the previous version, or a not-found page if there is none. Publishing the same content again is held again.',\n steps: [\n 'Change the page and publish a new version, or restore an earlier version from its history.',\n 'If you think the decision is wrong, choose Request a review and explain what the page is for.',\n ],\n actions: ['edit-and-resubmit', 'request-review', 'contact-support'],\n },\n staff: {\n title: 'Held page rejected',\n summary: '{{item.label}} of {{workspace.name}} was rejected. Reference {{reference}}.',\n actions: ['staff-open-row', 'staff-lock-site', 'staff-view-workspace'],\n },\n },\n 'listing-held': {\n kind: 'listing-held',\n severity: 'warning',\n emailOwners: true,\n alertStaff: true,\n neverDigest: false,\n reviewable: true,\n closesWith: { released: 'listing-released', rejected: 'listing-rejected' },\n includeSiteManagers: false,\n helpAnchor: 'listing-held',\n owner: {\n title: 'A marketplace submission is on hold for review',\n summary:\n 'Our automated safety review held {{item.label}} from {{workspace.name}} on {{occurredAt}} before it was listed.',\n meaning: 'Nothing was published. Buyers cannot see or install it while it waits.',\n steps: [\n 'Check its name, description, links and any fields it asks people to fill in.',\n 'If you change it, the new submission is checked on its own.',\n ASK_FOR_REVIEW,\n REVIEW_TIME,\n ],\n actions: ['request-review', 'view-details', 'contact-support'],\n },\n staff: {\n title: 'Marketplace submission held for review — possible phishing',\n summary:\n '{{item.label}} from publisher workspace {{workspace.name}} was held on {{occurredAt}}. {{staff.evidence}} Reference {{reference}}.',\n actions: [\n 'staff-open-row',\n 'staff-release',\n 'staff-reject',\n 'staff-lock-workspace',\n 'staff-view-workspace',\n ],\n },\n },\n 'listing-released': {\n kind: 'listing-released',\n severity: 'info',\n emailOwners: true,\n alertStaff: false,\n neverDigest: false,\n reviewable: false,\n includeSiteManagers: false,\n helpAnchor: 'listing-held',\n owner: {\n title: 'Your marketplace submission was released',\n summary: 'Our review team released {{item.label}} on {{occurredAt}}.',\n meaning: 'It was not published automatically.',\n steps: ['Submit it again; it will go through this time.'],\n actions: ['view-details'],\n },\n staff: {\n title: 'Held marketplace submission released',\n summary: '{{item.label}} from {{workspace.name}} was released. Reference {{reference}}.',\n actions: ['staff-open-row', 'staff-view-workspace'],\n },\n },\n 'listing-rejected': {\n kind: 'listing-rejected',\n severity: 'warning',\n emailOwners: true,\n alertStaff: false,\n neverDigest: false,\n reviewable: true,\n includeSiteManagers: false,\n helpAnchor: 'listing-held',\n owner: {\n title: 'A marketplace submission will not be listed',\n summary: 'After review, {{item.label}} was not approved on {{occurredAt}}.',\n meaning: 'It will not be published in its current form. Submitting the same content again is held again.',\n steps: [\n 'Read the marketplace listing rules before you submit something similar.',\n 'If you think the decision is wrong, choose Request a review and explain what it is for.',\n ],\n actions: ['request-review', 'contact-support'],\n },\n staff: {\n title: 'Held marketplace submission rejected',\n summary: '{{item.label}} from {{workspace.name}} was rejected. Reference {{reference}}.',\n actions: ['staff-open-row', 'staff-lock-workspace', 'staff-view-workspace'],\n },\n },\n 'domain-flagged': {\n kind: 'domain-flagged',\n severity: 'warning',\n emailOwners: true,\n alertStaff: true,\n neverDigest: false,\n reviewable: true,\n closesWith: { released: 'domain-cleared', rejected: 'domain-rejected' },\n includeSiteManagers: false,\n helpAnchor: 'domain-flagged',\n owner: {\n title: 'A domain you added is being reviewed',\n summary: '{{item.label}} was added to {{workspace.name}} on {{occurredAt}} and is waiting on a routine review.',\n meaning:\n 'The domain stays connected while we look. Email sent from it may wait for the review to finish.',\n steps: [\n 'Nothing is needed if the domain and the name on it are yours.',\n 'To speed it up, choose Request a review and tell us who owns the domain and what it is for.',\n REVIEW_TIME,\n ],\n actions: ['view-details', 'request-review', 'contact-support'],\n },\n staff: {\n title: 'Domain flagged — possible brand impersonation',\n summary:\n '{{item.label}} was added by {{workspace.name}} on {{occurredAt}}. {{staff.evidence}} Reference {{reference}}.',\n actions: [\n 'staff-open-row',\n 'staff-release',\n 'staff-reject',\n 'staff-lock-workspace',\n 'staff-view-workspace',\n ],\n },\n },\n 'domain-cleared': {\n kind: 'domain-cleared',\n severity: 'info',\n emailOwners: true,\n alertStaff: false,\n neverDigest: false,\n reviewable: false,\n includeSiteManagers: false,\n helpAnchor: 'domain-flagged',\n owner: {\n title: 'Your domain review is complete',\n summary: '{{item.label}} was reviewed and cleared on {{occurredAt}}.',\n meaning: 'The domain works as normal. Email that was waiting on it sends on its next attempt.',\n steps: ['Nothing to do.'],\n actions: ['view-details'],\n },\n staff: {\n title: 'Flagged domain cleared',\n summary: '{{item.label}} on {{workspace.name}} was cleared. Reference {{reference}}.',\n actions: ['staff-open-row', 'staff-view-workspace'],\n },\n },\n 'domain-rejected': {\n kind: 'domain-rejected',\n severity: 'warning',\n emailOwners: true,\n alertStaff: false,\n neverDigest: false,\n reviewable: true,\n includeSiteManagers: false,\n helpAnchor: 'domain-flagged',\n owner: {\n title: 'A domain on your workspace was not approved',\n summary: 'After review, {{item.label}} was not approved on {{occurredAt}}.',\n meaning:\n 'Email from this domain will not be sent, and pages served on it may be restricted.',\n steps: [\n 'Remove the domain from your workspace, or show us that it is yours.',\n 'If you think the decision is wrong, choose Request a review with proof of ownership.',\n ],\n actions: ['view-details', 'request-review', 'contact-support'],\n },\n staff: {\n title: 'Flagged domain rejected',\n summary: '{{item.label}} on {{workspace.name}} was rejected. Reference {{reference}}.',\n actions: ['staff-open-row', 'staff-lock-workspace', 'staff-view-workspace'],\n },\n },\n 'billing-payment-flagged': {\n kind: 'billing-payment-flagged',\n severity: 'urgent',\n emailOwners: true,\n alertStaff: true,\n neverDigest: false,\n reviewable: true,\n closesWith: { released: 'review-cleared', rejected: 'review-upheld' },\n includeSiteManagers: false,\n helpAnchor: 'billing-payment-flagged',\n owner: {\n title: 'A payment on your subscription needs attention',\n summary:\n 'On {{occurredAt}}, a subscription payment of {{amount}} for {{workspace.name}} was flagged for a fraud check.',\n meaning:\n 'The payment may be reversed by the cardholder\\'s bank. If it is, your subscription may need a new payment method, and your plan can change.',\n steps: [\n 'If you made this payment, choose Request a review and confirm it, so we can answer the bank.',\n 'Check that the card on your account is yours and current.',\n 'If you did not make this payment, contact support now.',\n ],\n actions: ['request-review', 'update-payment-method', 'view-billing', 'contact-support'],\n },\n staff: {\n title: 'Platform billing fraud signal',\n summary:\n '{{staff.evidence}} Workspace {{workspace.name}}, {{amount}}, on {{occurredAt}}. Nothing has been refunded or canceled. Reference {{reference}}.',\n actions: [\n 'staff-open-row',\n 'staff-open-stripe',\n 'staff-cancel-subscription',\n 'staff-lock-workspace',\n 'staff-release',\n 'staff-reject',\n ],\n },\n },\n 'link-blocked': {\n kind: 'link-blocked',\n severity: 'warning',\n emailOwners: true,\n alertStaff: true,\n neverDigest: false,\n reviewable: true,\n closesWith: { released: 'review-cleared', rejected: 'review-upheld' },\n includeSiteManagers: true,\n helpAnchor: 'link-blocked',\n owner: {\n title: 'A link on your store was blocked',\n summary:\n 'On {{occurredAt}}, we stopped sending anyone to {{item.label}}, because the address looks like another company\\'s website.',\n meaning: 'Nobody who follows that link is sent there. Everything else on your store works as normal.',\n steps: [\n 'Replace it with a file from your media library or a link on your own domain.',\n 'If the address really is yours, choose Request a review and tell us about it.',\n ],\n actions: ['view-details', 'request-review', 'contact-support'],\n },\n staff: {\n title: 'Commerce link flagged — possible brand impersonation',\n summary:\n '{{item.label}} on {{workspace.name}}. {{staff.evidence}} Reference {{reference}}.',\n actions: [\n 'staff-open-row',\n 'staff-release',\n 'staff-reject',\n 'staff-lock-site',\n 'staff-view-workspace',\n ],\n },\n },\n 'card-testing': {\n kind: 'card-testing',\n severity: 'urgent',\n emailOwners: true,\n alertStaff: true,\n neverDigest: false,\n reviewable: true,\n closesWith: { released: 'review-cleared', rejected: 'review-upheld' },\n includeSiteManagers: true,\n helpAnchor: 'card-testing',\n owner: {\n title: 'Unusual checkout activity on your site',\n summary:\n 'On {{occurredAt}}, the checkout on {{item.label}} saw an unusual burst of payment attempts, which can be someone testing stolen cards.',\n meaning:\n 'Checkout is still open. Repeated attempts from one source are being slowed. Payments from stolen cards may be reversed by the cardholder\\'s bank later.',\n steps: [\n 'Review your recent orders before you fulfill them.',\n 'Refund any order you do not recognize.',\n 'If this was a launch or a sale, nothing is needed; you can tell us with Request a review.',\n ],\n actions: ['view-details', 'request-review', 'contact-support'],\n },\n staff: {\n title: 'Card-testing velocity on a site',\n summary:\n '{{item.label}} of {{workspace.name}}. {{staff.evidence}} Nothing has been refused site-wide or refunded. Reference {{reference}}.',\n actions: [\n 'staff-open-row',\n 'staff-lock-site',\n 'staff-lock-workspace',\n 'staff-view-workspace',\n 'staff-release',\n 'staff-reject',\n ],\n },\n },\n 'gift-card-hold': {\n kind: 'gift-card-hold',\n severity: 'warning',\n emailOwners: true,\n alertStaff: true,\n neverDigest: false,\n reviewable: false,\n includeSiteManagers: true,\n helpAnchor: 'gift-card-hold',\n owner: {\n title: 'Gift cards from an order are on hold',\n summary:\n 'On {{occurredAt}}, the gift cards bought with {{item.label}} were put on hold because the payment for it is in question.',\n meaning:\n 'They cannot be redeemed for now. Their balance is kept. If the payment is refunded or the dispute is lost, the balance is voided for you.',\n steps: [\n 'Check the order and the buyer.',\n 'If you are satisfied the payment is genuine, release the cards from Gift cards under Promotions.',\n 'If you are not, refund the order.',\n ],\n actions: ['release-gift-cards', 'refund-order', 'contact-support'],\n },\n staff: {\n title: 'Gift cards frozen on a questioned payment',\n summary: '{{item.label}} on {{workspace.name}}. {{staff.evidence}}',\n actions: ['staff-view-workspace', 'staff-lock-site'],\n },\n },\n 'sale-fraud-warning': {\n kind: 'sale-fraud-warning',\n severity: 'warning',\n emailOwners: true,\n alertStaff: false,\n neverDigest: false,\n reviewable: false,\n includeSiteManagers: true,\n helpAnchor: 'sale-fraud-warning',\n owner: {\n title: 'A payment you received may be fraudulent',\n summary:\n 'On {{occurredAt}}, the card issuer reported the payment for {{item.label}} ({{amount}}) as possibly not made by the cardholder.',\n meaning:\n 'This payment may be reversed by the cardholder\\'s bank. It is not a chargeback yet. Nothing has been refunded or canceled.',\n steps: [\n 'Hold anything not yet shipped or delivered until you are sure the buyer is genuine.',\n 'Refunding now usually prevents a chargeback and its fee.',\n 'If you know the buyer and the sale is genuine, you can keep the order.',\n ],\n actions: ['refund-order', 'keep-order', 'view-details', 'contact-support'],\n },\n staff: {\n title: 'Early fraud warning on a site sale',\n summary: '{{item.label}} on {{workspace.name}}, {{amount}}. {{staff.evidence}}',\n actions: ['staff-open-stripe', 'staff-view-workspace', 'staff-lock-site'],\n },\n },\n 'sale-payment-review': {\n kind: 'sale-payment-review',\n severity: 'warning',\n emailOwners: true,\n alertStaff: false,\n neverDigest: false,\n reviewable: false,\n includeSiteManagers: true,\n helpAnchor: 'sale-payment-review',\n owner: {\n title: 'A payment you received is on hold for review',\n summary: 'On {{occurredAt}}, the payment for {{item.label}} ({{amount}}) was held for a fraud review.',\n meaning:\n 'The payment has not reached your balance yet and may be refunded if it is not approved. Nothing has been refunded or canceled.',\n steps: [\n 'Hold anything not yet shipped or delivered until the payment clears.',\n 'If you know the buyer is not genuine, refund the order.',\n 'If you know the buyer, keep the order; the review usually clears on its own.',\n ],\n actions: ['refund-order', 'keep-order', 'view-details', 'contact-support'],\n },\n staff: {\n title: 'Payment review on a site sale',\n summary: '{{item.label}} on {{workspace.name}}, {{amount}}. {{staff.evidence}}',\n actions: ['staff-open-stripe', 'staff-view-workspace'],\n },\n },\n 'sale-dispute': {\n kind: 'sale-dispute',\n severity: 'urgent',\n emailOwners: true,\n alertStaff: false,\n neverDigest: false,\n reviewable: false,\n includeSiteManagers: true,\n helpAnchor: 'sale-dispute',\n owner: {\n title: 'A customer disputed a payment',\n summary:\n 'On {{occurredAt}}, the cardholder disputed the payment for {{item.label}} ({{amount}}) with their bank.',\n meaning:\n 'The payment may be reversed by the cardholder\\'s bank. An unanswered dispute is decided for the cardholder. Your evidence is due by {{evidence.dueBy}}.',\n steps: [\n 'Open the order and gather what shows the sale was genuine: receipts, delivery tracking, messages with the buyer.',\n 'Send your evidence before {{evidence.dueBy}}. Contact support if you need help submitting it.',\n 'If the buyer is right, accept the dispute instead of answering it.',\n ],\n actions: ['answer-dispute', 'view-details', 'contact-support'],\n },\n staff: {\n title: 'Dispute on a site sale',\n summary: '{{item.label}} on {{workspace.name}}, {{amount}}. Evidence due {{evidence.dueBy}}. {{staff.evidence}}',\n actions: ['staff-open-stripe', 'staff-view-workspace'],\n },\n },\n 'seller-review': {\n kind: 'seller-review',\n severity: 'urgent',\n emailOwners: true,\n alertStaff: true,\n neverDigest: false,\n reviewable: true,\n closesWith: { released: 'review-cleared', rejected: 'review-upheld' },\n includeSiteManagers: false,\n helpAnchor: 'seller-review',\n owner: {\n title: 'We are reviewing recent payments on your store',\n summary:\n 'On {{occurredAt}}, our team opened a routine review of recent card payments taken by {{workspace.name}}.',\n meaning:\n 'Your store keeps selling while we look. We may contact you for information about your recent orders.',\n steps: [\n 'Check your recent orders and refund any you believe were not placed by the cardholder.',\n 'Choose Request a review to tell us about your business and your customers; it helps the review finish sooner.',\n REVIEW_TIME,\n ],\n actions: ['request-review', 'view-holds', 'contact-support'],\n },\n staff: {\n title: 'Seller fraud pattern on one connected account',\n summary:\n '{{staff.evidence}} Workspace {{workspace.name}}. Nothing has been refunded, canceled or paused. Reference {{reference}}.',\n actions: [\n 'staff-open-row',\n 'staff-open-stripe',\n 'staff-lock-workspace',\n 'staff-view-workspace',\n 'staff-release',\n 'staff-reject',\n ],\n },\n },\n 'marketplace-sale-review': {\n kind: 'marketplace-sale-review',\n severity: 'warning',\n emailOwners: true,\n alertStaff: true,\n neverDigest: false,\n reviewable: true,\n closesWith: { released: 'review-cleared', rejected: 'review-upheld' },\n includeSiteManagers: false,\n helpAnchor: 'marketplace-sale-review',\n owner: {\n title: 'A marketplace sale is under review',\n summary: 'On {{occurredAt}}, our team opened a routine review of {{item.label}} ({{amount}}).',\n meaning:\n 'The sale is recorded as usual, and its payout may take longer than normal. Nothing has been refunded.',\n steps: [\n 'Nothing is needed from you.',\n 'To tell us about the buyer or the sale, choose Request a review.',\n REVIEW_TIME,\n ],\n actions: ['request-review', 'view-marketplace-sales', 'contact-support'],\n },\n staff: {\n title: 'Marketplace sale flagged — possible self-dealing or card fraud',\n summary:\n '{{item.label}} ({{amount}}) paid publisher workspace {{workspace.name}}. {{staff.evidence}} Nothing has been refunded or paused. Reference {{reference}}.',\n actions: [\n 'staff-open-row',\n 'staff-open-stripe',\n 'staff-lock-workspace',\n 'staff-view-workspace',\n 'staff-release',\n 'staff-reject',\n ],\n },\n },\n 'marketplace-sale-warning': {\n kind: 'marketplace-sale-warning',\n severity: 'warning',\n emailOwners: true,\n alertStaff: false,\n neverDigest: false,\n reviewable: false,\n includeSiteManagers: false,\n helpAnchor: 'marketplace-sale-review',\n owner: {\n title: 'A marketplace sale may be reversed',\n summary:\n 'On {{occurredAt}}, the payment for {{item.label}} was reported as possibly not made by the cardholder, or held for a fraud check.',\n meaning:\n 'This payment may be reversed by the cardholder\\'s bank. If it is, your share of it is taken back from your payouts. Nothing has been refunded yet.',\n steps: [\n 'Nothing is needed from you: the marketplace handles the payment with the buyer\\'s bank.',\n 'If you know the buyer, contact support; it can help us answer the bank.',\n ],\n actions: ['view-marketplace-sales', 'contact-support'],\n },\n staff: {\n title: 'Card signal on a marketplace sale',\n summary: '{{item.label}} paid publisher workspace {{workspace.name}}. {{staff.evidence}}',\n actions: ['staff-open-stripe', 'staff-view-workspace'],\n },\n },\n 'publisher-payouts-held': {\n kind: 'publisher-payouts-held',\n severity: 'info',\n emailOwners: true,\n alertStaff: false,\n neverDigest: true,\n reviewable: false,\n includeSiteManagers: false,\n helpAnchor: 'marketplace-payouts',\n owner: {\n title: 'Your marketplace payouts are on an extended schedule',\n summary: 'On {{occurredAt}}, payouts for {{workspace.name}}\\'s marketplace sales moved to an extended schedule.',\n meaning:\n 'Payouts to new publishers wait for a period before they reach your bank. Your sales are recorded as usual and pay out automatically when the period ends.',\n steps: ['Nothing to do. You will get a notice when payouts return to the standard schedule.'],\n actions: ['view-marketplace-sales', 'contact-support'],\n },\n staff: {\n title: 'Publisher payouts on the extended schedule',\n summary: 'Publisher workspace {{workspace.name}} payouts moved to the extended schedule.',\n actions: ['staff-view-workspace'],\n },\n },\n 'publisher-payouts-standard': {\n kind: 'publisher-payouts-standard',\n severity: 'info',\n emailOwners: true,\n alertStaff: false,\n neverDigest: true,\n reviewable: false,\n includeSiteManagers: false,\n helpAnchor: 'marketplace-payouts',\n owner: {\n title: 'Your marketplace payouts are back on the standard schedule',\n summary: 'On {{occurredAt}}, payouts for {{workspace.name}}\\'s marketplace sales returned to the standard schedule.',\n meaning: 'Your sales now pay out on your payout account\\'s standard schedule.',\n steps: ['Nothing to do.'],\n actions: ['view-marketplace-sales'],\n },\n staff: {\n title: 'Publisher payouts back on the standard schedule',\n summary: 'Publisher workspace {{workspace.name}} payouts returned to the standard schedule.',\n actions: ['staff-view-workspace'],\n },\n },\n 'review-cleared': {\n kind: 'review-cleared',\n severity: 'info',\n emailOwners: true,\n alertStaff: false,\n neverDigest: false,\n reviewable: false,\n includeSiteManagers: false,\n helpAnchor: 'requesting-a-review',\n owner: {\n title: 'Our review is complete',\n summary: 'On {{occurredAt}}, our team finished reviewing {{item.label}} and closed it with no action.',\n meaning: 'Nothing on your account changes because of it.',\n steps: ['Nothing to do.'],\n actions: ['view-holds'],\n },\n staff: {\n title: 'Review closed with no action',\n summary: '{{item.label}} on {{workspace.name}} was dismissed. Reference {{reference}}.',\n actions: ['staff-open-row', 'staff-view-workspace'],\n },\n },\n 'review-upheld': {\n kind: 'review-upheld',\n severity: 'warning',\n emailOwners: true,\n alertStaff: false,\n neverDigest: false,\n reviewable: true,\n includeSiteManagers: false,\n helpAnchor: 'requesting-a-review',\n owner: {\n title: 'Our review found a problem',\n summary: 'On {{occurredAt}}, our team finished reviewing {{item.label}} and took action on it.',\n meaning:\n 'You may receive a separate notice about what changed on your account, such as a lock or a canceled subscription.',\n steps: [\n 'Read any other notices you received today; they say what changed.',\n 'If you think the decision is wrong, choose Request a review or contact support with the reference {{reference}}.',\n ],\n actions: ['request-review', 'view-holds', 'contact-support'],\n },\n staff: {\n title: 'Review closed with action',\n summary: '{{item.label}} on {{workspace.name}} was actioned. Reference {{reference}}.',\n actions: ['staff-open-row', 'staff-view-workspace'],\n },\n },\n 'workspace-locked': {\n kind: 'workspace-locked',\n severity: 'urgent',\n emailOwners: true,\n alertStaff: false,\n neverDigest: true,\n reviewable: false,\n includeSiteManagers: false,\n helpAnchor: 'locked',\n owner: {\n title: '{{workspace.name}} has been locked',\n summary: 'On {{occurredAt}}, our team locked {{workspace.name}}. The message from our team: {{lock.message}}',\n meaning: 'While it is locked: {{lock.affected}}',\n steps: [\n 'Read the message above; it says what we need from you.',\n CANNOT_SIGN_IN,\n 'Tell us who you are, what the workspace is for, and anything that explains what happened. A person reads every appeal.',\n ],\n actions: ['contact-support'],\n },\n staff: {\n title: 'Workspace locked',\n summary: '{{workspace.name}} was locked on {{occurredAt}}. Affected: {{lock.affected}}',\n actions: ['staff-unlock', 'staff-view-workspace'],\n },\n },\n 'workspace-unlocked': {\n kind: 'workspace-unlocked',\n severity: 'info',\n emailOwners: true,\n alertStaff: false,\n neverDigest: true,\n reviewable: false,\n includeSiteManagers: false,\n helpAnchor: 'locked',\n owner: {\n title: '{{workspace.name}} has been restored',\n summary: 'On {{occurredAt}}, our team lifted the lock on {{workspace.name}}.',\n meaning: 'What happens now: {{lock.affected}}',\n steps: [\n 'Sign in again; sessions that were signed out stay signed out.',\n 'Check your sites, scheduled sends and billing. Anything that was canceled stays canceled until you restart it.',\n ],\n actions: ['view-holds', 'view-billing', 'contact-support'],\n },\n staff: {\n title: 'Workspace lock lifted',\n summary: '{{workspace.name}} was unlocked on {{occurredAt}}.',\n actions: ['staff-view-workspace'],\n },\n },\n 'site-locked': {\n kind: 'site-locked',\n severity: 'urgent',\n emailOwners: true,\n alertStaff: false,\n neverDigest: true,\n reviewable: false,\n includeSiteManagers: true,\n helpAnchor: 'locked',\n owner: {\n title: 'A site on {{workspace.name}} has been locked',\n summary: 'On {{occurredAt}}, our team locked {{item.label}}. The message from our team: {{lock.message}}',\n meaning: 'While it is locked: {{lock.affected}}',\n steps: [\n 'Read the message above; it says what we need from you.',\n 'Contact support to appeal, with the reference {{reference}}. A person reads every appeal.',\n CANNOT_SIGN_IN,\n ],\n actions: ['contact-support', 'view-holds'],\n },\n staff: {\n title: 'Site locked',\n summary: '{{item.label}} of {{workspace.name}} was locked on {{occurredAt}}. Affected: {{lock.affected}}',\n actions: ['staff-unlock', 'staff-view-workspace'],\n },\n },\n 'site-unlocked': {\n kind: 'site-unlocked',\n severity: 'info',\n emailOwners: true,\n alertStaff: false,\n neverDigest: true,\n reviewable: false,\n includeSiteManagers: true,\n helpAnchor: 'locked',\n owner: {\n title: 'A site on {{workspace.name}} has been restored',\n summary: 'On {{occurredAt}}, our team lifted the lock on {{item.label}}.',\n meaning: 'What happens now: {{lock.affected}}',\n steps: ['Open the site and check that its pages, forms and checkout work as you expect.'],\n actions: ['view-details', 'contact-support'],\n },\n staff: {\n title: 'Site lock lifted',\n summary: '{{item.label}} of {{workspace.name}} was unlocked on {{occurredAt}}.',\n actions: ['staff-view-workspace'],\n },\n },\n 'domain-locked': {\n kind: 'domain-locked',\n severity: 'urgent',\n emailOwners: true,\n alertStaff: false,\n neverDigest: true,\n reviewable: false,\n includeSiteManagers: true,\n helpAnchor: 'locked',\n owner: {\n title: 'A domain on {{workspace.name}} has been locked',\n summary: 'On {{occurredAt}}, our team locked {{item.label}}. The message from our team: {{lock.message}}',\n meaning: 'While it is locked: {{lock.affected}}',\n steps: [\n 'Read the message above; it says what we need from you.',\n 'Contact support to appeal, with the reference {{reference}}.',\n ],\n actions: ['contact-support'],\n },\n staff: {\n title: 'Domain locked',\n summary: '{{item.label}} of {{workspace.name}} was locked on {{occurredAt}}.',\n actions: ['staff-unlock', 'staff-view-workspace'],\n },\n },\n 'domain-unlocked': {\n kind: 'domain-unlocked',\n severity: 'info',\n emailOwners: true,\n alertStaff: false,\n neverDigest: true,\n reviewable: false,\n includeSiteManagers: true,\n helpAnchor: 'locked',\n owner: {\n title: 'A domain on {{workspace.name}} has been restored',\n summary: 'On {{occurredAt}}, our team lifted the lock on {{item.label}}.',\n meaning: 'What happens now: {{lock.affected}}',\n steps: ['Visit the domain to confirm it serves your site again.'],\n actions: ['contact-support'],\n },\n staff: {\n title: 'Domain lock lifted',\n summary: '{{item.label}} of {{workspace.name}} was unlocked on {{occurredAt}}.',\n actions: ['staff-view-workspace'],\n },\n },\n 'account-locked': {\n kind: 'account-locked',\n severity: 'urgent',\n emailOwners: true,\n alertStaff: false,\n neverDigest: true,\n reviewable: false,\n includeSiteManagers: false,\n helpAnchor: 'locked',\n owner: {\n title: 'Your account has been locked',\n summary: 'On {{occurredAt}}, our team locked your {{brand.productName}} account. The message from our team: {{lock.message}}',\n meaning: 'While it is locked: {{lock.affected}}',\n steps: [\n 'Read the message above; it says what we need from you.',\n APPEAL_BY_REPLY,\n ],\n actions: ['contact-support'],\n },\n staff: {\n title: 'Account locked',\n summary: 'An account was locked on {{occurredAt}}. Affected: {{lock.affected}}',\n actions: ['staff-unlock'],\n },\n },\n 'account-unlocked': {\n kind: 'account-unlocked',\n severity: 'info',\n emailOwners: true,\n alertStaff: false,\n neverDigest: true,\n reviewable: false,\n includeSiteManagers: false,\n helpAnchor: 'locked',\n owner: {\n title: 'Your account has been restored',\n summary: 'On {{occurredAt}}, our team lifted the lock on your {{brand.productName}} account.',\n meaning: 'What happens now: {{lock.affected}}',\n steps: ['Sign in again. If anything still does not work, contact support.'],\n actions: ['contact-support'],\n },\n staff: {\n title: 'Account lock lifted',\n summary: 'An account was unlocked on {{occurredAt}}.',\n actions: ['staff-unlock'],\n },\n },\n 'feature-locked': {\n kind: 'feature-locked',\n severity: 'warning',\n emailOwners: true,\n alertStaff: false,\n neverDigest: true,\n reviewable: false,\n includeSiteManagers: false,\n helpAnchor: 'locked',\n owner: {\n title: '{{item.label}} is paused on {{workspace.name}}',\n summary: 'On {{occurredAt}}, our team paused {{item.label}} for {{workspace.name}}. The message from our team: {{lock.message}}',\n meaning: 'Everything else on your workspace keeps working. {{lock.affected}}',\n steps: [\n 'Read the message above; it says what we need from you.',\n 'Contact support with the reference {{reference}} if you have questions.',\n ],\n actions: ['contact-support'],\n },\n staff: {\n title: 'Workspace feature paused',\n summary: '{{item.label}} was paused for {{workspace.name}} on {{occurredAt}}.',\n actions: ['staff-unlock', 'staff-view-workspace'],\n },\n },\n 'feature-unlocked': {\n kind: 'feature-unlocked',\n severity: 'info',\n emailOwners: true,\n alertStaff: false,\n neverDigest: true,\n reviewable: false,\n includeSiteManagers: false,\n helpAnchor: 'locked',\n owner: {\n title: '{{item.label}} is back on {{workspace.name}}',\n summary: 'On {{occurredAt}}, our team turned {{item.label}} back on for {{workspace.name}}.',\n meaning: 'What happens now: {{lock.affected}}',\n steps: ['Nothing to do.'],\n actions: ['contact-support'],\n },\n staff: {\n title: 'Workspace feature restored',\n summary: '{{item.label}} was restored for {{workspace.name}} on {{occurredAt}}.',\n actions: ['staff-view-workspace'],\n },\n },\n 'subscription-canceled': {\n kind: 'subscription-canceled',\n severity: 'urgent',\n emailOwners: true,\n alertStaff: false,\n neverDigest: true,\n reviewable: false,\n includeSiteManagers: false,\n helpAnchor: 'subscription-canceled',\n owner: {\n title: 'The subscription for {{workspace.name}} was canceled',\n summary: 'On {{occurredAt}}, our team canceled the subscription for {{workspace.name}}.',\n meaning: 'What changes: {{lock.affected}}',\n steps: [\n 'Export anything you need from your workspace.',\n 'Contact support if you believe this was a mistake, with the reference {{reference}}.',\n ],\n actions: ['view-billing', 'contact-support'],\n },\n staff: {\n title: 'Subscription canceled by staff',\n summary: 'The subscription for {{workspace.name}} was canceled on {{occurredAt}}.',\n actions: ['staff-cancel-subscription', 'staff-view-workspace'],\n },\n },\n}\n\n/**\n * The kinds whose email wears the WORKSPACE's brand: routine notices about\n * the workspace's own customers' payments, which read as the workspace's own\n * operations. Every other kind — a hold, a lock, a lift, a cancellation, a\n * review — is the platform acting, and is sent in the platform's brand.\n */\nexport const RISK_NOTICE_WORKSPACE_BRANDED: ReadonlySet<RiskEventKind> = new Set<RiskEventKind>([\n 'sale-fraud-warning',\n 'sale-payment-review',\n 'sale-dispute',\n 'gift-card-hold',\n 'marketplace-sale-warning',\n])\n\n/**\n * The kinds that CLOSE another: a release, a rejection, a review's end. A\n * closing notice never re-stamps its row, so the row keeps naming the kind\n * it was opened as — and a second decision closes it again correctly.\n */\nexport const RISK_NOTICE_CLOSING_KINDS: ReadonlySet<RiskEventKind> = new Set<RiskEventKind>(\n Object.values(RISK_NOTICE_CATALOG).flatMap((definition) =>\n definition.closesWith ? [definition.closesWith.released, definition.closesWith.rejected] : [],\n ),\n)\n\n/** The catalog entry for a kind. */\nexport function riskNoticeDefinition(kind: RiskEventKind): RiskNoticeDefinition {\n return RISK_NOTICE_CATALOG[kind]\n}\n\nexport function isRiskEventKind(value: unknown): value is RiskEventKind {\n return typeof value === 'string' && (RISK_EVENT_KINDS as readonly string[]).includes(value)\n}\n\n/** The values a notice is rendered with. Every value is optional. */\nexport type RiskNoticeValues = Partial<Record<RiskNoticeToken | 'brand.productName', string | null | undefined>>\n\n/**\n * What a token renders as when the event did not carry it: always words, so\n * a sentence never has a hole in it.\n */\nconst TOKEN_FALLBACKS: Partial<Record<RiskNoticeToken | 'brand.productName', string>> = {\n 'workspace.name': 'your workspace',\n 'item.label': 'an item on your workspace',\n occurredAt: 'a recent date',\n reference: 'shown on this notice',\n 'support.email': 'our support team',\n 'lock.message': 'None was given.',\n 'lock.affected': 'see your account for details.',\n amount: 'an amount shown on the payment',\n 'evidence.dueBy': 'the date shown on the order',\n 'staff.evidence': '',\n 'brand.productName': 'the platform',\n}\n\n/** Fill `{{token}}`s from `values`, falling back to words, never to a hole. */\nexport function renderRiskNoticeText(template: string, values: RiskNoticeValues): string {\n return template\n .replace(/\\{\\{\\s*([\\w.]+)\\s*\\}\\}/g, (_match, token: string) => {\n const value = values[token as RiskNoticeToken]\n if (typeof value === 'string' && value.trim()) return value.trim()\n return TOKEN_FALLBACKS[token as RiskNoticeToken] ?? ''\n })\n .replace(/[ \\t]{2,}/g, ' ')\n .trim()\n}\n\n/** The values an action href may use. */\nexport interface RiskActionParams {\n itemPath?: string | null\n noticeId?: string | null\n reviewId?: string | null\n orgId?: string | null\n hostId?: string | null\n stripeUrl?: string | null\n lockScope?: string | null\n lockTargetId?: string | null\n}\n\n/**\n * An action's href with its placeholders filled, or null when one of them\n * has no value — an action that would lead nowhere is not offered.\n */\nexport function resolveRiskActionHref(\n action: RiskActionDefinition,\n params: RiskActionParams,\n): string | null {\n let missing = false\n const href = action.href.replace(/\\{(\\w+)\\}/g, (_match, name: string) => {\n const value = params[name as keyof RiskActionParams]\n if (typeof value !== 'string' || !value) {\n missing = true\n return ''\n }\n // A whole-path placeholder is taken as written; a path segment or query\n // value is encoded.\n return name === 'itemPath' || name === 'stripeUrl' ? value : encodeURIComponent(value)\n })\n if (missing) return null\n if (href.startsWith('https://')) return href\n return href.startsWith('/') ? href : null\n}\n\n/** One action, ready to render. */\nexport interface ResolvedRiskAction {\n id: RiskOwnerActionId | RiskStaffActionId\n label: string\n hint: string\n href: string\n}\n\n/** A kind's owner actions that lead somewhere, in catalog order. */\nexport function resolveOwnerRiskActions(\n kind: RiskEventKind,\n params: RiskActionParams,\n): ResolvedRiskAction[] {\n return RISK_NOTICE_CATALOG[kind].owner.actions.flatMap((id) => {\n const definition = RISK_OWNER_ACTIONS[id]\n const href = resolveRiskActionHref(definition, params)\n return href ? [{ id, label: definition.label, hint: definition.hint, href }] : []\n })\n}\n\n/** A kind's staff actions that lead somewhere, in catalog order. */\nexport function resolveStaffRiskActions(\n kind: RiskEventKind,\n params: RiskActionParams,\n): ResolvedRiskAction[] {\n return RISK_NOTICE_CATALOG[kind].staff.actions.flatMap((id) => {\n const definition = RISK_STAFF_ACTIONS[id]\n const href = resolveRiskActionHref(definition, params)\n return href ? [{ id, label: definition.label, hint: definition.hint, href }] : []\n })\n}\n\n/** An owner notice as plain text: title, summary, meaning, numbered steps. */\nexport function renderOwnerRiskNotice(\n kind: RiskEventKind,\n values: RiskNoticeValues,\n): { title: string; summary: string; meaning: string; steps: string[] } {\n const copy = RISK_NOTICE_CATALOG[kind].owner\n return {\n title: renderRiskNoticeText(copy.title, values),\n summary: renderRiskNoticeText(copy.summary, values),\n meaning: renderRiskNoticeText(copy.meaning, values),\n steps: copy.steps.map((step) => renderRiskNoticeText(step, values)),\n }\n}\n\n/** A staff alert as plain text. */\nexport function renderStaffRiskNotice(\n kind: RiskEventKind,\n values: RiskNoticeValues,\n): { title: string; summary: string } {\n const copy = RISK_NOTICE_CATALOG[kind].staff\n return {\n title: renderRiskNoticeText(copy.title, values),\n summary: renderRiskNoticeText(copy.summary, values),\n }\n}\n\n/** The System emails catalog key a kind's owner email is designed under. */\nexport function riskNoticeEmailKey(kind: RiskEventKind): string {\n return `risk-${kind}`\n}\n\n/** The key the burst digest is designed under. */\nexport const RISK_NOTICE_DIGEST_EMAIL_KEY = 'risk-digest'\n\n/**\n * The kind an abuse-queue row is about, so the staff queue renders the\n * catalog's text for EVERY row a risk source filed — including rows filed\n * before the row carried its notice. The stamp `notifyRiskEvent` writes\n * (`riskNotice.kind`) wins; otherwise the row's own shape says it. Null for\n * a row from the public report form, which is not a risk event.\n */\nexport function riskKindForAbuseRow(row: {\n source?: unknown\n heldSend?: { kind?: unknown } | null\n riskNotice?: { kind?: unknown } | null\n}): RiskEventKind | null {\n const stamped = row.riskNotice?.kind\n if (isRiskEventKind(stamped)) return stamped\n switch (row.source) {\n case 'outbound-screen':\n if (row.heldSend?.kind === 'page') return 'page-held'\n if (row.heldSend?.kind === 'listing') return 'listing-held'\n return row.heldSend ? 'email-held' : 'domain-flagged'\n case 'stripe-fraud-signal':\n return 'billing-payment-flagged'\n case 'stripe-seller-fraud-pattern':\n return 'seller-review'\n case 'marketplace-sale-risk':\n return 'marketplace-sale-review'\n case 'payment-velocity':\n return 'card-testing'\n default:\n return null\n }\n}\n"],"names":["RISK_EVENT_KINDS","RISK_OWNER_ACTION_IDS","RISK_STAFF_ACTION_IDS","RISK_OWNER_ACTIONS","label","hint","href","RISK_STAFF_ACTIONS","RISK_NOTICE_HELP_PATH","RISK_NOTICE_TOKENS","occurredAt","reference","amount","ASK_FOR_REVIEW","REVIEW_TIME","CANNOT_SIGN_IN","APPEAL_BY_REPLY","RISK_NOTICE_CATALOG","kind","severity","emailOwners","alertStaff","neverDigest","reviewable","closesWith","released","rejected","includeSiteManagers","helpAnchor","owner","title","summary","meaning","steps","actions","staff","RISK_NOTICE_WORKSPACE_BRANDED","Set","RISK_NOTICE_CLOSING_KINDS","Object","values","flatMap","definition","riskNoticeDefinition","isRiskEventKind","value","includes","TOKEN_FALLBACKS","renderRiskNoticeText","template","replace","_match","token","trim","resolveRiskActionHref","action","params","missing","name","encodeURIComponent","startsWith","resolveOwnerRiskActions","id","resolveStaffRiskActions","renderOwnerRiskNotice","copy","map","step","renderStaffRiskNotice","riskNoticeEmailKey","RISK_NOTICE_DIGEST_EMAIL_KEY","riskKindForAbuseRow","row","stamped","riskNotice","source","heldSend"],"mappings":"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;QACZC,YAAY;YAAEC,UAAU;YAAkBC,UAAU;QAAiB;QACrEC,qBAAqB;QACrBC,YAAY;QACZC,OAAO;YACLC,OAAO;YACPC,SACE;YACFC,SACE;YACFC,OAAO;gBACL;gBACA;gBACApB;gBACAC;aACD;YACDoB,SAAS;gBAAC;gBAAgB;gBAAqB;gBAAkB;aAAkB;QACrF;QACAC,OAAO;YACLL,OAAO;YACPC,SACE;YACFG,SAAS;gBACP;gBACA;gBACA;gBACA;gBACA;aACD;QACH;IACF;IACA,kBAAkB;QAChBhB,MAAM;QACNC,UAAU;QACVC,aAAa;QACbC,YAAY;QACZC,aAAa;QACbC,YAAY;QACZI,qBAAqB;QACrBC,YAAY;QACZC,OAAO;YACLC,OAAO;YACPC,SAAS;YACTC,SACE;YACFC,OAAO;gBACL;aACD;YACDC,SAAS;gBAAC;aAAe;QAC3B;QACAC,OAAO;YACLL,OAAO;YACPC,SAAS;YACTG,SAAS;gBAAC;gBAAkB;aAAuB;QACrD;IACF;IACA,kBAAkB;QAChBhB,MAAM;QACNC,UAAU;QACVC,aAAa;QACbC,YAAY;QACZC,aAAa;QACbC,YAAY;QACZI,qBAAqB;QACrBC,YAAY;QACZC,OAAO;YACLC,OAAO;YACPC,SAAS;YACTC,SACE;YACFC,OAAO;gBACL;gBACA;aACD;YACDC,SAAS;gBAAC;gBAAgB;gBAAkB;aAAkB;QAChE;QACAC,OAAO;YACLL,OAAO;YACPC,SAAS;YACTG,SAAS;gBAAC;gBAAkB;gBAAwB;aAAuB;QAC7E;IACF;IACA,aAAa;QACXhB,MAAM;QACNC,UAAU;QACVC,aAAa;QACbC,YAAY;QACZC,aAAa;QACbC,YAAY;QACZC,YAAY;YAAEC,UAAU;YAAiBC,UAAU;QAAgB;QACnEC,qBAAqB;QACrBC,YAAY;QACZC,OAAO;YACLC,OAAO;YACPC,SACE;YACFC,SACE;YACFC,OAAO;gBACL;gBACA;gBACApB;gBACAC;aACD;YACDoB,SAAS;gBAAC;gBAAgB;gBAAqB;gBAAkB;aAAkB;QACrF;QACAC,OAAO;YACLL,OAAO;YACPC,SACE;YACFG,SAAS;gBACP;gBACA;gBACA;gBACA;gBACA;gBACA;aACD;QACH;IACF;IACA,iBAAiB;QACfhB,MAAM;QACNC,UAAU;QACVC,aAAa;QACbC,YAAY;QACZC,aAAa;QACbC,YAAY;QACZI,qBAAqB;QACrBC,YAAY;QACZC,OAAO;YACLC,OAAO;YACPC,SAAS;YACTC,SAAS;YACTC,OAAO;gBAAC;aAA0D;YAClEC,SAAS;gBAAC;aAAe;QAC3B;QACAC,OAAO;YACLL,OAAO;YACPC,SAAS;YACTG,SAAS;gBAAC;gBAAkB;aAAuB;QACrD;IACF;IACA,gBAAgB;QACdhB,MAAM;QACNC,UAAU;QACVC,aAAa;QACbC,YAAY;QACZC,aAAa;QACbC,YAAY;QACZC,YAAY;YAAEC,UAAU;YAAkBC,UAAU;QAAgB;QACpEC,qBAAqB;QACrBC,YAAY;QACZC,OAAO;YACLC,OAAO;YACPC,SACE;YACFC,SACE;YACFC,OAAO;gBACL;gBACA;gBACApB;gBACAC;aACD;YACDoB,SAAS;gBAAC;gBAAgB;gBAAqB;gBAAkB;aAAkB;QACrF;QACAC,OAAO;YACLL,OAAO;YACPC,SACE;YACFG,SAAS;gBACP;gBACA;gBACA;gBACA;gBACA;aACD;QACH;IACF;IACA,iBAAiB;QACfhB,MAAM;QACNC,UAAU;QACVC,aAAa;QACbC,YAAY;QACZC,aAAa;QACbC,YAAY;QACZI,qBAAqB;QACrBC,YAAY;QACZC,OAAO;YACLC,OAAO;YACPC,SAAS;YACTC,SACE;YACFC,OAAO;gBACL;gBACA;aACD;YACDC,SAAS;gBAAC;gBAAqB;gBAAkB;aAAkB;QACrE;QACAC,OAAO;YACLL,OAAO;YACPC,SAAS;YACTG,SAAS;gBAAC;gBAAkB;gBAAmB;aAAuB;QACxE;IACF;IACA,gBAAgB;QACdhB,MAAM;QACNC,UAAU;QACVC,aAAa;QACbC,YAAY;QACZC,aAAa;QACbC,YAAY;QACZC,YAAY;YAAEC,UAAU;YAAoBC,UAAU;QAAmB;QACzEC,qBAAqB;QACrBC,YAAY;QACZC,OAAO;YACLC,OAAO;YACPC,SACE;YACFC,SAAS;YACTC,OAAO;gBACL;gBAC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;QACNC,UAAU;QACVC,aAAa;QACbC,YAAY;QACZC,aAAa;QACbC,YAAY;QACZC,YAAY;YAAEC,UAAU;YAAkBC,UAAU;QAAgB;QACpEC,qBAAqB;QACrBC,YAAY;QACZC,OAAO;YACLC,OAAO;YACPC,SACE;YACFC,SACE;YACFC,OAAO;gBACL;gBACA;gBACA;aACD;YACDC,SAAS;gBAAC;gBAAgB;gBAAkB;aAAkB;QAChE;QACAC,OAAO;YACLL,OAAO;YACPC,SACE;YACFG,SAAS;gBACP;gBACA;gBACA;gBACA;gBACA;gBACA;aACD;QACH;IACF;IACA,kBAAkB;QAChBhB,MAAM;QACNC,UAAU;QACVC,aAAa;QACbC,YAAY;QACZC,aAAa;QACbC,YAAY;QACZI,qBAAqB;QACrBC,YAAY;QACZC,OAAO;YACLC,OAAO;YACPC,SACE;YACFC,SACE;YACFC,OAAO;gBACL;gBACA;gBACA;aACD;YACDC,SAAS;gBAAC;gBAAsB;gBAAgB;aAAkB;QACpE;QACAC,OAAO;YACLL,OAAO;YACPC,SAAS;YACTG,SAAS;gBAAC;gBAAwB;aAAkB;QACtD;IACF;IACA,sBAAsB;QACpBhB,MAAM;QACNC,UAAU;QACVC,aAAa;QACbC,YAAY;QACZC,aAAa;QACbC,YAAY;QACZI,qBAAqB;QACrBC,YAAY;QACZC,OAAO;YACLC,OAAO;YACPC,SACE;YACFC,SACE;YACFC,OAAO;gBACL;gBACA;gBACA;aACD;YACDC,SAAS;gBAAC;gBAAgB;gBAAc;gBAAgB;aAAkB;QAC5E;QACAC,OAAO;YACLL,OAAO;YACPC,SAAS;YACTG,SAAS;gBAAC;gBAAqB;gBAAwB;aAAkB;QAC3E;IACF;IACA,uBAAuB;QACrBhB,MAAM;QACNC,UAAU;QACVC,aAAa;QACbC,YAAY;QACZC,aAAa;QACbC,YAAY;QACZI,qBAAqB;QACrBC,YAAY;QACZC,OAAO;YACLC,OAAO;YACPC,SAAS;YACTC,SACE;YACFC,OAAO;gBACL;gBACA;gBACA;aACD;YACDC,SAAS;gBAAC;gBAAgB;gBAAc;gBAAgB;aAAkB;QAC5E;QACAC,OAAO;YACLL,OAAO;YACPC,SAAS;YACTG,SAAS;gBAAC;gBAAqB;aAAuB;QACxD;IACF;IACA,gBAAgB;QACdhB,MAAM;QACNC,UAAU;QACVC,aAAa;QACbC,YAAY;QACZC,aAAa;QACbC,YAAY;QACZI,qBAAqB;QACrBC,YAAY;QACZC,OAAO;YACLC,OAAO;YACPC,SACE;YACFC,SACE;YACFC,OAAO;gBACL;gBACA;gBACA;aACD;YACDC,SAAS;gBAAC;gBAAkB;gBAAgB;aAAkB;QAChE;QACAC,OAAO;YACLL,OAAO;YACPC,SAAS;YACTG,SAAS;gBAAC;gBAAqB;aAAuB;QACxD;IACF;IACA,iBAAiB;QACfhB,MAAM;QACNC,UAAU;QACVC,aAAa;QACbC,YAAY;QACZC,aAAa;QACbC,YAAY;QACZC,YAAY;YAAEC,UAAU;YAAkBC,UAAU;QAAgB;QACpEC,qBAAqB;QACrBC,YAAY;QACZC,OAAO;YACLC,OAAO;YACPC,SACE;YACFC,SACE;YACFC,OAAO;gBACL;gBACA;gBACAnB;aACD;YACDoB,SAAS;gBAAC;gBAAkB;gBAAc;aAAkB;QAC9D;QACAC,OAAO;YACLL,OAAO;YACPC,SACE;YACFG,SAAS;gBACP;gBACA;gBACA;gBACA;gBACA;gBACA;aACD;QACH;IACF;IACA,2BAA2B;QACzBhB,MAAM;QACNC,UAAU;QACVC,aAAa;QACbC,YAAY;QACZC,aAAa;QACbC,YAAY;QACZC,YAAY;YAAEC,UAAU;YAAkBC,UAAU;QAAgB;QACpEC,qBAAqB;QACrBC,YAAY;QACZC,OAAO;YACLC,OAAO;YACPC,SAAS;YACTC,SACE;YACFC,OAAO;gBACL;gBACA;gBACAnB;aACD;YACDoB,SAAS;gBAAC;gBAAkB;gBAA0B;aAAkB;QAC1E;QACAC,OAAO;YACLL,OAAO;YACPC,SACE;YACFG,SAAS;gBACP;gBACA;gBACA;gBACA;gBACA;gBACA;aACD;QACH;IACF;IACA,4BAA4B;QAC1BhB,MAAM;QACNC,UAAU;QACVC,aAAa;QACbC,YAAY;QACZC,aAAa;QACbC,YAAY;QACZI,qBAAqB;QACrBC,YAAY;QACZC,OAAO;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"}
|
package/src/lib/send-email.d.ts
CHANGED
|
@@ -89,6 +89,13 @@ export interface SendEmailOptions {
|
|
|
89
89
|
* sweeps the tenant-owned trees so the omission cannot be an accident there.
|
|
90
90
|
*/
|
|
91
91
|
audience?: SendingIdentityAudience;
|
|
92
|
+
/**
|
|
93
|
+
* Delivers to an address whose account is BANNED (AGL-3420). A banned
|
|
94
|
+
* account is sent nothing after its ban notice, by any sender; the lock
|
|
95
|
+
* and lift notices themselves are the only mail that states this, and no
|
|
96
|
+
* other sender may.
|
|
97
|
+
*/
|
|
98
|
+
accountBanExempt?: boolean;
|
|
92
99
|
/**
|
|
93
100
|
* Short label for logs, e.g. `'invite'` or `'usage-summary'`. Makes a
|
|
94
101
|
* failure in the runtime logs traceable to the feature that caused it.
|
package/src/lib/send-email.js
CHANGED
|
@@ -417,14 +417,16 @@ function normalizeRecipients(to) {
|
|
|
417
417
|
* to the rest: one colleague's dead address must not stop the others'
|
|
418
418
|
* notification.
|
|
419
419
|
*/ const sendingAddress = bareSenderAddress(from);
|
|
420
|
-
const preflight = await askDeliverabilityPreflight({
|
|
420
|
+
const preflight = await askDeliverabilityPreflight(_extends({
|
|
421
421
|
recipients: to.map((address)=>address.toLowerCase()),
|
|
422
422
|
purpose: emailSendPurpose(options),
|
|
423
423
|
sendingDomain: sendingAddress ? sendingAddress.slice(sendingAddress.lastIndexOf('@') + 1) : null,
|
|
424
424
|
sendingSource: (_ref1 = (_options_sendingIdentity1 = options.sendingIdentity) == null ? void 0 : _options_sendingIdentity1.source) != null ? _ref1 : 'platform',
|
|
425
425
|
context: options.context,
|
|
426
426
|
hostId: (_ref2 = (_options_marketing = options.marketing) == null ? void 0 : _options_marketing.hostId) != null ? _ref2 : null
|
|
427
|
-
},
|
|
427
|
+
}, options.accountBanExempt ? {
|
|
428
|
+
accountBanExempt: true
|
|
429
|
+
} : {}), label);
|
|
428
430
|
if (preflight && (((_preflight_refused = preflight.refused) == null ? void 0 : _preflight_refused.length) || ((_preflight_held = preflight.held) == null ? void 0 : _preflight_held.length))) {
|
|
429
431
|
var _preflight_refused1, _preflight_held1;
|
|
430
432
|
const stopped = new Set([
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/send-email.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The Resend send endpoint. Every outbound application email in Aglyn goes\n * through here — invites, receipts, usage summaries, campaigns, staff alerts.\n *\n * Auth email (verification, password reset) is Firebase's job and does NOT\n * come through this module.\n */\nimport {\n type EmailSendPriority,\n emailSendRateWindowStartMs,\n getEmailSendGovernor,\n isRefusablePriority,\n resolveSendPriority,\n} from './send-rate'\nimport { renderTextEmailHtml } from './text-email-html'\nimport {\n sendingIdentityRefusal,\n pooledMarketingRefusal,\n type SendingIdentityAudience,\n type SendingIdentityVerdict,\n} from './sending-domain'\nimport {\n appendUnsubscribeHtml,\n appendUnsubscribeText,\n getMarketingSendGate,\n isMarketingMessage,\n unsubscribeHeaders,\n type MarketingSendContext,\n} from './marketing-send'\nimport {\n EMAIL_DELIVERABILITY_PREFLIGHT_TIMEOUT_MS,\n getEmailDeliverabilityPreflight,\n type EmailDeliverabilityPreflightVerdict,\n type EmailSendPurpose,\n} from './email-deliverability'\nimport { bareSenderAddress } from './email-delivery-events'\nimport { screenTenantMessage } from './outbound-screen-gate'\n\nexport const RESEND_SEND_ENDPOINT = 'https://api.resend.com/emails'\n\n/** A Resend delivery tag, used for webhook attribution (AGL-268). */\nexport interface EmailTag {\n name: string\n value: string\n}\n\nexport interface SendEmailOptions {\n /** One or more recipient addresses. */\n to: string | string[]\n subject: string\n /** Plain-text body. Supply at least one of `text` or `html`. */\n text?: string\n /**\n * HTML body. Supply at least one of `text` or `html`.\n *\n * Omitted, one is synthesized from `text` so the message always carries an\n * HTML part — a text-only message has no anchors, so its links are inert in\n * the inbox and Resend's click tracking has nothing to rewrite. See\n * `text-email-html.ts`.\n */\n html?: string\n /** Extra MIME headers, e.g. `List-Unsubscribe`. */\n headers?: Record<string, string>\n /** Delivery tags for the opens/clicks webhook. */\n tags?: EmailTag[]\n replyTo?: string | string[]\n /*\n * THERE IS NO `from`.\n *\n * There was: a raw override of the configured sender, subordinate to a\n * resolved identity but winning over everything else, verified against\n * nothing. Every address this function can send from now comes from one of\n * exactly two places — the deployment's own `USAGE_EMAIL_FROM`, or a\n * {@link SendingIdentityVerdict} the server resolved from a document — and\n * neither is reachable from a request body.\n *\n * Deleting it rather than guarding it is what makes that a property instead\n * of a habit. A guard would have to be written correctly at each of the\n * ninety-odd call sites, or once here and then trusted; an option that does\n * not exist cannot be passed by the next sender, and `resolveSendingIdentity`\n * becomes the whole of the answer to \"as whom does this leave\".\n *\n * The resolution below reads no `from` from `options` either, so this is not\n * only a compile-time close. Marketplace plugin bundles reach `sendEmail` as\n * JavaScript and are typechecked against nothing.\n */\n /**\n * White-label display name for the sender (White-Label Phase 1). Replaces\n * only the display name in front of the verified address — the address\n * itself is never taken from the caller, so this cannot forge a different\n * sender. Callers pass `resolveBrandingProfile(org).fromName` here so an\n * agency's mail reads as their brand instead of \"Aglyn\".\n */\n fromName?: string\n /**\n * The server-resolved sending identity for this message, from\n * `resolveSendingIdentity`.\n *\n * Supplied, it decides the address and it may refuse the send outright —\n * `fromName` is subordinate to it, because a verdict is the answer to \"may\n * this leave, and as whom\" and a display name is not. Omitted, every\n * existing caller keeps the behavior it had: the configured platform\n * identity with an optional display name.\n *\n * Callers resolve it from the ORG DOCUMENT, never from request input. An\n * address assembled from a request body is a `From:` override wearing a new\n * name, and the invariant `applyFromName` exists to hold is that the\n * address cannot move off a verified identity.\n */\n sendingIdentity?: SendingIdentityVerdict | null\n /**\n * Whose mail this is — see {@link SendingIdentityAudience}.\n *\n * `tenant` says the message belongs to a SITE, and it makes the platform\n * sender unreachable: a tenant message with no resolved identity is refused\n * rather than sent from `aglyn.com`. Pair it with `sendingIdentity` from\n * `hostSendingIdentity(hostId)` and the ordinary path is unchanged; the flag\n * is what decides the behavior when that resolution is missing or refuses.\n *\n * Omitted, a send is platform mail and keeps the configured sender, because\n * that is what the console's own senders are. `email-audience-coverage.spec`\n * sweeps the tenant-owned trees so the omission cannot be an accident there.\n */\n audience?: SendingIdentityAudience\n /**\n * Short label for logs, e.g. `'invite'` or `'usage-summary'`. Makes a\n * failure in the runtime logs traceable to the feature that caused it.\n *\n * Since AGL-2407 it is also stamped as a Resend `context` TAG on every\n * send — see `contextTag` below.\n */\n context?: string\n /**\n * What the platform send-rate governor is allowed to do to this message\n * (AGL-2409). Omitted, it is derived from `context`: `'campaign'` is a\n * campaign and everything else is transactional, so no existing caller\n * changes and the default is the one that can never be refused.\n *\n * Set it to `'bulk'` ONLY from a resumable sweep — a cron that leaves its\n * subject unstamped and picks it up on the next run. A refusal for a bulk\n * send means \"not this hour\", and a caller that cannot come back would turn\n * that into a message nobody ever gets.\n */\n priority?: EmailSendPriority\n /**\n * Declares this message as MARKETING mail for one site's audience — see\n * `marketing-send.ts` for what that means and why it is one seam.\n *\n * Set it and the message gains, in one place, the three things marketing\n * mail owes: the RFC 8058 unsubscribe header pair plus a visible opt-out\n * link, a check against both suppression lists, and a ceiling on how much\n * one person receives from one site.\n *\n * ONE RECIPIENT. An unsubscribe link is an HMAC over the address it belongs\n * to, and a suppression verdict is about one person — so a marketing send\n * addressed to a list would carry the wrong link for everybody after the\n * first, and would ask the gate about one of them. Callers fan out.\n */\n marketing?: MarketingSendContext\n /**\n * What the RECIPIENT did that makes this message owed to them (AGL-3356):\n * `order` — a receipt, a gift card, a shipping notice for something they\n * bought; `booking` — a confirmation, reminder or change to something they\n * booked; `account` — a password reset or sign-in link they asked for.\n *\n * The outbound phishing screen's SOFT rules never hold an owed message; a\n * lookalike link still does, because a receipt that links to a brand's\n * disguise is the disguise. Omitted — the default, and the one a forgotten\n * caller gets — the message is treated as merchant-authored and every\n * tier applies. A MARKETING message is never owed, whatever it declares.\n */\n owedFor?: 'order' | 'booking' | 'account'\n /**\n * A review row that the caller's own screen held and staff RELEASED for\n * exactly this content — a campaign put back on the clock, an automation\n * step whose hold was dismissed. The send seam's screen honors it only\n * after confirming, through the installed gate, that it is a release for\n * this site; naming a row is not a way past the screen.\n */\n releasedReviewId?: string | null\n}\n\n/**\n * The `context` tag, attached to every send (AGL-2407).\n *\n * ## Why this is here and not at 37 call sites\n *\n * Until now `tags` were set by exactly one sender, `campaign-send.ts`, which\n * stamps `hostId` and `campaignId` for the opens/clicks webhook. Everything\n * else went out with NO tags at all, so a bounce on an invite, a password\n * reset, a receipt or a usage summary reached the webhook carrying nothing to\n * identify it, and was dropped.\n *\n * The obvious fix — thread an identifier through every call site — asks 37\n * places to remember, which is the shape that produces the 38th that does\n * not. But `context` is ALREADY threaded through 35 of the 37 for logging,\n * and it is exactly the right value: it names the sender. So the tag is\n * derived here, once, and no caller changes.\n *\n * Resend tag values are restricted to ASCII letters, digits, `_` and `-`;\n * anything else is rejected and would fail the whole send. Every `context` in\n * the tree is already a plain slug, but this is mail delivery — a value that\n * makes the send fail is far worse than a value that is sanitised — so the\n * label is normalised rather than trusted, and a context that sanitises to\n * nothing yields no tag rather than an invalid one.\n */\nexport function contextTag(context: string | undefined): EmailTag[] {\n const value = String(context ?? '')\n .trim()\n .replace(/[^A-Za-z0-9_-]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 64)\n return value ? [{ name: 'context', value }] : []\n}\n\n/**\n * Why a send did not happen. `unconfigured` and `no-recipient` mean nothing\n * was attempted; `rejected` and `network` mean Resend was called and failed;\n * `rate-limited` is either, and says so in `status`.\n *\n * `rate-limited` (AGL-2409) and `frequency-capped` are the two a caller may\n * reasonably retry unchanged — see {@link isDeferrableSendResult}, which is\n * where that distinction is made once rather than at each sweep.\n */\nexport type SendEmailFailureReason =\n | 'unconfigured'\n | 'no-recipient'\n /**\n * Resend answered and would not take this message.\n *\n * Per-message and terminal: a malformed payload, an address the provider\n * will not accept, a tag it rejected. A caller that retries it unchanged\n * gets the same answer, so a batch settles the recipient rather than\n * holding a slot open for them.\n *\n * A 429 is deliberately NOT this. See `rate-limited`.\n */\n | 'rejected'\n | 'network'\n /**\n * A refusal that is about the RATE, not about this message — so the message\n * is intact and a later attempt sends it.\n *\n * Two sources, which is why it is one value. The platform hourly governor\n * refuses before the network and nothing is attempted (AGL-2409). Resend\n * answers `429` on the wire when requests arrive faster than it accepts\n * them, and `status` is 429 in that case.\n *\n * The provider arm reports here rather than as `rejected` because of what\n * the two mean to a batch. A `rejected` recipient is settled and never\n * addressed again, which is right for an address the provider will not take\n * and wrong for every recipient a 429 touches: nothing about them was\n * refused, the request was simply too soon. Classified as `rejected` a\n * single rate-limited burst silently deletes the rest of a campaign's\n * audience while the campaign reports itself complete — and the `sent`\n * figure stays honest throughout, so no rate on the report can show it.\n *\n * The provider's quota errors — `daily_quota_exceeded`,\n * `monthly_quota_exceeded` — are also 429 and also land here. That is the\n * right answer for the same reason: none of them is a statement about the\n * recipient, and all of them clear with time.\n */\n | 'rate-limited'\n /**\n * The org selected a custom sending domain and that domain is not verified.\n *\n * Distinct from `unconfigured` because the two need opposite responses: an\n * unconfigured deployment is the operator's to fix, while this is a\n * customer's DNS that is not finished, and the customer is the only person\n * who can finish it. `detail` carries the sentence naming the domain.\n *\n * This is the LAST line of defence, not the visible one. A caller that\n * reaches it has already skipped the check its route should have made, and\n * a refusal seen only here is a log line — which is the shape of the\n * `USAGE_EMAIL_FROM` outage. `performCampaignSend` refuses first, with a\n * `409`, so a person finds out.\n */\n | 'unverified-domain'\n /**\n * A MARKETING send whose recipient is on a suppression list — they\n * unsubscribed from this site, hard-bounced, or pressed \"report spam\".\n *\n * Terminal, and the only outcome here a caller must not retry: retrying is\n * the behavior the suppression exists to stop. Distinct from `rejected`\n * because nothing was attempted and nothing failed — this is the control\n * working.\n */\n | 'suppressed'\n /**\n * A MARKETING send refused because this person has already received their\n * ceiling from this site inside the window.\n *\n * Retryable, unlike `suppressed`: the window rolls. A resumable sweep does\n * not need to do anything about it — its next run asks again.\n */\n | 'frequency-capped'\n /**\n * A MARKETING send refused because this site has been mailing this person\n * for longer than the sunset window with nothing to show for it.\n *\n * TERMINAL for a sweep, and it sits with `suppressed` rather than with\n * `frequency-capped` for a reason worth stating: the frequency window\n * clears by the passage of time, so waiting works. A sunset clears when the\n * PERSON engages, which more mail from us cannot cause — so a sweep that\n * treated it as deferrable would re-read the same doomed row on every beat\n * forever. Nothing about the recipient has been reduced; the next message\n * after they open anything goes.\n */\n | 'unengaged'\n /**\n * The recipient's domain takes no mail — no MX and no address record, or\n * an RFC 7505 null MX — so the message would only have bounced\n * (AGL-3328). Refused for every purpose, transactional included, before\n * the provider was called; the address is suppressed with a reason of its\n * own. Terminal: the preflight answers the same until the domain's DNS\n * changes, which it re-reads daily.\n */\n | 'undeliverable'\n /**\n * A BULK or cold message whose recipient sits behind a mail gateway that\n * refused this sending domain twice in thirty days and delivered nothing\n * (AGL-3328). Terminal for this message rather than deferrable: the hold\n * lifts when the gateway delivers again or the refusals age out, which is\n * not something a sweep's next beat can wait out. Mail the recipient asked\n * for is never held.\n */\n | 'gateway-held'\n /**\n * The site or workspace this message belongs to is under a full lockdown\n * (AGL-3356), carried in on its sending identity. Terminal for this\n * message: a sweep that holds work across a suspension skips the host\n * before it gets here, so a caller that reaches this refusal is one with\n * no way to come back, and retrying would only ask the same question.\n */\n | 'suspended'\n /**\n * The outbound phishing screen held this message for staff review, or\n * staff already rejected a message carrying the same signals from this\n * site (AGL-3356). Nothing was attempted. Terminal for this message:\n * `detail` names the `HS-…` reference, and once staff release the review\n * the site's next message carrying the same signals sends.\n */\n | 'held-for-review'\n\nexport type SendEmailResult =\n | { sent: true; id: string | null }\n | {\n sent: false\n reason: SendEmailFailureReason\n /** HTTP status, when Resend answered. */\n status?: number\n /** Resend's error body or the thrown message, trimmed for logs. */\n detail?: string\n /**\n * `rate-limited` only: the earliest instant a caller may try again —\n * when the platform hourly window rolls, or what the provider's own\n * `retry-after` asked for. A resumable sweep does not need to wait on\n * it; its next scheduled run is the retry.\n */\n retryAtMs?: number\n }\n\n/**\n * The retry instant when the platform send-rate governor deferred this\n * message, or `null` for every other outcome (AGL-2409).\n *\n * A FUNCTION rather than `result.reason === 'rate-limited'` at each call site,\n * because `strictNullChecks` is OFF repo-wide and TypeScript will not narrow a\n * boolean-literal discriminant without it: `if (result.sent) … else\n * result.reason` does not compile, in every consumer, for a reason that has\n * nothing to do with this union. One helper is also one place to change if the\n * shape of a deferral ever moves.\n */\nexport function rateLimitedRetryAtMs(\n result: SendEmailResult | null | undefined,\n): number | null {\n const failure = result as { reason?: string; retryAtMs?: number } | null\n if (!failure || failure.reason !== 'rate-limited') return null\n const retryAtMs = Number(failure.retryAtMs)\n return Number.isFinite(retryAtMs) ? retryAtMs : 0\n}\n\n/**\n * How long to wait when the provider names no interval of its own.\n *\n * One second, because Resend's rate limit is counted per second — so a wait\n * of a whole window is the shortest one that is certain to have cleared it.\n */\nconst PROVIDER_RETRY_FALLBACK_MS = 1_000\n\n/**\n * The longest wait a provider header may ask for.\n *\n * A `retry-after` is read off the network and reaches a scheduler, so it is\n * clamped rather than trusted: a header of `86400` would park a campaign for\n * a day on one response nobody saw. An hour is past every documented window\n * and short enough that a wrong one costs a run rather than a day.\n */\nconst PROVIDER_RETRY_MAX_MS = 3_600_000\n\n/** One header as whole seconds, or null when it is absent or unreadable. */\nfunction headerSeconds(\n headers: { get?: (name: string) => string | null } | null | undefined,\n name: string,\n): number | null {\n const raw = headers?.get?.(name)\n // `Number(null)` and `Number('')` are both 0, which would read as \"retry\n // immediately\" for a header that is not there at all.\n if (raw === null || raw === undefined || String(raw).trim() === '') return null\n const seconds = Number(raw)\n return Number.isFinite(seconds) && seconds >= 0 ? seconds : null\n}\n\n/**\n * When the provider says a refused request may be repeated.\n *\n * Read from the two headers Resend documents beside a 429, both in whole\n * seconds: `retry-after` first because it is the direct answer to this\n * question, then `ratelimit-reset`, which names when the window rolls. A\n * response carrying neither falls back to one window.\n */\nexport function providerRetryAtMs(\n headers: { get?: (name: string) => string | null } | null | undefined,\n nowMs: number = Date.now(),\n): number {\n const seconds =\n headerSeconds(headers, 'retry-after') ??\n headerSeconds(headers, 'ratelimit-reset')\n const waitMs =\n seconds === null\n ? PROVIDER_RETRY_FALLBACK_MS\n : Math.min(seconds * 1_000, PROVIDER_RETRY_MAX_MS)\n return nowMs + waitMs\n}\n\n/**\n * Why a send did not happen, or `null` when it did.\n *\n * The same accessor `rateLimitedRetryAtMs` is, generalized: `strictNullChecks`\n * is OFF repo-wide, so TypeScript will not narrow the union on `result.sent`\n * and reading `result.reason` at a call site does not compile.\n */\nexport function sendFailureReason(\n result: SendEmailResult | null | undefined,\n): SendEmailFailureReason | null {\n const failure = result as {\n sent?: boolean\n reason?: SendEmailFailureReason\n } | null\n if (!failure || failure.sent) return null\n return failure.reason ?? null\n}\n\n/**\n * Whether this outcome is worth coming back for.\n *\n * TRUE only for the two refusals a later attempt can pass: the platform hour\n * rolls, and so does the marketing frequency window. Everything else is\n * either a delivery that happened or a failure a retry repeats — a\n * suppression most of all, since retrying is the exact behavior a suppression\n * exists to stop, and a sunset for the same reason at one remove: it clears\n * when the recipient engages, which no amount of further mail from us brings\n * about.\n *\n * A resumable sweep uses this to decide whether to leave its subject\n * unstamped. Stamping on a deferrable refusal discards a message; NOT\n * stamping on a terminal one re-reads the same doomed row on every beat until\n * it crowds out the work that could succeed. Both are silent, so the\n * distinction lives here instead of at each sweep.\n *\n * A FUNCTION rather than `result.reason === …` at each call site, because\n * `strictNullChecks` is OFF repo-wide and TypeScript will not narrow a\n * boolean-literal discriminant without it — the same reason\n * {@link rateLimitedRetryAtMs} beside it is one.\n */\nexport function isDeferrableSendResult(\n result: SendEmailResult | null | undefined,\n): boolean {\n const reason = sendFailureReason(result)\n return reason === 'rate-limited' || reason === 'frequency-capped'\n}\n\n/**\n * Who a message is for, as the deliverability preflight reads it\n * (AGL-3328): `bulk` for anything a control may already refuse — marketing\n * by either of its two marks, a campaign, a resumable sweep — and\n * `transactional` for the rest, which a gateway hold never touches.\n *\n * Derived from what a sender already declares, for the reason\n * `isMarketingMessage` is: a label callers had to remember would be\n * forgotten by the one that matters, and the permissive answer is the one a\n * forgotten caller gets.\n */\nexport function emailSendPurpose(\n options: Pick<SendEmailOptions, 'priority' | 'context' | 'headers'> & { marketing?: unknown },\n): EmailSendPurpose {\n if (isMarketingMessage(options)) return 'bulk'\n if (options.headers?.['List-Unsubscribe']) return 'bulk'\n return isRefusablePriority(resolveSendPriority(options.context, options.priority))\n ? 'bulk'\n : 'transactional'\n}\n\n/** The preflight's answer, or `null` when none is installed, it failed or it ran out of time. */\nasync function askDeliverabilityPreflight(\n request: Parameters<NonNullable<ReturnType<typeof getEmailDeliverabilityPreflight>>>[0],\n label: string,\n): Promise<EmailDeliverabilityPreflightVerdict | null> {\n const preflight = getEmailDeliverabilityPreflight()\n if (!preflight) return null\n let timer: ReturnType<typeof setTimeout> | undefined\n try {\n return await Promise.race([\n preflight(request),\n new Promise<null>((resolve) => {\n timer = setTimeout(() => {\n console.warn(`${label} deliverability preflight timed out — allowing`)\n resolve(null)\n }, EMAIL_DELIVERABILITY_PREFLIGHT_TIMEOUT_MS)\n ;(timer as { unref?: () => void })?.unref?.()\n }),\n ])\n } catch (error) {\n // Fails OPEN, the governor's posture: an outage on the control must not\n // become an outage on the mail.\n console.error(`${label} deliverability preflight failed — allowing`, error)\n return null\n } finally {\n if (timer) clearTimeout(timer)\n }\n}\n\n/**\n * The send seam's half of the outbound phishing screen (AGL-3356): every\n * message a SITE sends whose identity carries the workspace\n * `hostSendingIdentity` stamped, through {@link screenTenantMessage} — the\n * pure screen, its tiers, and the installed review store. `null` when the\n * message may go; fails open, as that function documents.\n */\nexport async function askOutboundScreen(\n options: SendEmailOptions,\n label = 'email',\n): Promise<SendEmailResult | null> {\n const workspace = options.sendingIdentity?.workspace\n if (options.audience !== 'tenant' || !workspace?.hostId) return null\n const refusal = await screenTenantMessage(\n {\n workspace,\n subject: options.subject,\n fromName: options.fromName ?? null,\n // The resolved address, never anything the caller passed: the only\n // `From:` this message can leave on (AGL-3362).\n fromAddress: options.sendingIdentity?.from ?? null,\n replyTo: options.replyTo ?? null,\n bodies: [options.text, options.html],\n // A marketing message is never owed, whatever it declares.\n owed: Boolean(options.owedFor) && !isMarketingMessage(options),\n context: options.context ?? null,\n releasedReviewId: options.releasedReviewId ?? null,\n },\n label,\n )\n if (!refusal) return null\n console.warn(`${label} not sent — ${refusal.detail}`)\n return { sent: false, reason: 'held-for-review', detail: refusal.detail }\n}\n\nexport interface EmailConfig {\n apiKey: string | undefined\n from: string | undefined\n}\n\n/**\n * Reads the email environment.\n *\n * Deliberately read per call rather than captured at module load: these run\n * in serverless handlers where the module may be evaluated during a build,\n * long before the runtime env exists.\n */\nexport function getEmailConfig(): EmailConfig {\n return {\n apiKey: process.env.RESEND_API_KEY || undefined,\n from: process.env.USAGE_EMAIL_FROM || undefined,\n }\n}\n\n/**\n * True when both `RESEND_API_KEY` and `USAGE_EMAIL_FROM` are present.\n *\n * Callers that answer an HTTP request (rather than firing best-effort mail)\n * use this to return a 501 with an actionable message instead of pretending\n * to have sent something.\n */\nexport function isEmailConfigured(): boolean {\n const { apiKey, from } = getEmailConfig()\n return Boolean(apiKey && from)\n}\n\n/**\n * Applies a white-label display name to a configured sender while keeping\n * its verified address (White-Label Phase 1). Accepts either a bare address\n * (`noreply@aglyn.com`) or an RFC-5322 `Name <addr>` header and returns\n * `\"<fromName>\" <addr>`. A blank name, or a value with no extractable\n * address, yields the original `from` untouched — the sender identity is\n * never dropped on the floor.\n */\nexport function applyFromName(\n from: string | undefined,\n fromName: string | undefined,\n): string | undefined {\n const name = (fromName ?? '').trim()\n if (!from || !name) return from\n const angle = from.match(/<([^>]+)>/)\n const address = (angle ? angle[1] : from).trim()\n if (!address.includes('@')) return from\n // Quote the display name so commas/specials stay inside one mailbox.\n return `\"${name.replace(/\"/g, '')}\" <${address}>`\n}\n\nfunction normalizeRecipients(to: string | string[]): string[] {\n const list = Array.isArray(to) ? to : [to]\n return list\n .map((address) => String(address ?? '').trim())\n .filter((address) => address.includes('@'))\n}\n\n/** A Resend send payload in the provider's own wire shape. */\nexport interface ResendSendPayload {\n to?: unknown\n from?: unknown\n subject?: unknown\n [field: string]: unknown\n}\n\n/**\n * The one place that POSTs to Resend's send endpoint, and the last thing\n * standing between a payload and the network.\n *\n * A payload carrying no recipient cannot become a message. Resend answers it\n * `422 missing_required_field`, which costs an API call and then shows up in\n * the vendor dashboard as a red line indistinguishable from mail that\n * genuinely failed to deliver — carrying no subject, no recipient and nothing\n * naming the code that produced it. Diagnosing that means reading a log\n * outside the deployment and guessing. So the refusal happens here, before\n * the fetch, and names the caller's `context`.\n *\n * It throws rather than returning a `SendEmailResult`: this is a programming\n * error, not a delivery outcome. `sendEmail` filters recipients well before\n * it reaches this call, so nothing on the ordinary path can trip it. The\n * guard exists because `RESEND_SEND_ENDPOINT` is exported and any module can\n * therefore reach the send endpoint on its own, bypassing every check\n * `sendEmail` owns.\n */\nexport async function postResendEmail(\n apiKey: string,\n payload: ResendSendPayload,\n context?: string,\n): Promise<Response> {\n const raw = payload?.to\n const recipients = (Array.isArray(raw) ? raw : raw == null ? [] : [raw])\n .map((address) => String(address ?? '').trim())\n .filter(Boolean)\n if (!recipients.length) {\n throw new Error(\n `${context ? `${context} ` : ''}send refused before the network — a ` +\n 'Resend payload with no `to` field cannot become a message, and the ' +\n 'attempt would surface only as a 422 in the Resend dashboard',\n )\n }\n\n return fetch(RESEND_SEND_ENDPOINT, {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${apiKey}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(payload),\n })\n}\n\n/**\n * Sends one email through Resend.\n *\n * **Never throws and never rejects.** Outbound mail is best-effort across\n * every caller in this codebase — a checkout must not fail because a receipt\n * bounced — so every outcome comes back as a `SendEmailResult` instead. The\n * one thing callers must not do is ignore the result: `sent` is what tells\n * the user whether a message actually went out (AGL-708).\n *\n * When the env vars are missing this warns once per call and returns\n * `{ sent: false, reason: 'unconfigured' }` rather than failing, so local and\n * preview environments keep working without a Resend account.\n */\nexport async function sendEmail(\n options: SendEmailOptions,\n): Promise<SendEmailResult> {\n const { apiKey, from: configuredFrom } = getEmailConfig()\n const label = options.context ? `${options.context} email` : 'email'\n\n /*\n * THE SENDING-IDENTITY REFUSAL.\n *\n * Enforced here AND at the campaign route, independently, for the reason\n * the send-rate governor is enforced twice: the route's check is the one a\n * person sees, and this one is what holds when a caller does not make it.\n * A governor is injectable and a route is skippable, so neither may be the\n * only thing standing between an unverified domain and a send.\n *\n * Placed above the `apiKey`/`from` gate so a refusal cannot be reported as\n * `unconfigured` — the two have different owners and different fixes.\n */\n const identityRefusal = sendingIdentityRefusal(options.sendingIdentity)\n if (identityRefusal) {\n console.warn(`${label} refused — ${identityRefusal.message}`)\n return {\n sent: false,\n // A suspension is not a DNS state, and the merchant reading the\n // failure has nothing to finish — so it is named for what it is.\n reason:\n identityRefusal.code === 'workspace-suspended'\n ? 'suspended'\n : 'unverified-domain',\n detail: identityRefusal.message,\n }\n }\n\n // A resolved identity outranks the configured sender: it is the server's\n // answer to which verified address this message leaves on. Without one, the\n // white-label display name is applied to the configured verified sender\n // (White-Label Phase 1).\n //\n // Two sources, and `options` is neither of them. Nothing the caller passes\n // reaches the address — only the display name in front of it.\n const resolvedFrom = options.sendingIdentity?.from ?? null\n\n /*\n * THE PLATFORM DOMAIN IS NOT A FALLBACK FOR TENANT MAIL.\n *\n * `configuredFrom` is `USAGE_EMAIL_FROM` — an address on `aglyn.com`, where\n * Aglyn's own billing, account and console mail leaves from. A site's mail\n * reaching it means that site's list quality is charged against the domain\n * every other customer's password reset depends on.\n *\n * `resolveHostSendingIdentity` already refuses above, so a tenant caller\n * that resolved an identity never arrives here with `resolvedFrom` null.\n * This is the arm for a tenant caller that resolved NOTHING — the shape a\n * new send site takes when its author does not know an identity is owed —\n * and it is checked here rather than left to the call sites because ninety\n * of them cannot each be relied on to remember.\n */\n if (options.audience === 'tenant' && !resolvedFrom) {\n console.warn(\n `${label} refused — a site's mail cannot leave on the shared platform ` +\n 'domain, and no sending identity was resolved for it',\n )\n return {\n sent: false,\n reason: 'unverified-domain',\n detail:\n 'This message belongs to a site and no sending identity was ' +\n 'resolved for it, so it was refused rather than sent from the ' +\n 'shared Aglyn address.',\n }\n }\n\n const from = applyFromName(\n resolvedFrom ?? configuredFrom,\n options.fromName,\n )\n\n if (!apiKey || !from) {\n console.warn(\n `${label} skipped — set RESEND_API_KEY and USAGE_EMAIL_FROM to ` +\n 'deliver mail',\n )\n return { sent: false, reason: 'unconfigured' }\n }\n\n let to = normalizeRecipients(options.to)\n if (!to.length) {\n console.warn(`${label} skipped — no valid recipient address`)\n return { sent: false, reason: 'no-recipient' }\n }\n\n /*\n * THE OUTBOUND PHISHING SCREEN (AGL-3356).\n *\n * Asked for every message a SITE sends whose identity came through\n * `hostSendingIdentity` — which stamps the workspace on it — so one check\n * covers every tenant sender, first-party or marketplace, without any of\n * them calling it. See `outbound-screen-gate.ts`.\n *\n * Ahead of the preflight and every other gate: a message that must not\n * leave is decided before anything is spent on who receives it.\n */\n const screenRefusal = await askOutboundScreen(options, label)\n if (screenRefusal) return screenRefusal\n\n /*\n * THE DELIVERABILITY PREFLIGHT (AGL-3328).\n *\n * Asked before every other gate, because its refusal is the most final: a\n * recipient whose domain takes no mail is not a recipient this message or\n * any other can reach, and spending the marketing gate's reads or the\n * governor's hourly budget deciding more about them would be spending it\n * on a bounce. A recipient behind a gateway that refused this sending\n * domain twice is held — for bulk mail only; the purpose decides that,\n * and the preflight is told it.\n *\n * A multi-recipient message drops the refused and held recipients and goes\n * to the rest: one colleague's dead address must not stop the others'\n * notification.\n */\n const sendingAddress = bareSenderAddress(from)\n const preflight = await askDeliverabilityPreflight(\n {\n recipients: to.map((address) => address.toLowerCase()),\n purpose: emailSendPurpose(options),\n sendingDomain: sendingAddress ? sendingAddress.slice(sendingAddress.lastIndexOf('@') + 1) : null,\n sendingSource: options.sendingIdentity?.source ?? 'platform',\n context: options.context,\n hostId: options.marketing?.hostId ?? null,\n },\n label,\n )\n if (preflight && (preflight.refused?.length || preflight.held?.length)) {\n const stopped = new Set(\n [...(preflight.refused ?? []), ...(preflight.held ?? [])].map((entry) =>\n String(entry?.email ?? '').toLowerCase(),\n ),\n )\n const remaining = to.filter((address) => !stopped.has(address.toLowerCase()))\n if (!remaining.length) {\n const refused = preflight.refused?.[0]\n const held = preflight.held?.[0]\n const detail = refused?.reason ?? held?.reason ?? 'The recipient cannot receive this message.'\n console.warn(`${label} not sent — ${detail}`)\n return { sent: false, reason: refused ? 'undeliverable' : 'gateway-held', detail }\n }\n console.warn(\n `${label} — ${to.length - remaining.length} of ${to.length} recipients ` +\n 'dropped by the deliverability preflight',\n )\n to = remaining\n }\n\n /*\n * THE MARKETING GATE.\n *\n * Everything a marketing message owes, asked once, here — because the four\n * merchant-triggered bulk paths that owed it discharged none of it, and\n * asking four call sites to remember is the shape that produces the fifth\n * that does not.\n *\n * Ahead of the send-rate governor deliberately. A refusal here is a message\n * that must never leave, so spending platform hourly budget deciding that\n * would be budget the rest of the hour's mail no longer has.\n *\n * Nothing installed is UNGATED. Same posture as the governor: the durable\n * half lives in another library, and a deployment that never installs it\n * must still send.\n */\n /*\n * TWO URLS, and which one goes where is the whole RFC 8058 story.\n *\n * `unsubscribeUrl` is what a PERSON clicks in the footer below, and the\n * gate points it at the preference page so the stream this message belongs\n * to is one of the things they can stop instead of all of it.\n * `oneClickUrl` is what `List-Unsubscribe` names, and it must stay on the\n * route whose POST writes immediately — a mailbox provider POSTs it with\n * nobody present and reads the 200 as \"done\", which a page of checkboxes\n * cannot honor.\n *\n * It falls back to `unsubscribeUrl` for a caller that minted one link and\n * named no page, which is the only shape that was ever possible before the\n * gate minted the pair.\n */\n let unsubscribeUrl = options.marketing?.unsubscribeUrl ?? ''\n let oneClickUrl = options.marketing?.oneClickUrl ?? ''\n if (options.marketing) {\n if (to.length !== 1) {\n // Not a delivery outcome — a caller error, and one that would put the\n // first recipient's signed unsubscribe link in everybody else's copy.\n console.error(\n `${label} refused — a marketing send addresses exactly one ` +\n `recipient, and this one names ${to.length}`,\n )\n return {\n sent: false,\n reason: 'no-recipient',\n detail: 'A marketing send addresses exactly one recipient.',\n }\n }\n const gate = getMarketingSendGate()\n if (gate) {\n let verdict: Awaited<ReturnType<typeof gate>> | null\n try {\n verdict = await gate({\n hostId: options.marketing.hostId,\n siteBase: options.marketing.siteBase,\n email: to[0],\n context: options.context,\n capped: options.marketing.capped !== false,\n // Passed through verbatim, INCLUDING absent. A default topic\n // applied here would invent a stream for every caller that named\n // none and let a topic opt-out refuse messages that belong to no\n // stream — see `MarketingSendContext.topicId`.\n ...(options.marketing.topicId\n ? { topicId: options.marketing.topicId }\n : {}),\n consentHostIds: options.marketing.consentHostIds,\n consentAwaitsConfirmation: options.marketing.consentAwaitsConfirmation,\n })\n } catch (error) {\n /*\n * FAILS OPEN, and the asymmetry with `filterSendableForHost` is\n * deliberate rather than an oversight. That helper fails CLOSED\n * because a suppression list it could not read is not a list that\n * said an address is safe to mail — and it keeps doing so, inside\n * the gate. What is being caught here is the gate itself being\n * unreachable or throwing, which is an outage on the control; an\n * outage on a control that becomes an outage on the product is the\n * worse of the two bugs, and it is the posture `sendEmail` takes\n * everywhere else.\n */\n console.error(`${label} marketing gate failed — allowing`, error)\n verdict = null\n }\n if (verdict && !verdict.allowed) {\n /*\n * A cadence refusal reports as `frequency-capped` rather than earning\n * a value of its own in {@link SendEmailFailureReason}.\n *\n * That union is what {@link isDeferrableSendResult} switches on, and\n * the two are deferrable for exactly the same reason: a later attempt\n * passes because time went by. A third value would have to be added\n * to that predicate as well, and a sweep built against the older\n * vocabulary would silently treat the recipient's own request as\n * terminal and stamp the subject — discarding a message the recipient\n * asked to receive later rather than never. Which of the two it was\n * is in `detail`, where a person reading a log needs it.\n */\n const reason: SendEmailFailureReason =\n verdict.refusal === 'frequency-capped' ||\n verdict.refusal === 'cadence-limited'\n ? 'frequency-capped'\n : verdict.refusal === 'unengaged'\n ? 'unengaged'\n : 'suppressed'\n console.warn(`${label} not sent — ${verdict.detail ?? reason}`)\n return { sent: false, reason, detail: verdict.detail }\n }\n unsubscribeUrl = unsubscribeUrl || verdict?.unsubscribeUrl || ''\n oneClickUrl = oneClickUrl || verdict?.oneClickUrl || ''\n }\n if (!unsubscribeUrl) {\n // A marketing message with no way out is the defect this gate exists to\n // close, so it is said out loud rather than shipped quietly. Not a\n // refusal: the cause is a missing `EMAIL_UNSUBSCRIBE_SECRET` or a host\n // with no public origin — an operator's configuration, not the\n // recipient's problem — and refusing here would turn it into silence.\n console.warn(\n `${label} carries no unsubscribe link — set ` +\n 'EMAIL_UNSUBSCRIBE_SECRET and publish the site on a domain',\n )\n }\n }\n\n /*\n * BULK MAIL WITH NO WAY OUT DOES NOT LEAVE ON THE POOLED IDENTITY.\n *\n * The pool carries marketing for every site that has no domain of its own,\n * and what keeps that survivable is that a recipient can always stop it: the\n * complaint that would otherwise be charged to every other site on the member\n * has a cheaper alternative one click away. A message that lost its\n * unsubscribe link removes that alternative, so it is refused here rather\n * than sent at other people's expense.\n *\n * Asked AFTER the gate, because only here is the answer known. The URL may\n * arrive from the caller, from the gate that mints one per recipient, or —\n * for a campaign, which composes its own one-click pair upstream — as a\n * `List-Unsubscribe` header and no marketing context at all. Reading only one\n * of the three would refuse the senders that are behaving.\n *\n * The classification is DERIVED, never declared. See `isMarketingMessage`.\n */\n const pooledRefusal = isMarketingMessage(options)\n ? pooledMarketingRefusal(\n options.sendingIdentity,\n Boolean(unsubscribeUrl) ||\n Boolean(options.headers?.['List-Unsubscribe']),\n )\n : null\n if (pooledRefusal) {\n console.warn(`${label} refused — ${pooledRefusal.message}`)\n return {\n sent: false,\n reason: 'unverified-domain',\n detail: pooledRefusal.message,\n }\n }\n\n /*\n * THE PLATFORM SEND-RATE GOVERNOR (AGL-2409).\n *\n * Asked on EVERY send, including transactional ones, because the ceiling is\n * about total volume on one sending domain — a governor that only saw\n * campaigns would report a quiet hour while ten thousand receipts went out.\n * The governor counts what it grants.\n *\n * Two properties this block must have, in order:\n *\n * 1. **A refusal is honoured only for a refusable priority.** This is the\n * second of the two enforcement points described in `send-rate.ts`.\n * `emailSendRateVerdict` already cannot refuse a transactional send;\n * the governor is INJECTABLE, so a wrong one is reachable, and the send\n * path must still be unable to drop a password reset. Anything that is\n * not explicitly a campaign or a bulk sweep sends regardless of the\n * answer.\n * 2. **It fails open.** A governor that throws — Firestore unreachable, no\n * Admin app, a bug — must not stop mail. The counter being unavailable\n * is an outage on the control, and an outage on a control that turns\n * into an outage on the product is a worse bug than the burst it was\n * guarding. The same posture `sendEmail` takes everywhere else: it\n * never throws, and neither does this.\n */\n const priority = resolveSendPriority(options.context, options.priority)\n const governor = getEmailSendGovernor()\n if (governor) {\n let verdict: Awaited<ReturnType<typeof governor>> | null\n try {\n verdict = await governor({\n priority,\n count: to.length,\n context: options.context,\n })\n } catch (error) {\n console.error(`${label} send-rate governor failed — allowing`, error)\n verdict = null\n }\n if (verdict && !verdict.allowed && isRefusablePriority(priority)) {\n const retryAtMs =\n verdict.retryAtMs ?? emailSendRateWindowStartMs(Date.now())\n console.warn(\n `${label} deferred — platform send rate reached ` +\n `(${verdict.used ?? '?'}/${verdict.ceiling ?? '?'} this hour)`,\n )\n return {\n sent: false,\n reason: 'rate-limited',\n retryAtMs,\n detail:\n `Platform hourly send rate reached (${verdict.ceiling ?? '?'}/hour). ` +\n 'Transactional mail is unaffected.',\n }\n }\n }\n\n /*\n * THE VISIBLE OPT-OUT, on both parts.\n *\n * The header pair below is for the mailbox provider; this is for the person\n * — CAN-SPAM asks for a mechanism the recipient can see and use, and most\n * clients render no control for the header at all. Both helpers are\n * idempotent by URL, so a sender that placed its own link (a designed\n * template rendering `{{unsubscribeUrl}}`, the campaign body's footer)\n * keeps its own placement and does not get a second one.\n *\n * `text` first and `html` from the result, so the synthesized HTML part\n * that stands in for a text-only message carries the link as an anchor\n * rather than as characters.\n */\n const text = unsubscribeUrl\n ? appendUnsubscribeText(options.text ?? '', unsubscribeUrl)\n : options.text\n const html = unsubscribeUrl\n ? options.html\n ? appendUnsubscribeHtml(options.html, unsubscribeUrl)\n : renderTextEmailHtml(text ?? '', options.subject)\n : options.html\n\n try {\n const response = await postResendEmail(\n apiKey,\n {\n from,\n to,\n subject: options.subject,\n ...(text ? { text } : {}),\n // The HTML part, from the caller when it has one and otherwise\n // synthesized from `text`. A message with no HTML part carries no\n // anchors, so its links are not links in the inbox AND Resend has\n // nothing to rewrite for click tracking — see `text-email-html.ts`.\n // The caller always wins: this can only fill a gap, never override a\n // designed template.\n ...(() => {\n const body = html || renderTextEmailHtml(text ?? '', options.subject)\n return body ? { html: body } : {}\n })(),\n // The caller's headers plus the RFC 8058 pair for a marketing send.\n // Caller-first, so the campaign sender's own pair is the one that\n // ships and a merchant-authored header is never silently replaced.\n ...(() => {\n const headers = {\n ...unsubscribeHeaders(oneClickUrl || unsubscribeUrl),\n ...(options.headers ?? {}),\n }\n return Object.keys(headers).length ? { headers } : {}\n })(),\n // The caller's tags plus the `context` tag (AGL-2407). Caller-first\n // so a sender that stamps its own `context` keeps it: a tag list with\n // two entries of one name is not a shape worth discovering in\n // production, and the explicit one is the more specific.\n ...(() => {\n const caller = options.tags ?? []\n const derived = caller.some((tag) => tag?.name === 'context')\n ? []\n : contextTag(options.context)\n const tags = [...caller, ...derived]\n return tags.length ? { tags } : {}\n })(),\n ...(options.replyTo ? { reply_to: options.replyTo } : {}),\n },\n options.context,\n )\n\n if (!response.ok) {\n const detail = await response.text().catch(() => '')\n /*\n * A 429 IS NOT A STATEMENT ABOUT THIS RECIPIENT, so it does not report\n * as one. See the `rate-limited` member of\n * {@link SendEmailFailureReason} for why the distinction is load-bearing\n * rather than cosmetic — every caller in the tree already has a branch\n * for a deferral, and none of them has one for \"rejected, but try this\n * exact address again later\".\n *\n * A warning rather than an error: the provider asking for a slower pace\n * is a normal thing to be told, and logging it at the level a failed\n * delivery uses is what teaches an operator to skim past both.\n */\n if (response.status === 429) {\n const retryAtMs = providerRetryAtMs(response.headers)\n console.warn(\n `${label} deferred — the provider is rate limiting; retry in ` +\n `${Math.max(0, Math.round((retryAtMs - Date.now()) / 1000))}s`,\n )\n return {\n sent: false,\n reason: 'rate-limited',\n status: response.status,\n retryAtMs,\n detail: detail.slice(0, 500),\n }\n }\n console.error(`${label} failed`, response.status, detail)\n return {\n sent: false,\n reason: 'rejected',\n status: response.status,\n detail: detail.slice(0, 500),\n }\n }\n\n const body = (await response.json().catch(() => null)) as {\n id?: string\n } | null\n return { sent: true, id: body?.id ?? null }\n } catch (error) {\n console.error(`${label} failed`, error)\n return {\n sent: false,\n reason: 'network',\n detail: String((error as Error)?.message ?? error).slice(0, 500),\n }\n }\n}\n\nexport default sendEmail\n"],"names":["emailSendRateWindowStartMs","getEmailSendGovernor","isRefusablePriority","resolveSendPriority","renderTextEmailHtml","sendingIdentityRefusal","pooledMarketingRefusal","appendUnsubscribeHtml","appendUnsubscribeText","getMarketingSendGate","isMarketingMessage","unsubscribeHeaders","EMAIL_DELIVERABILITY_PREFLIGHT_TIMEOUT_MS","getEmailDeliverabilityPreflight","bareSenderAddress","screenTenantMessage","RESEND_SEND_ENDPOINT","contextTag","context","value","String","trim","replace","slice","name","rateLimitedRetryAtMs","result","failure","reason","retryAtMs","Number","isFinite","PROVIDER_RETRY_FALLBACK_MS","PROVIDER_RETRY_MAX_MS","headerSeconds","headers","raw","get","undefined","seconds","providerRetryAtMs","nowMs","Date","now","waitMs","Math","min","sendFailureReason","sent","isDeferrableSendResult","emailSendPurpose","options","priority","askDeliverabilityPreflight","request","label","preflight","timer","Promise","race","resolve","setTimeout","console","warn","unref","error","clearTimeout","askOutboundScreen","workspace","sendingIdentity","audience","hostId","refusal","subject","fromName","fromAddress","from","replyTo","bodies","text","html","owed","Boolean","owedFor","releasedReviewId","detail","getEmailConfig","apiKey","process","env","RESEND_API_KEY","USAGE_EMAIL_FROM","isEmailConfigured","applyFromName","angle","match","address","includes","normalizeRecipients","to","list","Array","isArray","map","filter","postResendEmail","payload","recipients","length","Error","fetch","method","Authorization","body","JSON","stringify","sendEmail","configuredFrom","identityRefusal","message","code","resolvedFrom","screenRefusal","sendingAddress","toLowerCase","purpose","sendingDomain","lastIndexOf","sendingSource","source","marketing","refused","held","stopped","Set","entry","email","remaining","has","unsubscribeUrl","oneClickUrl","gate","verdict","siteBase","capped","topicId","consentHostIds","consentAwaitsConfirmation","allowed","pooledRefusal","governor","count","used","ceiling","response","Object","keys","caller","tags","derived","some","tag","reply_to","ok","catch","status","max","round","json","id"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/send-email.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The Resend send endpoint. Every outbound application email in Aglyn goes\n * through here — invites, receipts, usage summaries, campaigns, staff alerts.\n *\n * Auth email (verification, password reset) is Firebase's job and does NOT\n * come through this module.\n */\nimport {\n type EmailSendPriority,\n emailSendRateWindowStartMs,\n getEmailSendGovernor,\n isRefusablePriority,\n resolveSendPriority,\n} from './send-rate'\nimport { renderTextEmailHtml } from './text-email-html'\nimport {\n sendingIdentityRefusal,\n pooledMarketingRefusal,\n type SendingIdentityAudience,\n type SendingIdentityVerdict,\n} from './sending-domain'\nimport {\n appendUnsubscribeHtml,\n appendUnsubscribeText,\n getMarketingSendGate,\n isMarketingMessage,\n unsubscribeHeaders,\n type MarketingSendContext,\n} from './marketing-send'\nimport {\n EMAIL_DELIVERABILITY_PREFLIGHT_TIMEOUT_MS,\n getEmailDeliverabilityPreflight,\n type EmailDeliverabilityPreflightVerdict,\n type EmailSendPurpose,\n} from './email-deliverability'\nimport { bareSenderAddress } from './email-delivery-events'\nimport { screenTenantMessage } from './outbound-screen-gate'\n\nexport const RESEND_SEND_ENDPOINT = 'https://api.resend.com/emails'\n\n/** A Resend delivery tag, used for webhook attribution (AGL-268). */\nexport interface EmailTag {\n name: string\n value: string\n}\n\nexport interface SendEmailOptions {\n /** One or more recipient addresses. */\n to: string | string[]\n subject: string\n /** Plain-text body. Supply at least one of `text` or `html`. */\n text?: string\n /**\n * HTML body. Supply at least one of `text` or `html`.\n *\n * Omitted, one is synthesized from `text` so the message always carries an\n * HTML part — a text-only message has no anchors, so its links are inert in\n * the inbox and Resend's click tracking has nothing to rewrite. See\n * `text-email-html.ts`.\n */\n html?: string\n /** Extra MIME headers, e.g. `List-Unsubscribe`. */\n headers?: Record<string, string>\n /** Delivery tags for the opens/clicks webhook. */\n tags?: EmailTag[]\n replyTo?: string | string[]\n /*\n * THERE IS NO `from`.\n *\n * There was: a raw override of the configured sender, subordinate to a\n * resolved identity but winning over everything else, verified against\n * nothing. Every address this function can send from now comes from one of\n * exactly two places — the deployment's own `USAGE_EMAIL_FROM`, or a\n * {@link SendingIdentityVerdict} the server resolved from a document — and\n * neither is reachable from a request body.\n *\n * Deleting it rather than guarding it is what makes that a property instead\n * of a habit. A guard would have to be written correctly at each of the\n * ninety-odd call sites, or once here and then trusted; an option that does\n * not exist cannot be passed by the next sender, and `resolveSendingIdentity`\n * becomes the whole of the answer to \"as whom does this leave\".\n *\n * The resolution below reads no `from` from `options` either, so this is not\n * only a compile-time close. Marketplace plugin bundles reach `sendEmail` as\n * JavaScript and are typechecked against nothing.\n */\n /**\n * White-label display name for the sender (White-Label Phase 1). Replaces\n * only the display name in front of the verified address — the address\n * itself is never taken from the caller, so this cannot forge a different\n * sender. Callers pass `resolveBrandingProfile(org).fromName` here so an\n * agency's mail reads as their brand instead of \"Aglyn\".\n */\n fromName?: string\n /**\n * The server-resolved sending identity for this message, from\n * `resolveSendingIdentity`.\n *\n * Supplied, it decides the address and it may refuse the send outright —\n * `fromName` is subordinate to it, because a verdict is the answer to \"may\n * this leave, and as whom\" and a display name is not. Omitted, every\n * existing caller keeps the behavior it had: the configured platform\n * identity with an optional display name.\n *\n * Callers resolve it from the ORG DOCUMENT, never from request input. An\n * address assembled from a request body is a `From:` override wearing a new\n * name, and the invariant `applyFromName` exists to hold is that the\n * address cannot move off a verified identity.\n */\n sendingIdentity?: SendingIdentityVerdict | null\n /**\n * Whose mail this is — see {@link SendingIdentityAudience}.\n *\n * `tenant` says the message belongs to a SITE, and it makes the platform\n * sender unreachable: a tenant message with no resolved identity is refused\n * rather than sent from `aglyn.com`. Pair it with `sendingIdentity` from\n * `hostSendingIdentity(hostId)` and the ordinary path is unchanged; the flag\n * is what decides the behavior when that resolution is missing or refuses.\n *\n * Omitted, a send is platform mail and keeps the configured sender, because\n * that is what the console's own senders are. `email-audience-coverage.spec`\n * sweeps the tenant-owned trees so the omission cannot be an accident there.\n */\n audience?: SendingIdentityAudience\n /**\n * Delivers to an address whose account is BANNED (AGL-3420). A banned\n * account is sent nothing after its ban notice, by any sender; the lock\n * and lift notices themselves are the only mail that states this, and no\n * other sender may.\n */\n accountBanExempt?: boolean\n /**\n * Short label for logs, e.g. `'invite'` or `'usage-summary'`. Makes a\n * failure in the runtime logs traceable to the feature that caused it.\n *\n * Since AGL-2407 it is also stamped as a Resend `context` TAG on every\n * send — see `contextTag` below.\n */\n context?: string\n /**\n * What the platform send-rate governor is allowed to do to this message\n * (AGL-2409). Omitted, it is derived from `context`: `'campaign'` is a\n * campaign and everything else is transactional, so no existing caller\n * changes and the default is the one that can never be refused.\n *\n * Set it to `'bulk'` ONLY from a resumable sweep — a cron that leaves its\n * subject unstamped and picks it up on the next run. A refusal for a bulk\n * send means \"not this hour\", and a caller that cannot come back would turn\n * that into a message nobody ever gets.\n */\n priority?: EmailSendPriority\n /**\n * Declares this message as MARKETING mail for one site's audience — see\n * `marketing-send.ts` for what that means and why it is one seam.\n *\n * Set it and the message gains, in one place, the three things marketing\n * mail owes: the RFC 8058 unsubscribe header pair plus a visible opt-out\n * link, a check against both suppression lists, and a ceiling on how much\n * one person receives from one site.\n *\n * ONE RECIPIENT. An unsubscribe link is an HMAC over the address it belongs\n * to, and a suppression verdict is about one person — so a marketing send\n * addressed to a list would carry the wrong link for everybody after the\n * first, and would ask the gate about one of them. Callers fan out.\n */\n marketing?: MarketingSendContext\n /**\n * What the RECIPIENT did that makes this message owed to them (AGL-3356):\n * `order` — a receipt, a gift card, a shipping notice for something they\n * bought; `booking` — a confirmation, reminder or change to something they\n * booked; `account` — a password reset or sign-in link they asked for.\n *\n * The outbound phishing screen's SOFT rules never hold an owed message; a\n * lookalike link still does, because a receipt that links to a brand's\n * disguise is the disguise. Omitted — the default, and the one a forgotten\n * caller gets — the message is treated as merchant-authored and every\n * tier applies. A MARKETING message is never owed, whatever it declares.\n */\n owedFor?: 'order' | 'booking' | 'account'\n /**\n * A review row that the caller's own screen held and staff RELEASED for\n * exactly this content — a campaign put back on the clock, an automation\n * step whose hold was dismissed. The send seam's screen honors it only\n * after confirming, through the installed gate, that it is a release for\n * this site; naming a row is not a way past the screen.\n */\n releasedReviewId?: string | null\n}\n\n/**\n * The `context` tag, attached to every send (AGL-2407).\n *\n * ## Why this is here and not at 37 call sites\n *\n * Until now `tags` were set by exactly one sender, `campaign-send.ts`, which\n * stamps `hostId` and `campaignId` for the opens/clicks webhook. Everything\n * else went out with NO tags at all, so a bounce on an invite, a password\n * reset, a receipt or a usage summary reached the webhook carrying nothing to\n * identify it, and was dropped.\n *\n * The obvious fix — thread an identifier through every call site — asks 37\n * places to remember, which is the shape that produces the 38th that does\n * not. But `context` is ALREADY threaded through 35 of the 37 for logging,\n * and it is exactly the right value: it names the sender. So the tag is\n * derived here, once, and no caller changes.\n *\n * Resend tag values are restricted to ASCII letters, digits, `_` and `-`;\n * anything else is rejected and would fail the whole send. Every `context` in\n * the tree is already a plain slug, but this is mail delivery — a value that\n * makes the send fail is far worse than a value that is sanitised — so the\n * label is normalised rather than trusted, and a context that sanitises to\n * nothing yields no tag rather than an invalid one.\n */\nexport function contextTag(context: string | undefined): EmailTag[] {\n const value = String(context ?? '')\n .trim()\n .replace(/[^A-Za-z0-9_-]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 64)\n return value ? [{ name: 'context', value }] : []\n}\n\n/**\n * Why a send did not happen. `unconfigured` and `no-recipient` mean nothing\n * was attempted; `rejected` and `network` mean Resend was called and failed;\n * `rate-limited` is either, and says so in `status`.\n *\n * `rate-limited` (AGL-2409) and `frequency-capped` are the two a caller may\n * reasonably retry unchanged — see {@link isDeferrableSendResult}, which is\n * where that distinction is made once rather than at each sweep.\n */\nexport type SendEmailFailureReason =\n | 'unconfigured'\n | 'no-recipient'\n /**\n * Resend answered and would not take this message.\n *\n * Per-message and terminal: a malformed payload, an address the provider\n * will not accept, a tag it rejected. A caller that retries it unchanged\n * gets the same answer, so a batch settles the recipient rather than\n * holding a slot open for them.\n *\n * A 429 is deliberately NOT this. See `rate-limited`.\n */\n | 'rejected'\n | 'network'\n /**\n * A refusal that is about the RATE, not about this message — so the message\n * is intact and a later attempt sends it.\n *\n * Two sources, which is why it is one value. The platform hourly governor\n * refuses before the network and nothing is attempted (AGL-2409). Resend\n * answers `429` on the wire when requests arrive faster than it accepts\n * them, and `status` is 429 in that case.\n *\n * The provider arm reports here rather than as `rejected` because of what\n * the two mean to a batch. A `rejected` recipient is settled and never\n * addressed again, which is right for an address the provider will not take\n * and wrong for every recipient a 429 touches: nothing about them was\n * refused, the request was simply too soon. Classified as `rejected` a\n * single rate-limited burst silently deletes the rest of a campaign's\n * audience while the campaign reports itself complete — and the `sent`\n * figure stays honest throughout, so no rate on the report can show it.\n *\n * The provider's quota errors — `daily_quota_exceeded`,\n * `monthly_quota_exceeded` — are also 429 and also land here. That is the\n * right answer for the same reason: none of them is a statement about the\n * recipient, and all of them clear with time.\n */\n | 'rate-limited'\n /**\n * The org selected a custom sending domain and that domain is not verified.\n *\n * Distinct from `unconfigured` because the two need opposite responses: an\n * unconfigured deployment is the operator's to fix, while this is a\n * customer's DNS that is not finished, and the customer is the only person\n * who can finish it. `detail` carries the sentence naming the domain.\n *\n * This is the LAST line of defence, not the visible one. A caller that\n * reaches it has already skipped the check its route should have made, and\n * a refusal seen only here is a log line — which is the shape of the\n * `USAGE_EMAIL_FROM` outage. `performCampaignSend` refuses first, with a\n * `409`, so a person finds out.\n */\n | 'unverified-domain'\n /**\n * A MARKETING send whose recipient is on a suppression list — they\n * unsubscribed from this site, hard-bounced, or pressed \"report spam\".\n *\n * Terminal, and the only outcome here a caller must not retry: retrying is\n * the behavior the suppression exists to stop. Distinct from `rejected`\n * because nothing was attempted and nothing failed — this is the control\n * working.\n */\n | 'suppressed'\n /**\n * A MARKETING send refused because this person has already received their\n * ceiling from this site inside the window.\n *\n * Retryable, unlike `suppressed`: the window rolls. A resumable sweep does\n * not need to do anything about it — its next run asks again.\n */\n | 'frequency-capped'\n /**\n * A MARKETING send refused because this site has been mailing this person\n * for longer than the sunset window with nothing to show for it.\n *\n * TERMINAL for a sweep, and it sits with `suppressed` rather than with\n * `frequency-capped` for a reason worth stating: the frequency window\n * clears by the passage of time, so waiting works. A sunset clears when the\n * PERSON engages, which more mail from us cannot cause — so a sweep that\n * treated it as deferrable would re-read the same doomed row on every beat\n * forever. Nothing about the recipient has been reduced; the next message\n * after they open anything goes.\n */\n | 'unengaged'\n /**\n * The recipient's domain takes no mail — no MX and no address record, or\n * an RFC 7505 null MX — so the message would only have bounced\n * (AGL-3328). Refused for every purpose, transactional included, before\n * the provider was called; the address is suppressed with a reason of its\n * own. Terminal: the preflight answers the same until the domain's DNS\n * changes, which it re-reads daily.\n */\n | 'undeliverable'\n /**\n * A BULK or cold message whose recipient sits behind a mail gateway that\n * refused this sending domain twice in thirty days and delivered nothing\n * (AGL-3328). Terminal for this message rather than deferrable: the hold\n * lifts when the gateway delivers again or the refusals age out, which is\n * not something a sweep's next beat can wait out. Mail the recipient asked\n * for is never held.\n */\n | 'gateway-held'\n /**\n * The site or workspace this message belongs to is under a full lockdown\n * (AGL-3356), carried in on its sending identity. Terminal for this\n * message: a sweep that holds work across a suspension skips the host\n * before it gets here, so a caller that reaches this refusal is one with\n * no way to come back, and retrying would only ask the same question.\n */\n | 'suspended'\n /**\n * The outbound phishing screen held this message for staff review, or\n * staff already rejected a message carrying the same signals from this\n * site (AGL-3356). Nothing was attempted. Terminal for this message:\n * `detail` names the `HS-…` reference, and once staff release the review\n * the site's next message carrying the same signals sends.\n */\n | 'held-for-review'\n\nexport type SendEmailResult =\n | { sent: true; id: string | null }\n | {\n sent: false\n reason: SendEmailFailureReason\n /** HTTP status, when Resend answered. */\n status?: number\n /** Resend's error body or the thrown message, trimmed for logs. */\n detail?: string\n /**\n * `rate-limited` only: the earliest instant a caller may try again —\n * when the platform hourly window rolls, or what the provider's own\n * `retry-after` asked for. A resumable sweep does not need to wait on\n * it; its next scheduled run is the retry.\n */\n retryAtMs?: number\n }\n\n/**\n * The retry instant when the platform send-rate governor deferred this\n * message, or `null` for every other outcome (AGL-2409).\n *\n * A FUNCTION rather than `result.reason === 'rate-limited'` at each call site,\n * because `strictNullChecks` is OFF repo-wide and TypeScript will not narrow a\n * boolean-literal discriminant without it: `if (result.sent) … else\n * result.reason` does not compile, in every consumer, for a reason that has\n * nothing to do with this union. One helper is also one place to change if the\n * shape of a deferral ever moves.\n */\nexport function rateLimitedRetryAtMs(\n result: SendEmailResult | null | undefined,\n): number | null {\n const failure = result as { reason?: string; retryAtMs?: number } | null\n if (!failure || failure.reason !== 'rate-limited') return null\n const retryAtMs = Number(failure.retryAtMs)\n return Number.isFinite(retryAtMs) ? retryAtMs : 0\n}\n\n/**\n * How long to wait when the provider names no interval of its own.\n *\n * One second, because Resend's rate limit is counted per second — so a wait\n * of a whole window is the shortest one that is certain to have cleared it.\n */\nconst PROVIDER_RETRY_FALLBACK_MS = 1_000\n\n/**\n * The longest wait a provider header may ask for.\n *\n * A `retry-after` is read off the network and reaches a scheduler, so it is\n * clamped rather than trusted: a header of `86400` would park a campaign for\n * a day on one response nobody saw. An hour is past every documented window\n * and short enough that a wrong one costs a run rather than a day.\n */\nconst PROVIDER_RETRY_MAX_MS = 3_600_000\n\n/** One header as whole seconds, or null when it is absent or unreadable. */\nfunction headerSeconds(\n headers: { get?: (name: string) => string | null } | null | undefined,\n name: string,\n): number | null {\n const raw = headers?.get?.(name)\n // `Number(null)` and `Number('')` are both 0, which would read as \"retry\n // immediately\" for a header that is not there at all.\n if (raw === null || raw === undefined || String(raw).trim() === '') return null\n const seconds = Number(raw)\n return Number.isFinite(seconds) && seconds >= 0 ? seconds : null\n}\n\n/**\n * When the provider says a refused request may be repeated.\n *\n * Read from the two headers Resend documents beside a 429, both in whole\n * seconds: `retry-after` first because it is the direct answer to this\n * question, then `ratelimit-reset`, which names when the window rolls. A\n * response carrying neither falls back to one window.\n */\nexport function providerRetryAtMs(\n headers: { get?: (name: string) => string | null } | null | undefined,\n nowMs: number = Date.now(),\n): number {\n const seconds =\n headerSeconds(headers, 'retry-after') ??\n headerSeconds(headers, 'ratelimit-reset')\n const waitMs =\n seconds === null\n ? PROVIDER_RETRY_FALLBACK_MS\n : Math.min(seconds * 1_000, PROVIDER_RETRY_MAX_MS)\n return nowMs + waitMs\n}\n\n/**\n * Why a send did not happen, or `null` when it did.\n *\n * The same accessor `rateLimitedRetryAtMs` is, generalized: `strictNullChecks`\n * is OFF repo-wide, so TypeScript will not narrow the union on `result.sent`\n * and reading `result.reason` at a call site does not compile.\n */\nexport function sendFailureReason(\n result: SendEmailResult | null | undefined,\n): SendEmailFailureReason | null {\n const failure = result as {\n sent?: boolean\n reason?: SendEmailFailureReason\n } | null\n if (!failure || failure.sent) return null\n return failure.reason ?? null\n}\n\n/**\n * Whether this outcome is worth coming back for.\n *\n * TRUE only for the two refusals a later attempt can pass: the platform hour\n * rolls, and so does the marketing frequency window. Everything else is\n * either a delivery that happened or a failure a retry repeats — a\n * suppression most of all, since retrying is the exact behavior a suppression\n * exists to stop, and a sunset for the same reason at one remove: it clears\n * when the recipient engages, which no amount of further mail from us brings\n * about.\n *\n * A resumable sweep uses this to decide whether to leave its subject\n * unstamped. Stamping on a deferrable refusal discards a message; NOT\n * stamping on a terminal one re-reads the same doomed row on every beat until\n * it crowds out the work that could succeed. Both are silent, so the\n * distinction lives here instead of at each sweep.\n *\n * A FUNCTION rather than `result.reason === …` at each call site, because\n * `strictNullChecks` is OFF repo-wide and TypeScript will not narrow a\n * boolean-literal discriminant without it — the same reason\n * {@link rateLimitedRetryAtMs} beside it is one.\n */\nexport function isDeferrableSendResult(\n result: SendEmailResult | null | undefined,\n): boolean {\n const reason = sendFailureReason(result)\n return reason === 'rate-limited' || reason === 'frequency-capped'\n}\n\n/**\n * Who a message is for, as the deliverability preflight reads it\n * (AGL-3328): `bulk` for anything a control may already refuse — marketing\n * by either of its two marks, a campaign, a resumable sweep — and\n * `transactional` for the rest, which a gateway hold never touches.\n *\n * Derived from what a sender already declares, for the reason\n * `isMarketingMessage` is: a label callers had to remember would be\n * forgotten by the one that matters, and the permissive answer is the one a\n * forgotten caller gets.\n */\nexport function emailSendPurpose(\n options: Pick<SendEmailOptions, 'priority' | 'context' | 'headers'> & { marketing?: unknown },\n): EmailSendPurpose {\n if (isMarketingMessage(options)) return 'bulk'\n if (options.headers?.['List-Unsubscribe']) return 'bulk'\n return isRefusablePriority(resolveSendPriority(options.context, options.priority))\n ? 'bulk'\n : 'transactional'\n}\n\n/** The preflight's answer, or `null` when none is installed, it failed or it ran out of time. */\nasync function askDeliverabilityPreflight(\n request: Parameters<NonNullable<ReturnType<typeof getEmailDeliverabilityPreflight>>>[0],\n label: string,\n): Promise<EmailDeliverabilityPreflightVerdict | null> {\n const preflight = getEmailDeliverabilityPreflight()\n if (!preflight) return null\n let timer: ReturnType<typeof setTimeout> | undefined\n try {\n return await Promise.race([\n preflight(request),\n new Promise<null>((resolve) => {\n timer = setTimeout(() => {\n console.warn(`${label} deliverability preflight timed out — allowing`)\n resolve(null)\n }, EMAIL_DELIVERABILITY_PREFLIGHT_TIMEOUT_MS)\n ;(timer as { unref?: () => void })?.unref?.()\n }),\n ])\n } catch (error) {\n // Fails OPEN, the governor's posture: an outage on the control must not\n // become an outage on the mail.\n console.error(`${label} deliverability preflight failed — allowing`, error)\n return null\n } finally {\n if (timer) clearTimeout(timer)\n }\n}\n\n/**\n * The send seam's half of the outbound phishing screen (AGL-3356): every\n * message a SITE sends whose identity carries the workspace\n * `hostSendingIdentity` stamped, through {@link screenTenantMessage} — the\n * pure screen, its tiers, and the installed review store. `null` when the\n * message may go; fails open, as that function documents.\n */\nexport async function askOutboundScreen(\n options: SendEmailOptions,\n label = 'email',\n): Promise<SendEmailResult | null> {\n const workspace = options.sendingIdentity?.workspace\n if (options.audience !== 'tenant' || !workspace?.hostId) return null\n const refusal = await screenTenantMessage(\n {\n workspace,\n subject: options.subject,\n fromName: options.fromName ?? null,\n // The resolved address, never anything the caller passed: the only\n // `From:` this message can leave on (AGL-3362).\n fromAddress: options.sendingIdentity?.from ?? null,\n replyTo: options.replyTo ?? null,\n bodies: [options.text, options.html],\n // A marketing message is never owed, whatever it declares.\n owed: Boolean(options.owedFor) && !isMarketingMessage(options),\n context: options.context ?? null,\n releasedReviewId: options.releasedReviewId ?? null,\n },\n label,\n )\n if (!refusal) return null\n console.warn(`${label} not sent — ${refusal.detail}`)\n return { sent: false, reason: 'held-for-review', detail: refusal.detail }\n}\n\nexport interface EmailConfig {\n apiKey: string | undefined\n from: string | undefined\n}\n\n/**\n * Reads the email environment.\n *\n * Deliberately read per call rather than captured at module load: these run\n * in serverless handlers where the module may be evaluated during a build,\n * long before the runtime env exists.\n */\nexport function getEmailConfig(): EmailConfig {\n return {\n apiKey: process.env.RESEND_API_KEY || undefined,\n from: process.env.USAGE_EMAIL_FROM || undefined,\n }\n}\n\n/**\n * True when both `RESEND_API_KEY` and `USAGE_EMAIL_FROM` are present.\n *\n * Callers that answer an HTTP request (rather than firing best-effort mail)\n * use this to return a 501 with an actionable message instead of pretending\n * to have sent something.\n */\nexport function isEmailConfigured(): boolean {\n const { apiKey, from } = getEmailConfig()\n return Boolean(apiKey && from)\n}\n\n/**\n * Applies a white-label display name to a configured sender while keeping\n * its verified address (White-Label Phase 1). Accepts either a bare address\n * (`noreply@aglyn.com`) or an RFC-5322 `Name <addr>` header and returns\n * `\"<fromName>\" <addr>`. A blank name, or a value with no extractable\n * address, yields the original `from` untouched — the sender identity is\n * never dropped on the floor.\n */\nexport function applyFromName(\n from: string | undefined,\n fromName: string | undefined,\n): string | undefined {\n const name = (fromName ?? '').trim()\n if (!from || !name) return from\n const angle = from.match(/<([^>]+)>/)\n const address = (angle ? angle[1] : from).trim()\n if (!address.includes('@')) return from\n // Quote the display name so commas/specials stay inside one mailbox.\n return `\"${name.replace(/\"/g, '')}\" <${address}>`\n}\n\nfunction normalizeRecipients(to: string | string[]): string[] {\n const list = Array.isArray(to) ? to : [to]\n return list\n .map((address) => String(address ?? '').trim())\n .filter((address) => address.includes('@'))\n}\n\n/** A Resend send payload in the provider's own wire shape. */\nexport interface ResendSendPayload {\n to?: unknown\n from?: unknown\n subject?: unknown\n [field: string]: unknown\n}\n\n/**\n * The one place that POSTs to Resend's send endpoint, and the last thing\n * standing between a payload and the network.\n *\n * A payload carrying no recipient cannot become a message. Resend answers it\n * `422 missing_required_field`, which costs an API call and then shows up in\n * the vendor dashboard as a red line indistinguishable from mail that\n * genuinely failed to deliver — carrying no subject, no recipient and nothing\n * naming the code that produced it. Diagnosing that means reading a log\n * outside the deployment and guessing. So the refusal happens here, before\n * the fetch, and names the caller's `context`.\n *\n * It throws rather than returning a `SendEmailResult`: this is a programming\n * error, not a delivery outcome. `sendEmail` filters recipients well before\n * it reaches this call, so nothing on the ordinary path can trip it. The\n * guard exists because `RESEND_SEND_ENDPOINT` is exported and any module can\n * therefore reach the send endpoint on its own, bypassing every check\n * `sendEmail` owns.\n */\nexport async function postResendEmail(\n apiKey: string,\n payload: ResendSendPayload,\n context?: string,\n): Promise<Response> {\n const raw = payload?.to\n const recipients = (Array.isArray(raw) ? raw : raw == null ? [] : [raw])\n .map((address) => String(address ?? '').trim())\n .filter(Boolean)\n if (!recipients.length) {\n throw new Error(\n `${context ? `${context} ` : ''}send refused before the network — a ` +\n 'Resend payload with no `to` field cannot become a message, and the ' +\n 'attempt would surface only as a 422 in the Resend dashboard',\n )\n }\n\n return fetch(RESEND_SEND_ENDPOINT, {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${apiKey}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(payload),\n })\n}\n\n/**\n * Sends one email through Resend.\n *\n * **Never throws and never rejects.** Outbound mail is best-effort across\n * every caller in this codebase — a checkout must not fail because a receipt\n * bounced — so every outcome comes back as a `SendEmailResult` instead. The\n * one thing callers must not do is ignore the result: `sent` is what tells\n * the user whether a message actually went out (AGL-708).\n *\n * When the env vars are missing this warns once per call and returns\n * `{ sent: false, reason: 'unconfigured' }` rather than failing, so local and\n * preview environments keep working without a Resend account.\n */\nexport async function sendEmail(\n options: SendEmailOptions,\n): Promise<SendEmailResult> {\n const { apiKey, from: configuredFrom } = getEmailConfig()\n const label = options.context ? `${options.context} email` : 'email'\n\n /*\n * THE SENDING-IDENTITY REFUSAL.\n *\n * Enforced here AND at the campaign route, independently, for the reason\n * the send-rate governor is enforced twice: the route's check is the one a\n * person sees, and this one is what holds when a caller does not make it.\n * A governor is injectable and a route is skippable, so neither may be the\n * only thing standing between an unverified domain and a send.\n *\n * Placed above the `apiKey`/`from` gate so a refusal cannot be reported as\n * `unconfigured` — the two have different owners and different fixes.\n */\n const identityRefusal = sendingIdentityRefusal(options.sendingIdentity)\n if (identityRefusal) {\n console.warn(`${label} refused — ${identityRefusal.message}`)\n return {\n sent: false,\n // A suspension is not a DNS state, and the merchant reading the\n // failure has nothing to finish — so it is named for what it is.\n reason:\n identityRefusal.code === 'workspace-suspended'\n ? 'suspended'\n : 'unverified-domain',\n detail: identityRefusal.message,\n }\n }\n\n // A resolved identity outranks the configured sender: it is the server's\n // answer to which verified address this message leaves on. Without one, the\n // white-label display name is applied to the configured verified sender\n // (White-Label Phase 1).\n //\n // Two sources, and `options` is neither of them. Nothing the caller passes\n // reaches the address — only the display name in front of it.\n const resolvedFrom = options.sendingIdentity?.from ?? null\n\n /*\n * THE PLATFORM DOMAIN IS NOT A FALLBACK FOR TENANT MAIL.\n *\n * `configuredFrom` is `USAGE_EMAIL_FROM` — an address on `aglyn.com`, where\n * Aglyn's own billing, account and console mail leaves from. A site's mail\n * reaching it means that site's list quality is charged against the domain\n * every other customer's password reset depends on.\n *\n * `resolveHostSendingIdentity` already refuses above, so a tenant caller\n * that resolved an identity never arrives here with `resolvedFrom` null.\n * This is the arm for a tenant caller that resolved NOTHING — the shape a\n * new send site takes when its author does not know an identity is owed —\n * and it is checked here rather than left to the call sites because ninety\n * of them cannot each be relied on to remember.\n */\n if (options.audience === 'tenant' && !resolvedFrom) {\n console.warn(\n `${label} refused — a site's mail cannot leave on the shared platform ` +\n 'domain, and no sending identity was resolved for it',\n )\n return {\n sent: false,\n reason: 'unverified-domain',\n detail:\n 'This message belongs to a site and no sending identity was ' +\n 'resolved for it, so it was refused rather than sent from the ' +\n 'shared Aglyn address.',\n }\n }\n\n const from = applyFromName(\n resolvedFrom ?? configuredFrom,\n options.fromName,\n )\n\n if (!apiKey || !from) {\n console.warn(\n `${label} skipped — set RESEND_API_KEY and USAGE_EMAIL_FROM to ` +\n 'deliver mail',\n )\n return { sent: false, reason: 'unconfigured' }\n }\n\n let to = normalizeRecipients(options.to)\n if (!to.length) {\n console.warn(`${label} skipped — no valid recipient address`)\n return { sent: false, reason: 'no-recipient' }\n }\n\n /*\n * THE OUTBOUND PHISHING SCREEN (AGL-3356).\n *\n * Asked for every message a SITE sends whose identity came through\n * `hostSendingIdentity` — which stamps the workspace on it — so one check\n * covers every tenant sender, first-party or marketplace, without any of\n * them calling it. See `outbound-screen-gate.ts`.\n *\n * Ahead of the preflight and every other gate: a message that must not\n * leave is decided before anything is spent on who receives it.\n */\n const screenRefusal = await askOutboundScreen(options, label)\n if (screenRefusal) return screenRefusal\n\n /*\n * THE DELIVERABILITY PREFLIGHT (AGL-3328).\n *\n * Asked before every other gate, because its refusal is the most final: a\n * recipient whose domain takes no mail is not a recipient this message or\n * any other can reach, and spending the marketing gate's reads or the\n * governor's hourly budget deciding more about them would be spending it\n * on a bounce. A recipient behind a gateway that refused this sending\n * domain twice is held — for bulk mail only; the purpose decides that,\n * and the preflight is told it.\n *\n * A multi-recipient message drops the refused and held recipients and goes\n * to the rest: one colleague's dead address must not stop the others'\n * notification.\n */\n const sendingAddress = bareSenderAddress(from)\n const preflight = await askDeliverabilityPreflight(\n {\n recipients: to.map((address) => address.toLowerCase()),\n purpose: emailSendPurpose(options),\n sendingDomain: sendingAddress ? sendingAddress.slice(sendingAddress.lastIndexOf('@') + 1) : null,\n sendingSource: options.sendingIdentity?.source ?? 'platform',\n context: options.context,\n hostId: options.marketing?.hostId ?? null,\n ...(options.accountBanExempt ? { accountBanExempt: true } : {}),\n },\n label,\n )\n if (preflight && (preflight.refused?.length || preflight.held?.length)) {\n const stopped = new Set(\n [...(preflight.refused ?? []), ...(preflight.held ?? [])].map((entry) =>\n String(entry?.email ?? '').toLowerCase(),\n ),\n )\n const remaining = to.filter((address) => !stopped.has(address.toLowerCase()))\n if (!remaining.length) {\n const refused = preflight.refused?.[0]\n const held = preflight.held?.[0]\n const detail = refused?.reason ?? held?.reason ?? 'The recipient cannot receive this message.'\n console.warn(`${label} not sent — ${detail}`)\n return { sent: false, reason: refused ? 'undeliverable' : 'gateway-held', detail }\n }\n console.warn(\n `${label} — ${to.length - remaining.length} of ${to.length} recipients ` +\n 'dropped by the deliverability preflight',\n )\n to = remaining\n }\n\n /*\n * THE MARKETING GATE.\n *\n * Everything a marketing message owes, asked once, here — because the four\n * merchant-triggered bulk paths that owed it discharged none of it, and\n * asking four call sites to remember is the shape that produces the fifth\n * that does not.\n *\n * Ahead of the send-rate governor deliberately. A refusal here is a message\n * that must never leave, so spending platform hourly budget deciding that\n * would be budget the rest of the hour's mail no longer has.\n *\n * Nothing installed is UNGATED. Same posture as the governor: the durable\n * half lives in another library, and a deployment that never installs it\n * must still send.\n */\n /*\n * TWO URLS, and which one goes where is the whole RFC 8058 story.\n *\n * `unsubscribeUrl` is what a PERSON clicks in the footer below, and the\n * gate points it at the preference page so the stream this message belongs\n * to is one of the things they can stop instead of all of it.\n * `oneClickUrl` is what `List-Unsubscribe` names, and it must stay on the\n * route whose POST writes immediately — a mailbox provider POSTs it with\n * nobody present and reads the 200 as \"done\", which a page of checkboxes\n * cannot honor.\n *\n * It falls back to `unsubscribeUrl` for a caller that minted one link and\n * named no page, which is the only shape that was ever possible before the\n * gate minted the pair.\n */\n let unsubscribeUrl = options.marketing?.unsubscribeUrl ?? ''\n let oneClickUrl = options.marketing?.oneClickUrl ?? ''\n if (options.marketing) {\n if (to.length !== 1) {\n // Not a delivery outcome — a caller error, and one that would put the\n // first recipient's signed unsubscribe link in everybody else's copy.\n console.error(\n `${label} refused — a marketing send addresses exactly one ` +\n `recipient, and this one names ${to.length}`,\n )\n return {\n sent: false,\n reason: 'no-recipient',\n detail: 'A marketing send addresses exactly one recipient.',\n }\n }\n const gate = getMarketingSendGate()\n if (gate) {\n let verdict: Awaited<ReturnType<typeof gate>> | null\n try {\n verdict = await gate({\n hostId: options.marketing.hostId,\n siteBase: options.marketing.siteBase,\n email: to[0],\n context: options.context,\n capped: options.marketing.capped !== false,\n // Passed through verbatim, INCLUDING absent. A default topic\n // applied here would invent a stream for every caller that named\n // none and let a topic opt-out refuse messages that belong to no\n // stream — see `MarketingSendContext.topicId`.\n ...(options.marketing.topicId\n ? { topicId: options.marketing.topicId }\n : {}),\n consentHostIds: options.marketing.consentHostIds,\n consentAwaitsConfirmation: options.marketing.consentAwaitsConfirmation,\n })\n } catch (error) {\n /*\n * FAILS OPEN, and the asymmetry with `filterSendableForHost` is\n * deliberate rather than an oversight. That helper fails CLOSED\n * because a suppression list it could not read is not a list that\n * said an address is safe to mail — and it keeps doing so, inside\n * the gate. What is being caught here is the gate itself being\n * unreachable or throwing, which is an outage on the control; an\n * outage on a control that becomes an outage on the product is the\n * worse of the two bugs, and it is the posture `sendEmail` takes\n * everywhere else.\n */\n console.error(`${label} marketing gate failed — allowing`, error)\n verdict = null\n }\n if (verdict && !verdict.allowed) {\n /*\n * A cadence refusal reports as `frequency-capped` rather than earning\n * a value of its own in {@link SendEmailFailureReason}.\n *\n * That union is what {@link isDeferrableSendResult} switches on, and\n * the two are deferrable for exactly the same reason: a later attempt\n * passes because time went by. A third value would have to be added\n * to that predicate as well, and a sweep built against the older\n * vocabulary would silently treat the recipient's own request as\n * terminal and stamp the subject — discarding a message the recipient\n * asked to receive later rather than never. Which of the two it was\n * is in `detail`, where a person reading a log needs it.\n */\n const reason: SendEmailFailureReason =\n verdict.refusal === 'frequency-capped' ||\n verdict.refusal === 'cadence-limited'\n ? 'frequency-capped'\n : verdict.refusal === 'unengaged'\n ? 'unengaged'\n : 'suppressed'\n console.warn(`${label} not sent — ${verdict.detail ?? reason}`)\n return { sent: false, reason, detail: verdict.detail }\n }\n unsubscribeUrl = unsubscribeUrl || verdict?.unsubscribeUrl || ''\n oneClickUrl = oneClickUrl || verdict?.oneClickUrl || ''\n }\n if (!unsubscribeUrl) {\n // A marketing message with no way out is the defect this gate exists to\n // close, so it is said out loud rather than shipped quietly. Not a\n // refusal: the cause is a missing `EMAIL_UNSUBSCRIBE_SECRET` or a host\n // with no public origin — an operator's configuration, not the\n // recipient's problem — and refusing here would turn it into silence.\n console.warn(\n `${label} carries no unsubscribe link — set ` +\n 'EMAIL_UNSUBSCRIBE_SECRET and publish the site on a domain',\n )\n }\n }\n\n /*\n * BULK MAIL WITH NO WAY OUT DOES NOT LEAVE ON THE POOLED IDENTITY.\n *\n * The pool carries marketing for every site that has no domain of its own,\n * and what keeps that survivable is that a recipient can always stop it: the\n * complaint that would otherwise be charged to every other site on the member\n * has a cheaper alternative one click away. A message that lost its\n * unsubscribe link removes that alternative, so it is refused here rather\n * than sent at other people's expense.\n *\n * Asked AFTER the gate, because only here is the answer known. The URL may\n * arrive from the caller, from the gate that mints one per recipient, or —\n * for a campaign, which composes its own one-click pair upstream — as a\n * `List-Unsubscribe` header and no marketing context at all. Reading only one\n * of the three would refuse the senders that are behaving.\n *\n * The classification is DERIVED, never declared. See `isMarketingMessage`.\n */\n const pooledRefusal = isMarketingMessage(options)\n ? pooledMarketingRefusal(\n options.sendingIdentity,\n Boolean(unsubscribeUrl) ||\n Boolean(options.headers?.['List-Unsubscribe']),\n )\n : null\n if (pooledRefusal) {\n console.warn(`${label} refused — ${pooledRefusal.message}`)\n return {\n sent: false,\n reason: 'unverified-domain',\n detail: pooledRefusal.message,\n }\n }\n\n /*\n * THE PLATFORM SEND-RATE GOVERNOR (AGL-2409).\n *\n * Asked on EVERY send, including transactional ones, because the ceiling is\n * about total volume on one sending domain — a governor that only saw\n * campaigns would report a quiet hour while ten thousand receipts went out.\n * The governor counts what it grants.\n *\n * Two properties this block must have, in order:\n *\n * 1. **A refusal is honoured only for a refusable priority.** This is the\n * second of the two enforcement points described in `send-rate.ts`.\n * `emailSendRateVerdict` already cannot refuse a transactional send;\n * the governor is INJECTABLE, so a wrong one is reachable, and the send\n * path must still be unable to drop a password reset. Anything that is\n * not explicitly a campaign or a bulk sweep sends regardless of the\n * answer.\n * 2. **It fails open.** A governor that throws — Firestore unreachable, no\n * Admin app, a bug — must not stop mail. The counter being unavailable\n * is an outage on the control, and an outage on a control that turns\n * into an outage on the product is a worse bug than the burst it was\n * guarding. The same posture `sendEmail` takes everywhere else: it\n * never throws, and neither does this.\n */\n const priority = resolveSendPriority(options.context, options.priority)\n const governor = getEmailSendGovernor()\n if (governor) {\n let verdict: Awaited<ReturnType<typeof governor>> | null\n try {\n verdict = await governor({\n priority,\n count: to.length,\n context: options.context,\n })\n } catch (error) {\n console.error(`${label} send-rate governor failed — allowing`, error)\n verdict = null\n }\n if (verdict && !verdict.allowed && isRefusablePriority(priority)) {\n const retryAtMs =\n verdict.retryAtMs ?? emailSendRateWindowStartMs(Date.now())\n console.warn(\n `${label} deferred — platform send rate reached ` +\n `(${verdict.used ?? '?'}/${verdict.ceiling ?? '?'} this hour)`,\n )\n return {\n sent: false,\n reason: 'rate-limited',\n retryAtMs,\n detail:\n `Platform hourly send rate reached (${verdict.ceiling ?? '?'}/hour). ` +\n 'Transactional mail is unaffected.',\n }\n }\n }\n\n /*\n * THE VISIBLE OPT-OUT, on both parts.\n *\n * The header pair below is for the mailbox provider; this is for the person\n * — CAN-SPAM asks for a mechanism the recipient can see and use, and most\n * clients render no control for the header at all. Both helpers are\n * idempotent by URL, so a sender that placed its own link (a designed\n * template rendering `{{unsubscribeUrl}}`, the campaign body's footer)\n * keeps its own placement and does not get a second one.\n *\n * `text` first and `html` from the result, so the synthesized HTML part\n * that stands in for a text-only message carries the link as an anchor\n * rather than as characters.\n */\n const text = unsubscribeUrl\n ? appendUnsubscribeText(options.text ?? '', unsubscribeUrl)\n : options.text\n const html = unsubscribeUrl\n ? options.html\n ? appendUnsubscribeHtml(options.html, unsubscribeUrl)\n : renderTextEmailHtml(text ?? '', options.subject)\n : options.html\n\n try {\n const response = await postResendEmail(\n apiKey,\n {\n from,\n to,\n subject: options.subject,\n ...(text ? { text } : {}),\n // The HTML part, from the caller when it has one and otherwise\n // synthesized from `text`. A message with no HTML part carries no\n // anchors, so its links are not links in the inbox AND Resend has\n // nothing to rewrite for click tracking — see `text-email-html.ts`.\n // The caller always wins: this can only fill a gap, never override a\n // designed template.\n ...(() => {\n const body = html || renderTextEmailHtml(text ?? '', options.subject)\n return body ? { html: body } : {}\n })(),\n // The caller's headers plus the RFC 8058 pair for a marketing send.\n // Caller-first, so the campaign sender's own pair is the one that\n // ships and a merchant-authored header is never silently replaced.\n ...(() => {\n const headers = {\n ...unsubscribeHeaders(oneClickUrl || unsubscribeUrl),\n ...(options.headers ?? {}),\n }\n return Object.keys(headers).length ? { headers } : {}\n })(),\n // The caller's tags plus the `context` tag (AGL-2407). Caller-first\n // so a sender that stamps its own `context` keeps it: a tag list with\n // two entries of one name is not a shape worth discovering in\n // production, and the explicit one is the more specific.\n ...(() => {\n const caller = options.tags ?? []\n const derived = caller.some((tag) => tag?.name === 'context')\n ? []\n : contextTag(options.context)\n const tags = [...caller, ...derived]\n return tags.length ? { tags } : {}\n })(),\n ...(options.replyTo ? { reply_to: options.replyTo } : {}),\n },\n options.context,\n )\n\n if (!response.ok) {\n const detail = await response.text().catch(() => '')\n /*\n * A 429 IS NOT A STATEMENT ABOUT THIS RECIPIENT, so it does not report\n * as one. See the `rate-limited` member of\n * {@link SendEmailFailureReason} for why the distinction is load-bearing\n * rather than cosmetic — every caller in the tree already has a branch\n * for a deferral, and none of them has one for \"rejected, but try this\n * exact address again later\".\n *\n * A warning rather than an error: the provider asking for a slower pace\n * is a normal thing to be told, and logging it at the level a failed\n * delivery uses is what teaches an operator to skim past both.\n */\n if (response.status === 429) {\n const retryAtMs = providerRetryAtMs(response.headers)\n console.warn(\n `${label} deferred — the provider is rate limiting; retry in ` +\n `${Math.max(0, Math.round((retryAtMs - Date.now()) / 1000))}s`,\n )\n return {\n sent: false,\n reason: 'rate-limited',\n status: response.status,\n retryAtMs,\n detail: detail.slice(0, 500),\n }\n }\n console.error(`${label} failed`, response.status, detail)\n return {\n sent: false,\n reason: 'rejected',\n status: response.status,\n detail: detail.slice(0, 500),\n }\n }\n\n const body = (await response.json().catch(() => null)) as {\n id?: string\n } | null\n return { sent: true, id: body?.id ?? null }\n } catch (error) {\n console.error(`${label} failed`, error)\n return {\n sent: false,\n reason: 'network',\n detail: String((error as Error)?.message ?? error).slice(0, 500),\n }\n }\n}\n\nexport default sendEmail\n"],"names":["emailSendRateWindowStartMs","getEmailSendGovernor","isRefusablePriority","resolveSendPriority","renderTextEmailHtml","sendingIdentityRefusal","pooledMarketingRefusal","appendUnsubscribeHtml","appendUnsubscribeText","getMarketingSendGate","isMarketingMessage","unsubscribeHeaders","EMAIL_DELIVERABILITY_PREFLIGHT_TIMEOUT_MS","getEmailDeliverabilityPreflight","bareSenderAddress","screenTenantMessage","RESEND_SEND_ENDPOINT","contextTag","context","value","String","trim","replace","slice","name","rateLimitedRetryAtMs","result","failure","reason","retryAtMs","Number","isFinite","PROVIDER_RETRY_FALLBACK_MS","PROVIDER_RETRY_MAX_MS","headerSeconds","headers","raw","get","undefined","seconds","providerRetryAtMs","nowMs","Date","now","waitMs","Math","min","sendFailureReason","sent","isDeferrableSendResult","emailSendPurpose","options","priority","askDeliverabilityPreflight","request","label","preflight","timer","Promise","race","resolve","setTimeout","console","warn","unref","error","clearTimeout","askOutboundScreen","workspace","sendingIdentity","audience","hostId","refusal","subject","fromName","fromAddress","from","replyTo","bodies","text","html","owed","Boolean","owedFor","releasedReviewId","detail","getEmailConfig","apiKey","process","env","RESEND_API_KEY","USAGE_EMAIL_FROM","isEmailConfigured","applyFromName","angle","match","address","includes","normalizeRecipients","to","list","Array","isArray","map","filter","postResendEmail","payload","recipients","length","Error","fetch","method","Authorization","body","JSON","stringify","sendEmail","configuredFrom","identityRefusal","message","code","resolvedFrom","screenRefusal","sendingAddress","toLowerCase","purpose","sendingDomain","lastIndexOf","sendingSource","source","marketing","accountBanExempt","refused","held","stopped","Set","entry","email","remaining","has","unsubscribeUrl","oneClickUrl","gate","verdict","siteBase","capped","topicId","consentHostIds","consentAwaitsConfirmation","allowed","pooledRefusal","governor","count","used","ceiling","response","Object","keys","caller","tags","derived","some","tag","reply_to","ok","catch","status","max","round","json","id"],"mappings":"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"}
|