@aglyn/shared-util-email 1.0.0-beta.201 → 1.0.0-beta.203

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.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/outbound-phishing-screen.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 OUTBOUND PHISHING SCREEN (AGL-3356).\n *\n * A fraud actor signed up, paid with a card that never met 3-D Secure, and\n * used a workspace to send two kinds of mail: a campaign of Booking.com-style\n * \"guest feedback regarding your property\" lures to hotels, and a workflow\n * that mailed \"Poshmark Order — one of the items from your Seller Account has\n * finally sold\", linking to `poshmark.id63835663.shop`.\n *\n * This module answers one question about a message a YOUNG workspace is\n * about to send: does its content carry a STRONG phishing signal? The caller\n * decides who is young and what happens to a positive answer — the answer is\n * a hold for staff review, never a drop.\n *\n * ## Conservative by construction\n *\n * A false positive costs a real merchant's first campaign a wait for a\n * human; a miss costs strangers their bank logins. Both are real, so only\n * three shapes hold, and each is one a legitimate merchant essentially never\n * produces:\n *\n * 1. **A lookalike link** — a link or reply address whose host carries a\n * brand's name without being the brand's own domain: the brand as a label\n * of an unrelated registrable domain (`poshmark.id63835663.shop`), hyphen-\n * joined to one (`paypal-secure.com`, `secure-paypal.net`), the brand's\n * real domain embedded in a longer host (`booking.com.guest-review.top`),\n * or leetspeak for it (`paypa1.com`). Holds on its own.\n * 2. **A brand in the sender's display name** that is not the workspace's\n * own name — mail that says it is FROM PayPal.\n * 3. **The three-part lure**: a brand named in the copy, AND credential or\n * account-action phrasing (\"verify your account\", \"has finally sold\",\n * \"feedback regarding your property\"), AND a link to a domain that is\n * neither the brand's, nor the workspace's, nor a common social or maps\n * link. Any two of the three are ordinary marketing — \"our book is on\n * Amazon\", \"sign in to your account\", a link to the shop — and pass.\n *\n * Mentioning a brand, alone, never holds.\n *\n * ## The brand list is small on purpose\n *\n * The brands phishing kits most often wear, plus the two in the incident. A\n * brand is added when it has been seen impersonated, not because it is big:\n * every entry is a set of words that can now hold somebody's newsletter.\n * Each carries its REAL domains, so a link to the brand itself is never\n * suspicious, and host tokens chosen so a common word (`booking`, `apple`,\n * `outlook`) does not match an unrelated host by itself.\n *\n * Pure: no DNS, no store, no clock. Everything the caller knows about the\n * workspace arrives as input.\n *=========================================*/\n\n/** One brand the screen recognizes. */\nexport interface PhishingScreenBrand {\n /** Stable id, as stored on a hold. */\n id: string\n /** How staff read it. */\n label: string\n /** Copy that names the brand — subject, body, sender. Case-insensitive. */\n mention: RegExp\n /**\n * Host labels that name the brand. Matched against each dot-separated\n * label of a link's host, whole or hyphen-joined, after leetspeak is\n * folded — never as a bare substring, which is how `amazonaws` or\n * `bookingengine` would read as a brand.\n */\n hostTokens: readonly string[]\n /**\n * Registrable domains the brand genuinely links from. The FIRST is its\n * primary domain, the one a kit embeds in a longer host.\n */\n officialDomains: readonly string[]\n /**\n * The brand's name is an ordinary word (`apple`, `amazon`), so a host\n * label names the brand only when it IS the word — `apple.example.top` —\n * and never hyphen-joined or leet-folded, which `apple-orchard-farm.com`\n * would otherwise be.\n */\n wordToken?: boolean\n /**\n * Whether `<brand>.<country code>` (and `<brand>.co.<cc>`) is the brand's\n * too — true for the marketplaces that run a site per country.\n */\n countryDomains?: boolean\n}\n\nexport const PHISHING_SCREEN_BRANDS: readonly PhishingScreenBrand[] = [\n {\n id: 'poshmark',\n label: 'Poshmark',\n mention: /\\bposhmark\\b/i,\n hostTokens: ['poshmark'],\n officialDomains: ['poshmark.com', 'posh.mk'],\n },\n {\n id: 'booking',\n label: 'Booking.com',\n // Never the bare word: \"booking\" is every hotel's and every salon's.\n mention: /\\bbooking\\.com\\b/i,\n hostTokens: ['bookingcom', 'booking-com'],\n officialDomains: ['booking.com', 'bstatic.com'],\n },\n {\n id: 'airbnb',\n label: 'Airbnb',\n mention: /\\bairbnb\\b/i,\n hostTokens: ['airbnb'],\n officialDomains: ['airbnb.com', 'abnb.me', 'muscache.com'],\n countryDomains: true,\n },\n {\n id: 'paypal',\n label: 'PayPal',\n mention: /\\bpay\\s?pal\\b/i,\n hostTokens: ['paypal'],\n officialDomains: ['paypal.com', 'paypal.me', 'paypalobjects.com'],\n countryDomains: true,\n },\n {\n id: 'amazon',\n label: 'Amazon',\n mention: /\\bamazon\\b/i,\n hostTokens: ['amazon', 'amzn'],\n wordToken: true,\n officialDomains: [\n 'amazon.com',\n 'amzn.to',\n 'amzn.com',\n 'amazonaws.com',\n 'media-amazon.com',\n 'ssl-images-amazon.com',\n ],\n countryDomains: true,\n },\n {\n id: 'apple',\n label: 'Apple',\n // The fruit is on every bakery's menu; the account is what gets phished.\n mention: /\\bapple\\s?(?:id|support|account)\\b|\\bicloud\\b|\\bitunes\\b/i,\n hostTokens: ['apple', 'appleid', 'icloud', 'itunes'],\n wordToken: true,\n officialDomains: ['apple.com', 'icloud.com', 'apple.co', 'itunes.com', 'me.com', 'mzstatic.com'],\n countryDomains: true,\n },\n {\n id: 'microsoft',\n label: 'Microsoft',\n mention: /\\bmicrosoft\\b|\\boffice\\s?365\\b|\\bonedrive\\b|\\bsharepoint\\b/i,\n hostTokens: ['microsoft', 'office365', 'onedrive', 'sharepoint', 'microsoftonline', 'msonline'],\n officialDomains: [\n 'microsoft.com',\n 'office.com',\n 'office365.com',\n 'live.com',\n 'outlook.com',\n 'microsoftonline.com',\n 'sharepoint.com',\n 'onedrive.com',\n '1drv.ms',\n 'aka.ms',\n ],\n },\n {\n id: 'docusign',\n label: 'DocuSign',\n mention: /\\bdocu\\s?sign\\b/i,\n hostTokens: ['docusign'],\n officialDomains: ['docusign.com', 'docusign.net'],\n },\n {\n id: 'netflix',\n label: 'Netflix',\n mention: /\\bnetflix\\b/i,\n hostTokens: ['netflix'],\n officialDomains: ['netflix.com', 'nflxext.com', 'nflximg.net'],\n },\n {\n id: 'wellsfargo',\n label: 'Wells Fargo',\n mention: /\\bwells\\s?fargo\\b/i,\n hostTokens: ['wellsfargo', 'wells-fargo'],\n officialDomains: ['wellsfargo.com'],\n },\n {\n id: 'bankofamerica',\n label: 'Bank of America',\n mention: /\\bbank\\s+of\\s+america\\b/i,\n hostTokens: ['bankofamerica', 'bofa'],\n officialDomains: ['bankofamerica.com', 'bofa.com'],\n },\n {\n id: 'coinbase',\n label: 'Coinbase',\n mention: /\\bcoinbase\\b/i,\n hostTokens: ['coinbase'],\n officialDomains: ['coinbase.com'],\n },\n {\n id: 'usps',\n label: 'USPS',\n mention: /\\busps\\b/i,\n hostTokens: ['usps'],\n officialDomains: ['usps.com'],\n },\n {\n id: 'fedex',\n label: 'FedEx',\n mention: /\\bfed\\s?ex\\b/i,\n hostTokens: ['fedex'],\n officialDomains: ['fedex.com'],\n },\n {\n id: 'dhl',\n label: 'DHL',\n mention: /\\bdhl\\b/i,\n hostTokens: ['dhl'],\n officialDomains: ['dhl.com'],\n countryDomains: true,\n },\n]\n\n/**\n * Account-action and credential phrasing — the half of a lure that asks the\n * reader to DO something with an account. Only ever counted beside a brand\n * AND a foreign link; alone, several of these are ordinary transactional copy.\n */\nexport const PHISHING_LURE_PATTERNS: readonly RegExp[] = [\n /\\bverify\\s+your\\s+(?:account|identity|information|details|payment|email)\\b/i,\n /\\bconfirm\\s+your\\s+(?:account|identity|details|payment|password|billing|information)\\b/i,\n /\\b(?:account|payment|card)\\s+(?:has\\s+been\\s+|is\\s+|was\\s+)?(?:suspended|locked|limited|restricted|disabled|on\\s+hold)\\b/i,\n /\\bunusual\\s+(?:sign[-\\s]?in|login|activity)\\b/i,\n /\\bupdate\\s+your\\s+(?:payment|billing|card|account)\\s*(?:details|information|method)?\\b/i,\n /\\b(?:sign|log)\\s?in\\s+to\\s+(?:your|view|confirm|claim|release)\\b/i,\n /\\b(?:has|have)\\s+(?:finally\\s+)?sold\\b/i,\n /\\bseller\\s+account\\b/i,\n /\\b(?:guest|customer)\\s+(?:complaint|review|feedback)\\b/i,\n /\\bfeedback\\s+regarding\\s+your\\s+(?:property|listing|stay|booking|reservation)\\b/i,\n /\\b(?:document|invoice|agreement)s?\\s+(?:is\\s+|are\\s+)?(?:ready|waiting|pending)\\s+(?:for\\s+)?(?:your\\s+)?(?:review|signature|to\\s+sign)\\b/i,\n /\\bpassword\\s+(?:will\\s+)?expire/i,\n /\\bclaim\\s+your\\s+(?:refund|reward|prize|package|payment|funds)\\b/i,\n /\\b(?:delivery|shipment)\\s+(?:failed|attempt|suspended|on\\s+hold)\\b/i,\n /\\b(?:package|parcel)\\s+(?:is\\s+)?(?:on\\s+hold|held|awaiting|undeliverable)\\b/i,\n /\\bsecurity\\s+alert\\b/i,\n]\n\n/**\n * Link targets a merchant's mail ordinarily carries that are not theirs:\n * social profiles, maps, review sites, video. Counted as neither foreign nor\n * suspicious in the three-part lure, so \"leave us a review on Google\" with a\n * brand named elsewhere does not become a hold. Never consulted by the\n * lookalike check, which holds on the host's own shape.\n */\nconst COMMON_LINK_DOMAINS: ReadonlySet<string> = new Set([\n 'facebook.com',\n 'fb.com',\n 'instagram.com',\n 'x.com',\n 'twitter.com',\n 'linkedin.com',\n 'youtube.com',\n 'youtu.be',\n 'tiktok.com',\n 'pinterest.com',\n 'google.com',\n 'goo.gl',\n 'g.page',\n 'yelp.com',\n 'tripadvisor.com',\n 'wa.me',\n 'whatsapp.com',\n])\n\n/**\n * Two-label public suffixes, so `evil.co.uk` registers as `evil.co.uk` and\n * not as `co.uk`. Deliberately a short list of the ones mail actually\n * arrives from rather than the whole public suffix list: a suffix missing\n * here makes the registrable domain one label too short, which can only\n * widen what counts as \"the same domain\" for a link — never invent a brand\n * label that is not in the host.\n */\nconst TWO_LABEL_SUFFIXES: ReadonlySet<string> = new Set([\n 'co.uk', 'org.uk', 'ac.uk', 'gov.uk', 'me.uk',\n 'com.au', 'net.au', 'org.au',\n 'co.nz', 'co.jp', 'co.kr', 'co.in', 'co.za', 'co.il', 'co.id', 'co.th',\n 'com.br', 'com.mx', 'com.ar', 'com.tr', 'com.cn', 'com.hk', 'com.sg',\n 'com.tw', 'com.my', 'com.ph', 'com.co', 'com.pe', 'com.sa', 'com.eg',\n])\n\n/** A signal the screen found. */\nexport type PhishingScreenSignal =\n | { code: 'lookalike-link'; brand: string; host: string }\n | { code: 'brand-sender'; brand: string; fromName: string }\n | { code: 'brand-lure-link'; brand: string; lure: string; host: string }\n /**\n * A published page asks for a password, a card number or a one-time code\n * in a field that is not one of the platform's own sign-in or checkout\n * elements (`hosted-page-screen.ts`). Strong.\n */\n | { code: 'credential-field'; field: 'password' | 'card' | 'otp'; label: string }\n /**\n * A published page names a brand that is not the workspace's beside a\n * sign-in, verify or payment call to action (`hosted-page-screen.ts`).\n * Soft.\n */\n | { code: 'brand-action-page'; brand: string; action: string }\n\nexport interface PhishingScreenInput {\n subject?: string | null\n /** The display name in front of the sending address. */\n fromName?: string | null\n /**\n * The ADDRESS the message leaves from (AGL-3362) — a `From:` domain is a\n * link the recipient never has to click, so its host is read by the\n * lookalike rule exactly like a link's: mail from `support@paypa1.com` is\n * the disguise whoever sends it. Not an own domain for the lure's \"is this\n * elsewhere\" test either way; it only ever adds a lookalike signal.\n */\n fromAddress?: string | null\n replyTo?: string | readonly string[] | null\n preheader?: string | null\n /**\n * Everything else the recipient reads or clicks: the body, the plain-text\n * part, a design's nodes serialized — any string. Links are found by\n * shape, so a JSON-encoded design is as readable here as HTML.\n */\n bodies?: readonly (string | null | undefined)[]\n /**\n * The workspace's own names — org, site, subdomain. A brand in one of them\n * is the workspace's own, and `brand-sender` / `brand-lure-link` do not\n * fire for it. The lookalike check does not consult names: a workspace\n * NAMED \"Poshmark\" linking to `poshmark.id63835663.shop` is the incident.\n */\n ownNames?: readonly (string | null | undefined)[]\n /**\n * The workspace's own hosts — its subdomain on the tenant apex, custom\n * domain, sending domain. A link to one of them, or below one, is never\n * the \"somewhere else\" of a lure. Matched by host, never by registrable\n * domain: every site's subdomain shares the tenant apex, and a link to a\n * NEIGHBOR's site is somewhere else.\n *\n * Not an exemption from the lookalike check. A host shaped like\n * `paypal-secure.com` is a lookalike whoever attached it, and a workspace\n * that attached it has attached the disguise.\n */\n ownDomains?: readonly (string | null | undefined)[]\n}\n\nexport interface PhishingScreenVerdict {\n /** True when the message should be held for staff review. */\n hold: boolean\n signals: PhishingScreenSignal[]\n}\n\n/** The registrable domain of a host: `a.b.evil.co.uk` → `evil.co.uk`. */\nexport function registrableDomain(host: string): string {\n const labels = String(host ?? '')\n .toLowerCase()\n .replace(/\\.+$/, '')\n .split('.')\n .filter(Boolean)\n if (labels.length <= 2) return labels.join('.')\n const lastTwo = labels.slice(-2).join('.')\n return TWO_LABEL_SUFFIXES.has(lastTwo)\n ? labels.slice(-3).join('.')\n : lastTwo\n}\n\n/** `<label>.<suffix>` of a registrable domain, split once. */\nfunction splitRegistrable(registrable: string): { label: string; suffix: string } {\n const dot = registrable.indexOf('.')\n return dot < 0\n ? { label: registrable, suffix: '' }\n : { label: registrable.slice(0, dot), suffix: registrable.slice(dot + 1) }\n}\n\nfunction isOfficialDomain(brand: PhishingScreenBrand, registrable: string): boolean {\n if (brand.officialDomains.includes(registrable)) return true\n if (!brand.countryDomains) return false\n const base = splitRegistrable(brand.officialDomains[0]).label\n const { label, suffix } = splitRegistrable(registrable)\n return label === base && (/^[a-z]{2}$/.test(suffix) || TWO_LABEL_SUFFIXES.has(suffix))\n}\n\n/** Every brand's real domains, for \"is this link just the brand itself\". */\nfunction isAnyOfficialDomain(registrable: string): boolean {\n return PHISHING_SCREEN_BRANDS.some((brand) => isOfficialDomain(brand, registrable))\n}\n\n/** Leetspeak folded, so `paypa1` reads as `paypal`. */\nfunction foldLeet(label: string): string {\n return label\n .replace(/0/g, 'o')\n .replace(/1/g, 'l')\n .replace(/3/g, 'e')\n .replace(/4/g, 'a')\n .replace(/5/g, 's')\n .replace(/7/g, 't')\n}\n\n/** Does one host label name the brand — whole, or hyphen-joined? */\nfunction labelNamesBrand(label: string, token: string, wordToken: boolean): boolean {\n if (wordToken) return label === token\n for (const candidate of new Set([label, foldLeet(label)])) {\n if (candidate === token) return true\n const parts = candidate.split('-')\n // A hyphen-joined token (`booking-com`) spans parts, so it is matched\n // as a run of parts rather than one of them.\n const tokenParts = token.split('-')\n for (let at = 0; at + tokenParts.length <= parts.length; at += 1) {\n if (tokenParts.every((part, offset) => parts[at + offset] === part)) {\n return true\n }\n }\n }\n return false\n}\n\n/** The brand a host impersonates, or null. */\nexport function lookalikeBrandForHost(host: string): PhishingScreenBrand | null {\n const normalized = String(host ?? '').toLowerCase().replace(/\\.+$/, '')\n if (!normalized.includes('.')) return null\n const registrable = registrableDomain(normalized)\n for (const brand of PHISHING_SCREEN_BRANDS) {\n if (isOfficialDomain(brand, registrable)) continue\n const labels = normalized.split('.')\n if (\n labels.some((label) =>\n brand.hostTokens.some((token) => labelNamesBrand(label, token, brand.wordToken === true)),\n )\n ) {\n return brand\n }\n // The brand's primary domain embedded in a longer, different one:\n // `booking.com.guest-review.top`, `paypal.com-secure.net`. Only the\n // primary: a short secondary (`me.com`, `aka.ms`) would match too much.\n const primary = brand.officialDomains[0]\n if (\n normalized.startsWith(`${primary}.`) ||\n normalized.startsWith(`${primary}-`) ||\n normalized.includes(`.${primary}.`) ||\n normalized.includes(`.${primary}-`)\n ) {\n return brand\n }\n }\n return null\n}\n\n/**\n * The few entity forms a phishing kit uses to keep a URL away from a naive\n * scanner (`&#46;` for a dot, `&amp;` inside a query). Decoded before links\n * are read; everything else is left alone.\n */\nfunction decodeEntities(text: string): string {\n return text\n .replace(/&#x([0-9a-f]{1,6});/gi, (_, hex: string) => safeCodePoint(parseInt(hex, 16)))\n .replace(/&#(\\d{1,7});/g, (_, dec: string) => safeCodePoint(parseInt(dec, 10)))\n .replace(/&amp;/gi, '&')\n}\n\nfunction safeCodePoint(value: number): string {\n return Number.isFinite(value) && value > 0 && value <= 0x10ffff\n ? String.fromCodePoint(value)\n : ''\n}\n\nconst URL_PATTERN = /\\b(?:https?:\\/\\/|www\\.)[^\\s\"'<>()\\\\[\\]{}]+/gi\n\n/** The hosts every link in the text points at, lowercased. */\nexport function linkHostsIn(text: string): string[] {\n const hosts = new Set<string>()\n for (const match of decodeEntities(String(text ?? '')).match(URL_PATTERN) ?? []) {\n let rest = match.replace(/^https?:\\/\\//i, '')\n rest = rest.split(/[/?#]/)[0]\n // `https://paypal.com@evil.top/` resolves to evil.top — and the part\n // before the `@` is exactly the disguise, so it is kept as a host too.\n const at = rest.lastIndexOf('@')\n const disguise = at >= 0 ? rest.slice(0, at) : ''\n rest = at >= 0 ? rest.slice(at + 1) : rest\n for (const candidate of [rest, disguise]) {\n const host = candidate.replace(/:\\d+$/, '').replace(/\\.+$/, '').toLowerCase()\n // A merge tag inside a URL is not a host anybody can visit yet.\n if (host && host.includes('.') && !host.includes('{') && !host.includes('%7b')) {\n hosts.add(host)\n }\n }\n }\n return [...hosts]\n}\n\nconst ADDRESS_HOST_PATTERN = /[a-z0-9._%+-]+@([a-z0-9-]+(?:\\.[a-z0-9-]+)+)/gi\n\n/** The host of every email address in the text — a reply-to, a `mailto:`. */\nfunction addressHostsIn(text: string): string[] {\n const hosts = new Set<string>()\n for (const match of decodeEntities(String(text ?? '')).matchAll(ADDRESS_HOST_PATTERN)) {\n hosts.add(match[1].toLowerCase().replace(/\\.+$/, ''))\n }\n return [...hosts]\n}\n\n/** Lowercased alphanumerics only, so \"Wells-Fargo Advisors\" contains `wellsfargo`. */\nfunction squash(text: string): string {\n return text.toLowerCase().replace(/[^a-z0-9]/g, '')\n}\n\n/** Is this brand the workspace's own, by name or by a domain it holds? */\nfunction isOwnBrand(\n brand: PhishingScreenBrand,\n ownNames: string,\n ownRegistrables: ReadonlySet<string>,\n): boolean {\n const labels = [brand.id, squash(brand.label), ...brand.hostTokens.map(squash)]\n if (labels.some((label) => label && ownNames.includes(label))) return true\n return [...ownRegistrables].some((registrable) => isOfficialDomain(brand, registrable))\n}\n\n/*\n * The same judgements, for the other surfaces that read content with this\n * screen's data (`hosted-page-screen.ts`). Exported under these names rather\n * than copied, so a page and an email can never disagree about what a\n * brand's own domain is or whose brand a name is.\n */\n\n/** `squash`: lowercased alphanumerics, the form own-name matching reads. */\nexport const squashScreenText = squash\n/** Is this brand the workspace's own, by name (squashed) or by a domain it holds? */\nexport const isWorkspaceOwnBrand = isOwnBrand\n/** Is this registrable domain one of any listed brand's own? */\nexport const isAnyOfficialBrandDomain = isAnyOfficialDomain\n/** Is this registrable domain a social, maps or review link merchants ordinarily carry? */\nexport function isCommonLinkDomain(registrable: string): boolean {\n return COMMON_LINK_DOMAINS.has(registrable)\n}\n\n/**\n * Screen one outbound message. See the module header for what holds and why.\n */\nexport function screenOutboundEmail(input: PhishingScreenInput): PhishingScreenVerdict {\n const signals: PhishingScreenSignal[] = []\n const ownNames = squash((input.ownNames ?? []).filter(Boolean).join(' '))\n const ownHosts = (input.ownDomains ?? [])\n .map((domain) => String(domain ?? '').trim().toLowerCase().replace(/\\.+$/, ''))\n .filter((domain) => domain.includes('.'))\n const ownRegistrables = new Set(ownHosts.map(registrableDomain))\n const isOwnHost = (host: string) =>\n ownHosts.some((own) => host === own || host.endsWith(`.${own}`))\n\n const copy = [input.subject, input.preheader, ...(input.bodies ?? [])]\n .filter((part): part is string => typeof part === 'string' && part.length > 0)\n .join('\\n')\n const decodedCopy = decodeEntities(copy)\n const replyTo = Array.isArray(input.replyTo)\n ? input.replyTo.join(' ')\n : String(input.replyTo ?? '')\n const linkHosts = linkHostsIn(copy)\n\n // 1. A lookalike host, anywhere a recipient could click, write back, or\n // read as the sender.\n const seenLookalike = new Set<string>()\n const fromAddress = String(input.fromAddress ?? '')\n for (const host of [...linkHosts, ...addressHostsIn(`${copy}\\n${replyTo}\\n${fromAddress}`)]) {\n if (seenLookalike.has(host)) continue\n const brand = lookalikeBrandForHost(host)\n if (brand) {\n seenLookalike.add(host)\n signals.push({ code: 'lookalike-link', brand: brand.id, host })\n }\n }\n\n // 2. A brand in the sender's display name that is not the workspace's.\n const fromName = String(input.fromName ?? '').trim()\n if (fromName) {\n for (const brand of PHISHING_SCREEN_BRANDS) {\n if (brand.mention.test(fromName) && !isOwnBrand(brand, ownNames, ownRegistrables)) {\n signals.push({ code: 'brand-sender', brand: brand.id, fromName: fromName.slice(0, 120) })\n }\n }\n }\n\n // 3. Brand + lure + a link to somewhere that is nobody's we know.\n const lure = PHISHING_LURE_PATTERNS.map((pattern) => decodedCopy.match(pattern)?.[0])\n .find(Boolean)\n if (lure) {\n const foreign = linkHosts.find((host) => {\n if (isOwnHost(host)) return false\n const registrable = registrableDomain(host)\n return !isAnyOfficialDomain(registrable) && !COMMON_LINK_DOMAINS.has(registrable)\n })\n if (foreign) {\n const namedInCopy = `${decodedCopy}\\n${fromName}`\n for (const brand of PHISHING_SCREEN_BRANDS) {\n if (brand.mention.test(namedInCopy) && !isOwnBrand(brand, ownNames, ownRegistrables)) {\n signals.push({\n code: 'brand-lure-link',\n brand: brand.id,\n lure: lure.slice(0, 120),\n host: foreign,\n })\n break\n }\n }\n }\n }\n\n return { hold: signals.length > 0, signals }\n}\n\n/** The brand's label for a stored id, for the staff surface. */\nexport function phishingScreenBrandLabel(id: string): string {\n return PHISHING_SCREEN_BRANDS.find((brand) => brand.id === id)?.label ?? id\n}\n\n/** One line per signal, in words staff read on the review row. */\nexport function describePhishingScreenSignals(\n signals: readonly PhishingScreenSignal[],\n): string[] {\n return signals.map((signal) => {\n const brand = 'brand' in signal ? phishingScreenBrandLabel(signal.brand) : ''\n switch (signal.code) {\n case 'lookalike-link':\n return `Links to ${signal.host}, which wears the ${brand} name but is not ${brand}'s domain.`\n case 'brand-sender':\n return `Sends as \"${signal.fromName}\", naming ${brand}, which is not this workspace.`\n case 'brand-lure-link':\n return `Names ${brand}, asks the reader to act (\"${signal.lure}\"), and links to ${signal.host}.`\n case 'credential-field':\n return `Asks for a ${\n signal.field === 'card' ? 'card number' : signal.field === 'otp' ? 'one-time code' : 'password'\n } in its own field (\"${signal.label}\"), outside the platform's sign-in and checkout elements.`\n case 'brand-action-page':\n return `Names ${brand} beside a call to action (\"${signal.action}\"), and this workspace is not ${brand}.`\n default:\n return 'Phishing signal.'\n }\n })\n}\n\n/*==========================================\n * RISK TIERS: who a signal holds for (AGL-3356, widened to every surface).\n *\n * The screen above finds signals; this decides which of them hold, the same\n * way on every surface that asks — an email at the send seam, a campaign\n * before it is claimed, a page at publish, a subdomain at creation.\n *\n * - STRONG: a lookalike host. A link to `poshmark.id63835663.shop` or\n * `paypa1.com` is a disguise whoever wears it and however old the\n * workspace is, so it holds for EVERY workspace, on every message and\n * page, transactional mail included. A merchant has no ordinary reason to\n * link to a host shaped like somebody else's brand.\n * - SOFT: a brand in the sender name, the three-part lure, a brand's name\n * beside a sign-in or payment call to action on a page. Each is shaped\n * like phishing but also like a clumsy legitimate message, so they hold\n * only for a workspace in its first {@link OUTBOUND_REVIEW_YOUNG_DAYS}\n * days — the incident's window — and NEVER for mail the recipient's own\n * act made owed ({@link OutboundScreenPolicy.owed}).\n *\n * A credential-harvest page (a password, card or one-time-code field that\n * is not one of the platform's own sign-in or checkout elements) is STRONG:\n * see `hosted-page-screen.ts`.\n *=========================================*/\n\n/** A workspace younger than this many days has the soft rules applied. */\nexport const OUTBOUND_REVIEW_YOUNG_DAYS = 14\n\n/** Which tier a signal belongs to. */\nexport type PhishingSignalTier = 'strong' | 'soft'\n\n/** The signal codes that hold for every workspace. */\nexport const STRONG_PHISHING_SIGNAL_CODES: ReadonlySet<string> = new Set([\n 'lookalike-link',\n 'credential-field',\n])\n\nexport function phishingSignalTier(signal: { code: string }): PhishingSignalTier {\n return STRONG_PHISHING_SIGNAL_CODES.has(signal?.code) ? 'strong' : 'soft'\n}\n\n/** Is a workspace of this age (days, `null` = unreadable) screened by the soft rules? */\nexport function isYoungWorkspaceAge(ageDays: number | null | undefined): boolean {\n return typeof ageDays === 'number' && Number.isFinite(ageDays) && ageDays < OUTBOUND_REVIEW_YOUNG_DAYS\n}\n\nexport interface OutboundScreenPolicy {\n /**\n * The workspace's age in days, or `null` when its creation date cannot be\n * read — an org that predates the field, which is an EXISTING customer and\n * is not young.\n */\n ageDays: number | null\n /**\n * The recipient's own act made this message owed: a receipt for their\n * order, a confirmation of their booking, a reset of their password. The\n * soft rules never hold it. The lookalike rule still does — a receipt that\n * links to a Poshmark lookalike is the disguise, not the receipt.\n */\n owed?: boolean\n}\n\n/** The signals that HOLD under the tiers, for this workspace and message. */\nexport function signalsThatHold<T extends { code: string }>(\n signals: readonly T[],\n policy: OutboundScreenPolicy,\n): T[] {\n const soft = isYoungWorkspaceAge(policy.ageDays) && !policy.owed\n return signals.filter((signal) => phishingSignalTier(signal) === 'strong' || soft)\n}\n\n/**\n * The same brand, lookalike and tier logic applied to a HOST NAME a workspace\n * asks for — a subdomain on the tenant apex, or a custom domain it attaches.\n *\n * Words a brand's impersonator hyphen-joins to its name. A subdomain that\n * joins a brand's word to one of these (`booking-review`, `apple-support`,\n * `amazon-account`) is the incident's shape even where the brand's word is\n * an ordinary one — which the lookalike rule alone deliberately lets pass,\n * so a salon can be `tanyas-booking` and a bakery `apple-pie-co`.\n */\nexport const BRAND_IMPERSONATION_WORDS: ReadonlySet<string> = new Set([\n 'account', 'accounts', 'auth', 'billing', 'help', 'helpdesk', 'id', 'login',\n 'logon', 'order', 'orders', 'pay', 'payment', 'payments', 'refund', 'resolution',\n 'review', 'reviews', 'secure', 'security', 'seller', 'service', 'signin',\n 'support', 'update', 'verify', 'verification', 'wallet',\n])\n\n/**\n * The brand a requested subdomain label wears, or null.\n *\n * Refused for EVERY workspace, at creation and at rename — the tier is\n * strong, because nothing is lost by asking a real bakery to pick\n * `apple-pie-co` instead of `apple-support`, and a brand's name on our apex\n * is a disguise that outlives every later screen. Two shapes:\n *\n * 1. the label is a lookalike host label ({@link lookalikeBrandForHost} on\n * `<label>.<apex>`): `poshmark`, `paypal-secure`, `paypa1`, `appleid`;\n * 2. a brand's word hyphen-joined to an impersonation word\n * ({@link BRAND_IMPERSONATION_WORDS}): `booking-review`, `apple-support`.\n */\nexport function brandForSubdomainLabel(\n label: string,\n apex = 'example.invalid',\n): PhishingScreenBrand | null {\n const normalized = String(label ?? '').trim().toLowerCase()\n if (!normalized) return null\n const byHost = lookalikeBrandForHost(`${normalized}.${apex}`)\n if (byHost) return byHost\n const parts = foldLeet(normalized).split('-').filter(Boolean)\n if (parts.length < 2 || !parts.some((part) => BRAND_IMPERSONATION_WORDS.has(part))) {\n return null\n }\n for (const brand of PHISHING_SCREEN_BRANDS) {\n const words = [brand.id, ...brand.hostTokens].filter((word) => !word.includes('-'))\n if (parts.some((part) => words.includes(part))) return brand\n }\n return null\n}\n"],"names":["PHISHING_SCREEN_BRANDS","id","label","mention","hostTokens","officialDomains","countryDomains","wordToken","PHISHING_LURE_PATTERNS","COMMON_LINK_DOMAINS","Set","TWO_LABEL_SUFFIXES","registrableDomain","host","labels","String","toLowerCase","replace","split","filter","Boolean","length","join","lastTwo","slice","has","splitRegistrable","registrable","dot","indexOf","suffix","isOfficialDomain","brand","includes","base","test","isAnyOfficialDomain","some","foldLeet","labelNamesBrand","token","candidate","parts","tokenParts","at","every","part","offset","lookalikeBrandForHost","normalized","primary","startsWith","decodeEntities","text","_","hex","safeCodePoint","parseInt","dec","value","Number","isFinite","fromCodePoint","URL_PATTERN","linkHostsIn","hosts","match","rest","lastIndexOf","disguise","add","ADDRESS_HOST_PATTERN","addressHostsIn","matchAll","squash","isOwnBrand","ownNames","ownRegistrables","map","squashScreenText","isWorkspaceOwnBrand","isAnyOfficialBrandDomain","isCommonLinkDomain","screenOutboundEmail","input","signals","ownHosts","ownDomains","domain","trim","isOwnHost","own","endsWith","copy","subject","preheader","bodies","decodedCopy","replyTo","Array","isArray","linkHosts","seenLookalike","fromAddress","push","code","fromName","lure","pattern","find","foreign","namedInCopy","hold","phishingScreenBrandLabel","describePhishingScreenSignals","signal","field","action","OUTBOUND_REVIEW_YOUNG_DAYS","STRONG_PHISHING_SIGNAL_CODES","phishingSignalTier","isYoungWorkspaceAge","ageDays","signalsThatHold","policy","soft","owed","BRAND_IMPERSONATION_WORDS","brandForSubdomainLabel","apex","byHost","words","word"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/outbound-phishing-screen.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 OUTBOUND PHISHING SCREEN (AGL-3356).\n *\n * A fraud actor signed up, paid with a card that never met 3-D Secure, and\n * used a workspace to send two kinds of mail: a campaign of Booking.com-style\n * \"guest feedback regarding your property\" lures to hotels, and a workflow\n * that mailed \"Poshmark Order — one of the items from your Seller Account has\n * finally sold\", linking to `poshmark.id63835663.shop`.\n *\n * This module answers one question about a message a YOUNG workspace is\n * about to send: does its content carry a STRONG phishing signal? The caller\n * decides who is young and what happens to a positive answer — the answer is\n * a hold for staff review, never a drop.\n *\n * ## Conservative by construction\n *\n * A false positive costs a real merchant's first campaign a wait for a\n * human; a miss costs strangers their bank logins. Both are real, so only\n * three shapes hold, and each is one a legitimate merchant essentially never\n * produces:\n *\n * 1. **A lookalike link** — a link or reply address whose host carries a\n * brand's name without being the brand's own domain: the brand as a label\n * of an unrelated registrable domain (`poshmark.id63835663.shop`), hyphen-\n * joined to one (`paypal-secure.com`, `secure-paypal.net`), the brand's\n * real domain embedded in a longer host (`booking.com.guest-review.top`),\n * or leetspeak for it (`paypa1.com`). Holds on its own.\n * 2. **A brand in the sender's display name** that is not the workspace's\n * own name — mail that says it is FROM PayPal.\n * 3. **The three-part lure**: a brand named in the copy, AND credential or\n * account-action phrasing (\"verify your account\", \"has finally sold\",\n * \"feedback regarding your property\"), AND a link to a domain that is\n * neither the brand's, nor the workspace's, nor a common social or maps\n * link. Any two of the three are ordinary marketing — \"our book is on\n * Amazon\", \"sign in to your account\", a link to the shop — and pass.\n *\n * Mentioning a brand, alone, never holds.\n *\n * ## The brand list is small on purpose\n *\n * The brands phishing kits most often wear, plus the two in the incident. A\n * brand is added when it has been seen impersonated, not because it is big:\n * every entry is a set of words that can now hold somebody's newsletter.\n * Each carries its REAL domains, so a link to the brand itself is never\n * suspicious, and host tokens chosen so a common word (`booking`, `apple`,\n * `outlook`) does not match an unrelated host by itself.\n *\n * Pure: no DNS, no store, no clock. Everything the caller knows about the\n * workspace arrives as input.\n *=========================================*/\n\n/** One brand the screen recognizes. */\nexport interface PhishingScreenBrand {\n /** Stable id, as stored on a hold. */\n id: string\n /** How staff read it. */\n label: string\n /** Copy that names the brand — subject, body, sender. Case-insensitive. */\n mention: RegExp\n /**\n * Host labels that name the brand. Matched against each dot-separated\n * label of a link's host, whole or hyphen-joined, after leetspeak is\n * folded — never as a bare substring, which is how `amazonaws` or\n * `bookingengine` would read as a brand.\n */\n hostTokens: readonly string[]\n /**\n * Registrable domains the brand genuinely links from. The FIRST is its\n * primary domain, the one a kit embeds in a longer host.\n */\n officialDomains: readonly string[]\n /**\n * The brand's name is an ordinary word (`apple`, `amazon`), so a host\n * label names the brand only when it IS the word — `apple.example.top` —\n * and never hyphen-joined or leet-folded, which `apple-orchard-farm.com`\n * would otherwise be.\n */\n wordToken?: boolean\n /**\n * Whether `<brand>.<country code>` (and `<brand>.co.<cc>`) is the brand's\n * too — true for the marketplaces that run a site per country.\n */\n countryDomains?: boolean\n /**\n * Registrable domains the brand runs but where OTHER people's content lives —\n * the platform's own tenant apex, where every site is `<name>.<apex>`. A\n * host there never wears the brand as a disguise, so the lookalike rule\n * skips it; but it is not the brand's own domain either, so a link to a\n * neighbor's site still counts as \"somewhere else\" to the lure rules, and a\n * workspace hosted there does not own the brand by being hosted there.\n */\n hostedDomains?: readonly string[]\n}\n\nexport const PHISHING_SCREEN_BRANDS: readonly PhishingScreenBrand[] = [\n {\n id: 'poshmark',\n label: 'Poshmark',\n mention: /\\bposhmark\\b/i,\n hostTokens: ['poshmark'],\n officialDomains: ['poshmark.com', 'posh.mk'],\n },\n {\n id: 'booking',\n label: 'Booking.com',\n // Never the bare word: \"booking\" is every hotel's and every salon's.\n mention: /\\bbooking\\.com\\b/i,\n hostTokens: ['bookingcom', 'booking-com'],\n officialDomains: ['booking.com', 'bstatic.com'],\n },\n {\n id: 'airbnb',\n label: 'Airbnb',\n mention: /\\bairbnb\\b/i,\n hostTokens: ['airbnb'],\n officialDomains: ['airbnb.com', 'abnb.me', 'muscache.com'],\n countryDomains: true,\n },\n {\n id: 'paypal',\n label: 'PayPal',\n mention: /\\bpay\\s?pal\\b/i,\n hostTokens: ['paypal'],\n officialDomains: ['paypal.com', 'paypal.me', 'paypalobjects.com'],\n countryDomains: true,\n },\n {\n id: 'amazon',\n label: 'Amazon',\n mention: /\\bamazon\\b/i,\n hostTokens: ['amazon', 'amzn'],\n wordToken: true,\n officialDomains: [\n 'amazon.com',\n 'amzn.to',\n 'amzn.com',\n 'amazonaws.com',\n 'media-amazon.com',\n 'ssl-images-amazon.com',\n ],\n countryDomains: true,\n },\n {\n id: 'apple',\n label: 'Apple',\n // The fruit is on every bakery's menu; the account is what gets phished.\n mention: /\\bapple\\s?(?:id|support|account)\\b|\\bicloud\\b|\\bitunes\\b/i,\n hostTokens: ['apple', 'appleid', 'icloud', 'itunes'],\n wordToken: true,\n officialDomains: ['apple.com', 'icloud.com', 'apple.co', 'itunes.com', 'me.com', 'mzstatic.com'],\n countryDomains: true,\n },\n {\n id: 'microsoft',\n label: 'Microsoft',\n mention: /\\bmicrosoft\\b|\\boffice\\s?365\\b|\\bonedrive\\b|\\bsharepoint\\b/i,\n hostTokens: ['microsoft', 'office365', 'onedrive', 'sharepoint', 'microsoftonline', 'msonline'],\n officialDomains: [\n 'microsoft.com',\n 'office.com',\n 'office365.com',\n 'live.com',\n 'outlook.com',\n 'microsoftonline.com',\n 'sharepoint.com',\n 'onedrive.com',\n '1drv.ms',\n 'aka.ms',\n ],\n },\n {\n id: 'docusign',\n label: 'DocuSign',\n mention: /\\bdocu\\s?sign\\b/i,\n hostTokens: ['docusign'],\n officialDomains: ['docusign.com', 'docusign.net'],\n },\n {\n id: 'netflix',\n label: 'Netflix',\n mention: /\\bnetflix\\b/i,\n hostTokens: ['netflix'],\n officialDomains: ['netflix.com', 'nflxext.com', 'nflximg.net'],\n },\n {\n id: 'wellsfargo',\n label: 'Wells Fargo',\n mention: /\\bwells\\s?fargo\\b/i,\n hostTokens: ['wellsfargo', 'wells-fargo'],\n officialDomains: ['wellsfargo.com'],\n },\n {\n id: 'bankofamerica',\n label: 'Bank of America',\n mention: /\\bbank\\s+of\\s+america\\b/i,\n hostTokens: ['bankofamerica', 'bofa'],\n officialDomains: ['bankofamerica.com', 'bofa.com'],\n },\n {\n id: 'coinbase',\n label: 'Coinbase',\n mention: /\\bcoinbase\\b/i,\n hostTokens: ['coinbase'],\n officialDomains: ['coinbase.com'],\n },\n {\n id: 'usps',\n label: 'USPS',\n mention: /\\busps\\b/i,\n hostTokens: ['usps'],\n officialDomains: ['usps.com'],\n },\n {\n id: 'fedex',\n label: 'FedEx',\n mention: /\\bfed\\s?ex\\b/i,\n hostTokens: ['fedex'],\n officialDomains: ['fedex.com'],\n },\n {\n id: 'dhl',\n label: 'DHL',\n mention: /\\bdhl\\b/i,\n hostTokens: ['dhl'],\n officialDomains: ['dhl.com'],\n countryDomains: true,\n },\n {\n // The platform itself (AGL-3365). A marketplace listing, a page or an\n // email that wears the platform's name — \"Aglyn Support\", a link to\n // `aglyn-billing.com`, \"official Aglyn plugin\" — is the one impersonation\n // every customer is primed to trust. The workspace named for it (the\n // platform's own org) owns it by name, as any brand's own workspace does.\n id: 'aglyn',\n label: 'Aglyn',\n mention: /\\baglyn\\b/i,\n hostTokens: ['aglyn'],\n officialDomains: ['aglyn.com', 'aglyn.io', 'aglyn.dev'],\n hostedDomains: ['aglyn.app'],\n },\n]\n\n/**\n * Account-action and credential phrasing — the half of a lure that asks the\n * reader to DO something with an account. Only ever counted beside a brand\n * AND a foreign link; alone, several of these are ordinary transactional copy.\n */\nexport const PHISHING_LURE_PATTERNS: readonly RegExp[] = [\n /\\bverify\\s+your\\s+(?:account|identity|information|details|payment|email)\\b/i,\n /\\bconfirm\\s+your\\s+(?:account|identity|details|payment|password|billing|information)\\b/i,\n /\\b(?:account|payment|card)\\s+(?:has\\s+been\\s+|is\\s+|was\\s+)?(?:suspended|locked|limited|restricted|disabled|on\\s+hold)\\b/i,\n /\\bunusual\\s+(?:sign[-\\s]?in|login|activity)\\b/i,\n /\\bupdate\\s+your\\s+(?:payment|billing|card|account)\\s*(?:details|information|method)?\\b/i,\n /\\b(?:sign|log)\\s?in\\s+to\\s+(?:your|view|confirm|claim|release)\\b/i,\n /\\b(?:has|have)\\s+(?:finally\\s+)?sold\\b/i,\n /\\bseller\\s+account\\b/i,\n /\\b(?:guest|customer)\\s+(?:complaint|review|feedback)\\b/i,\n /\\bfeedback\\s+regarding\\s+your\\s+(?:property|listing|stay|booking|reservation)\\b/i,\n /\\b(?:document|invoice|agreement)s?\\s+(?:is\\s+|are\\s+)?(?:ready|waiting|pending)\\s+(?:for\\s+)?(?:your\\s+)?(?:review|signature|to\\s+sign)\\b/i,\n /\\bpassword\\s+(?:will\\s+)?expire/i,\n /\\bclaim\\s+your\\s+(?:refund|reward|prize|package|payment|funds)\\b/i,\n /\\b(?:delivery|shipment)\\s+(?:failed|attempt|suspended|on\\s+hold)\\b/i,\n /\\b(?:package|parcel)\\s+(?:is\\s+)?(?:on\\s+hold|held|awaiting|undeliverable)\\b/i,\n /\\bsecurity\\s+alert\\b/i,\n]\n\n/**\n * Link targets a merchant's mail ordinarily carries that are not theirs:\n * social profiles, maps, review sites, video. Counted as neither foreign nor\n * suspicious in the three-part lure, so \"leave us a review on Google\" with a\n * brand named elsewhere does not become a hold. Never consulted by the\n * lookalike check, which holds on the host's own shape.\n */\nconst COMMON_LINK_DOMAINS: ReadonlySet<string> = new Set([\n 'facebook.com',\n 'fb.com',\n 'instagram.com',\n 'x.com',\n 'twitter.com',\n 'linkedin.com',\n 'youtube.com',\n 'youtu.be',\n 'tiktok.com',\n 'pinterest.com',\n 'google.com',\n 'goo.gl',\n 'g.page',\n 'yelp.com',\n 'tripadvisor.com',\n 'wa.me',\n 'whatsapp.com',\n])\n\n/**\n * Two-label public suffixes, so `evil.co.uk` registers as `evil.co.uk` and\n * not as `co.uk`. Deliberately a short list of the ones mail actually\n * arrives from rather than the whole public suffix list: a suffix missing\n * here makes the registrable domain one label too short, which can only\n * widen what counts as \"the same domain\" for a link — never invent a brand\n * label that is not in the host.\n */\nconst TWO_LABEL_SUFFIXES: ReadonlySet<string> = new Set([\n 'co.uk', 'org.uk', 'ac.uk', 'gov.uk', 'me.uk',\n 'com.au', 'net.au', 'org.au',\n 'co.nz', 'co.jp', 'co.kr', 'co.in', 'co.za', 'co.il', 'co.id', 'co.th',\n 'com.br', 'com.mx', 'com.ar', 'com.tr', 'com.cn', 'com.hk', 'com.sg',\n 'com.tw', 'com.my', 'com.ph', 'com.co', 'com.pe', 'com.sa', 'com.eg',\n])\n\n/** A signal the screen found. */\nexport type PhishingScreenSignal =\n | { code: 'lookalike-link'; brand: string; host: string }\n | { code: 'brand-sender'; brand: string; fromName: string }\n | { code: 'brand-lure-link'; brand: string; lure: string; host: string }\n /**\n * A published page asks for a password, a card number or a one-time code\n * in a field that is not one of the platform's own sign-in or checkout\n * elements (`hosted-page-screen.ts`). Strong.\n */\n | { code: 'credential-field'; field: 'password' | 'card' | 'otp'; label: string }\n /**\n * A published page names a brand that is not the workspace's beside a\n * sign-in, verify or payment call to action (`hosted-page-screen.ts`).\n * Soft.\n */\n | { code: 'brand-action-page'; brand: string; action: string }\n\nexport interface PhishingScreenInput {\n subject?: string | null\n /** The display name in front of the sending address. */\n fromName?: string | null\n /**\n * The ADDRESS the message leaves from (AGL-3362) — a `From:` domain is a\n * link the recipient never has to click, so its host is read by the\n * lookalike rule exactly like a link's: mail from `support@paypa1.com` is\n * the disguise whoever sends it. Not an own domain for the lure's \"is this\n * elsewhere\" test either way; it only ever adds a lookalike signal.\n */\n fromAddress?: string | null\n replyTo?: string | readonly string[] | null\n preheader?: string | null\n /**\n * Everything else the recipient reads or clicks: the body, the plain-text\n * part, a design's nodes serialized — any string. Links are found by\n * shape, so a JSON-encoded design is as readable here as HTML.\n */\n bodies?: readonly (string | null | undefined)[]\n /**\n * The workspace's own names — org, site, subdomain. A brand in one of them\n * is the workspace's own, and `brand-sender` / `brand-lure-link` do not\n * fire for it. The lookalike check does not consult names: a workspace\n * NAMED \"Poshmark\" linking to `poshmark.id63835663.shop` is the incident.\n */\n ownNames?: readonly (string | null | undefined)[]\n /**\n * The workspace's own hosts — its subdomain on the tenant apex, custom\n * domain, sending domain. A link to one of them, or below one, is never\n * the \"somewhere else\" of a lure. Matched by host, never by registrable\n * domain: every site's subdomain shares the tenant apex, and a link to a\n * NEIGHBOR's site is somewhere else.\n *\n * Not an exemption from the lookalike check. A host shaped like\n * `paypal-secure.com` is a lookalike whoever attached it, and a workspace\n * that attached it has attached the disguise.\n */\n ownDomains?: readonly (string | null | undefined)[]\n}\n\nexport interface PhishingScreenVerdict {\n /** True when the message should be held for staff review. */\n hold: boolean\n signals: PhishingScreenSignal[]\n}\n\n/** The registrable domain of a host: `a.b.evil.co.uk` → `evil.co.uk`. */\nexport function registrableDomain(host: string): string {\n const labels = String(host ?? '')\n .toLowerCase()\n .replace(/\\.+$/, '')\n .split('.')\n .filter(Boolean)\n if (labels.length <= 2) return labels.join('.')\n const lastTwo = labels.slice(-2).join('.')\n return TWO_LABEL_SUFFIXES.has(lastTwo)\n ? labels.slice(-3).join('.')\n : lastTwo\n}\n\n/** `<label>.<suffix>` of a registrable domain, split once. */\nfunction splitRegistrable(registrable: string): { label: string; suffix: string } {\n const dot = registrable.indexOf('.')\n return dot < 0\n ? { label: registrable, suffix: '' }\n : { label: registrable.slice(0, dot), suffix: registrable.slice(dot + 1) }\n}\n\nfunction isOfficialDomain(brand: PhishingScreenBrand, registrable: string): boolean {\n if (brand.officialDomains.includes(registrable)) return true\n if (!brand.countryDomains) return false\n const base = splitRegistrable(brand.officialDomains[0]).label\n const { label, suffix } = splitRegistrable(registrable)\n return label === base && (/^[a-z]{2}$/.test(suffix) || TWO_LABEL_SUFFIXES.has(suffix))\n}\n\n/** Every brand's real domains, for \"is this link just the brand itself\". */\nfunction isAnyOfficialDomain(registrable: string): boolean {\n return PHISHING_SCREEN_BRANDS.some((brand) => isOfficialDomain(brand, registrable))\n}\n\n/** Leetspeak folded, so `paypa1` reads as `paypal`. */\nfunction foldLeet(label: string): string {\n return label\n .replace(/0/g, 'o')\n .replace(/1/g, 'l')\n .replace(/3/g, 'e')\n .replace(/4/g, 'a')\n .replace(/5/g, 's')\n .replace(/7/g, 't')\n}\n\n/** Does one host label name the brand — whole, or hyphen-joined? */\nfunction labelNamesBrand(label: string, token: string, wordToken: boolean): boolean {\n if (wordToken) return label === token\n for (const candidate of new Set([label, foldLeet(label)])) {\n if (candidate === token) return true\n const parts = candidate.split('-')\n // A hyphen-joined token (`booking-com`) spans parts, so it is matched\n // as a run of parts rather than one of them.\n const tokenParts = token.split('-')\n for (let at = 0; at + tokenParts.length <= parts.length; at += 1) {\n if (tokenParts.every((part, offset) => parts[at + offset] === part)) {\n return true\n }\n }\n }\n return false\n}\n\n/** The brand a host impersonates, or null. */\nexport function lookalikeBrandForHost(host: string): PhishingScreenBrand | null {\n const normalized = String(host ?? '').toLowerCase().replace(/\\.+$/, '')\n if (!normalized.includes('.')) return null\n const registrable = registrableDomain(normalized)\n for (const brand of PHISHING_SCREEN_BRANDS) {\n if (isOfficialDomain(brand, registrable)) continue\n if (brand.hostedDomains?.includes(registrable)) continue\n const labels = normalized.split('.')\n if (\n labels.some((label) =>\n brand.hostTokens.some((token) => labelNamesBrand(label, token, brand.wordToken === true)),\n )\n ) {\n return brand\n }\n // The brand's primary domain embedded in a longer, different one:\n // `booking.com.guest-review.top`, `paypal.com-secure.net`. Only the\n // primary: a short secondary (`me.com`, `aka.ms`) would match too much.\n const primary = brand.officialDomains[0]\n if (\n normalized.startsWith(`${primary}.`) ||\n normalized.startsWith(`${primary}-`) ||\n normalized.includes(`.${primary}.`) ||\n normalized.includes(`.${primary}-`)\n ) {\n return brand\n }\n }\n return null\n}\n\n/**\n * The few entity forms a phishing kit uses to keep a URL away from a naive\n * scanner (`&#46;` for a dot, `&amp;` inside a query). Decoded before links\n * are read; everything else is left alone.\n */\nfunction decodeEntities(text: string): string {\n return text\n .replace(/&#x([0-9a-f]{1,6});/gi, (_, hex: string) => safeCodePoint(parseInt(hex, 16)))\n .replace(/&#(\\d{1,7});/g, (_, dec: string) => safeCodePoint(parseInt(dec, 10)))\n .replace(/&amp;/gi, '&')\n}\n\nfunction safeCodePoint(value: number): string {\n return Number.isFinite(value) && value > 0 && value <= 0x10ffff\n ? String.fromCodePoint(value)\n : ''\n}\n\nconst URL_PATTERN = /\\b(?:https?:\\/\\/|www\\.)[^\\s\"'<>()\\\\[\\]{}]+/gi\n\n/** The hosts every link in the text points at, lowercased. */\nexport function linkHostsIn(text: string): string[] {\n const hosts = new Set<string>()\n for (const match of decodeEntities(String(text ?? '')).match(URL_PATTERN) ?? []) {\n let rest = match.replace(/^https?:\\/\\//i, '')\n rest = rest.split(/[/?#]/)[0]\n // `https://paypal.com@evil.top/` resolves to evil.top — and the part\n // before the `@` is exactly the disguise, so it is kept as a host too.\n const at = rest.lastIndexOf('@')\n const disguise = at >= 0 ? rest.slice(0, at) : ''\n rest = at >= 0 ? rest.slice(at + 1) : rest\n for (const candidate of [rest, disguise]) {\n const host = candidate.replace(/:\\d+$/, '').replace(/\\.+$/, '').toLowerCase()\n // A merge tag inside a URL is not a host anybody can visit yet.\n if (host && host.includes('.') && !host.includes('{') && !host.includes('%7b')) {\n hosts.add(host)\n }\n }\n }\n return [...hosts]\n}\n\nconst ADDRESS_HOST_PATTERN = /[a-z0-9._%+-]+@([a-z0-9-]+(?:\\.[a-z0-9-]+)+)/gi\n\n/** The host of every email address in the text — a reply-to, a `mailto:`. */\nfunction addressHostsIn(text: string): string[] {\n const hosts = new Set<string>()\n for (const match of decodeEntities(String(text ?? '')).matchAll(ADDRESS_HOST_PATTERN)) {\n hosts.add(match[1].toLowerCase().replace(/\\.+$/, ''))\n }\n return [...hosts]\n}\n\n/** Lowercased alphanumerics only, so \"Wells-Fargo Advisors\" contains `wellsfargo`. */\nfunction squash(text: string): string {\n return text.toLowerCase().replace(/[^a-z0-9]/g, '')\n}\n\n/** Is this brand the workspace's own, by name or by a domain it holds? */\nfunction isOwnBrand(\n brand: PhishingScreenBrand,\n ownNames: string,\n ownRegistrables: ReadonlySet<string>,\n): boolean {\n const labels = [brand.id, squash(brand.label), ...brand.hostTokens.map(squash)]\n if (labels.some((label) => label && ownNames.includes(label))) return true\n return [...ownRegistrables].some((registrable) => isOfficialDomain(brand, registrable))\n}\n\n/*\n * The same judgements, for the other surfaces that read content with this\n * screen's data (`hosted-page-screen.ts`). Exported under these names rather\n * than copied, so a page and an email can never disagree about what a\n * brand's own domain is or whose brand a name is.\n */\n\n/** `squash`: lowercased alphanumerics, the form own-name matching reads. */\nexport const squashScreenText = squash\n/** Is this brand the workspace's own, by name (squashed) or by a domain it holds? */\nexport const isWorkspaceOwnBrand = isOwnBrand\n/** Is this registrable domain one of any listed brand's own? */\nexport const isAnyOfficialBrandDomain = isAnyOfficialDomain\n/** Is this registrable domain a social, maps or review link merchants ordinarily carry? */\nexport function isCommonLinkDomain(registrable: string): boolean {\n return COMMON_LINK_DOMAINS.has(registrable)\n}\n\n/**\n * Screen one outbound message. See the module header for what holds and why.\n */\nexport function screenOutboundEmail(input: PhishingScreenInput): PhishingScreenVerdict {\n const signals: PhishingScreenSignal[] = []\n const ownNames = squash((input.ownNames ?? []).filter(Boolean).join(' '))\n const ownHosts = (input.ownDomains ?? [])\n .map((domain) => String(domain ?? '').trim().toLowerCase().replace(/\\.+$/, ''))\n .filter((domain) => domain.includes('.'))\n const ownRegistrables = new Set(ownHosts.map(registrableDomain))\n const isOwnHost = (host: string) =>\n ownHosts.some((own) => host === own || host.endsWith(`.${own}`))\n\n const copy = [input.subject, input.preheader, ...(input.bodies ?? [])]\n .filter((part): part is string => typeof part === 'string' && part.length > 0)\n .join('\\n')\n const decodedCopy = decodeEntities(copy)\n const replyTo = Array.isArray(input.replyTo)\n ? input.replyTo.join(' ')\n : String(input.replyTo ?? '')\n const linkHosts = linkHostsIn(copy)\n\n // 1. A lookalike host, anywhere a recipient could click, write back, or\n // read as the sender.\n const seenLookalike = new Set<string>()\n const fromAddress = String(input.fromAddress ?? '')\n for (const host of [...linkHosts, ...addressHostsIn(`${copy}\\n${replyTo}\\n${fromAddress}`)]) {\n if (seenLookalike.has(host)) continue\n const brand = lookalikeBrandForHost(host)\n if (brand) {\n seenLookalike.add(host)\n signals.push({ code: 'lookalike-link', brand: brand.id, host })\n }\n }\n\n // 2. A brand in the sender's display name that is not the workspace's.\n const fromName = String(input.fromName ?? '').trim()\n if (fromName) {\n for (const brand of PHISHING_SCREEN_BRANDS) {\n if (brand.mention.test(fromName) && !isOwnBrand(brand, ownNames, ownRegistrables)) {\n signals.push({ code: 'brand-sender', brand: brand.id, fromName: fromName.slice(0, 120) })\n }\n }\n }\n\n // 3. Brand + lure + a link to somewhere that is nobody's we know.\n const lure = PHISHING_LURE_PATTERNS.map((pattern) => decodedCopy.match(pattern)?.[0])\n .find(Boolean)\n if (lure) {\n const foreign = linkHosts.find((host) => {\n if (isOwnHost(host)) return false\n const registrable = registrableDomain(host)\n return !isAnyOfficialDomain(registrable) && !COMMON_LINK_DOMAINS.has(registrable)\n })\n if (foreign) {\n const namedInCopy = `${decodedCopy}\\n${fromName}`\n for (const brand of PHISHING_SCREEN_BRANDS) {\n if (brand.mention.test(namedInCopy) && !isOwnBrand(brand, ownNames, ownRegistrables)) {\n signals.push({\n code: 'brand-lure-link',\n brand: brand.id,\n lure: lure.slice(0, 120),\n host: foreign,\n })\n break\n }\n }\n }\n }\n\n return { hold: signals.length > 0, signals }\n}\n\n/** The brand's label for a stored id, for the staff surface. */\nexport function phishingScreenBrandLabel(id: string): string {\n return PHISHING_SCREEN_BRANDS.find((brand) => brand.id === id)?.label ?? id\n}\n\n/** One line per signal, in words staff read on the review row. */\nexport function describePhishingScreenSignals(\n signals: readonly PhishingScreenSignal[],\n): string[] {\n return signals.map((signal) => {\n const brand = 'brand' in signal ? phishingScreenBrandLabel(signal.brand) : ''\n switch (signal.code) {\n case 'lookalike-link':\n return `Links to ${signal.host}, which wears the ${brand} name but is not ${brand}'s domain.`\n case 'brand-sender':\n return `Sends as \"${signal.fromName}\", naming ${brand}, which is not this workspace.`\n case 'brand-lure-link':\n return `Names ${brand}, asks the reader to act (\"${signal.lure}\"), and links to ${signal.host}.`\n case 'credential-field':\n return `Asks for a ${\n signal.field === 'card' ? 'card number' : signal.field === 'otp' ? 'one-time code' : 'password'\n } in its own field (\"${signal.label}\"), outside the platform's sign-in and checkout elements.`\n case 'brand-action-page':\n return `Names ${brand} beside a call to action (\"${signal.action}\"), and this workspace is not ${brand}.`\n default:\n return 'Phishing signal.'\n }\n })\n}\n\n/*==========================================\n * RISK TIERS: who a signal holds for (AGL-3356, widened to every surface).\n *\n * The screen above finds signals; this decides which of them hold, the same\n * way on every surface that asks — an email at the send seam, a campaign\n * before it is claimed, a page at publish, a subdomain at creation.\n *\n * - STRONG: a lookalike host. A link to `poshmark.id63835663.shop` or\n * `paypa1.com` is a disguise whoever wears it and however old the\n * workspace is, so it holds for EVERY workspace, on every message and\n * page, transactional mail included. A merchant has no ordinary reason to\n * link to a host shaped like somebody else's brand.\n * - SOFT: a brand in the sender name, the three-part lure, a brand's name\n * beside a sign-in or payment call to action on a page. Each is shaped\n * like phishing but also like a clumsy legitimate message, so they hold\n * only for a workspace in its first {@link OUTBOUND_REVIEW_YOUNG_DAYS}\n * days — the incident's window — and NEVER for mail the recipient's own\n * act made owed ({@link OutboundScreenPolicy.owed}).\n *\n * A credential-harvest page (a password, card or one-time-code field that\n * is not one of the platform's own sign-in or checkout elements) is STRONG:\n * see `hosted-page-screen.ts`.\n *=========================================*/\n\n/** A workspace younger than this many days has the soft rules applied. */\nexport const OUTBOUND_REVIEW_YOUNG_DAYS = 14\n\n/** Which tier a signal belongs to. */\nexport type PhishingSignalTier = 'strong' | 'soft'\n\n/** The signal codes that hold for every workspace. */\nexport const STRONG_PHISHING_SIGNAL_CODES: ReadonlySet<string> = new Set([\n 'lookalike-link',\n 'credential-field',\n])\n\nexport function phishingSignalTier(signal: { code: string }): PhishingSignalTier {\n return STRONG_PHISHING_SIGNAL_CODES.has(signal?.code) ? 'strong' : 'soft'\n}\n\n/** Is a workspace of this age (days, `null` = unreadable) screened by the soft rules? */\nexport function isYoungWorkspaceAge(ageDays: number | null | undefined): boolean {\n return typeof ageDays === 'number' && Number.isFinite(ageDays) && ageDays < OUTBOUND_REVIEW_YOUNG_DAYS\n}\n\nexport interface OutboundScreenPolicy {\n /**\n * The workspace's age in days, or `null` when its creation date cannot be\n * read — an org that predates the field, which is an EXISTING customer and\n * is not young.\n */\n ageDays: number | null\n /**\n * The recipient's own act made this message owed: a receipt for their\n * order, a confirmation of their booking, a reset of their password. The\n * soft rules never hold it. The lookalike rule still does — a receipt that\n * links to a Poshmark lookalike is the disguise, not the receipt.\n */\n owed?: boolean\n}\n\n/** The signals that HOLD under the tiers, for this workspace and message. */\nexport function signalsThatHold<T extends { code: string }>(\n signals: readonly T[],\n policy: OutboundScreenPolicy,\n): T[] {\n const soft = isYoungWorkspaceAge(policy.ageDays) && !policy.owed\n return signals.filter((signal) => phishingSignalTier(signal) === 'strong' || soft)\n}\n\n/**\n * The same brand, lookalike and tier logic applied to a HOST NAME a workspace\n * asks for — a subdomain on the tenant apex, or a custom domain it attaches.\n *\n * Words a brand's impersonator hyphen-joins to its name. A subdomain that\n * joins a brand's word to one of these (`booking-review`, `apple-support`,\n * `amazon-account`) is the incident's shape even where the brand's word is\n * an ordinary one — which the lookalike rule alone deliberately lets pass,\n * so a salon can be `tanyas-booking` and a bakery `apple-pie-co`.\n */\nexport const BRAND_IMPERSONATION_WORDS: ReadonlySet<string> = new Set([\n 'account', 'accounts', 'auth', 'billing', 'help', 'helpdesk', 'id', 'login',\n 'logon', 'order', 'orders', 'pay', 'payment', 'payments', 'refund', 'resolution',\n 'review', 'reviews', 'secure', 'security', 'seller', 'service', 'signin',\n 'support', 'update', 'verify', 'verification', 'wallet',\n])\n\n/**\n * The brand a requested subdomain label wears, or null.\n *\n * Refused for EVERY workspace, at creation and at rename — the tier is\n * strong, because nothing is lost by asking a real bakery to pick\n * `apple-pie-co` instead of `apple-support`, and a brand's name on our apex\n * is a disguise that outlives every later screen. Two shapes:\n *\n * 1. the label is a lookalike host label ({@link lookalikeBrandForHost} on\n * `<label>.<apex>`): `poshmark`, `paypal-secure`, `paypa1`, `appleid`;\n * 2. a brand's word hyphen-joined to an impersonation word\n * ({@link BRAND_IMPERSONATION_WORDS}): `booking-review`, `apple-support`.\n */\nexport function brandForSubdomainLabel(\n label: string,\n apex = 'example.invalid',\n): PhishingScreenBrand | null {\n const normalized = String(label ?? '').trim().toLowerCase()\n if (!normalized) return null\n const byHost = lookalikeBrandForHost(`${normalized}.${apex}`)\n if (byHost) return byHost\n const parts = foldLeet(normalized).split('-').filter(Boolean)\n if (parts.length < 2 || !parts.some((part) => BRAND_IMPERSONATION_WORDS.has(part))) {\n return null\n }\n for (const brand of PHISHING_SCREEN_BRANDS) {\n const words = [brand.id, ...brand.hostTokens].filter((word) => !word.includes('-'))\n if (parts.some((part) => words.includes(part))) return brand\n }\n return null\n}\n\n/**\n * The platform's own entry in {@link PHISHING_SCREEN_BRANDS} (AGL-3365). A\n * workspace owns every other brand by carrying its name or domain; the\n * platform's is owned only by the platform's staff, because a workspace NAMED\n * for the platform is the cheapest disguise there is.\n */\nexport const PLATFORM_PHISHING_BRAND_ID = 'aglyn'\n\n/** The platform's brand entry. */\nexport function platformPhishingBrand(): PhishingScreenBrand {\n return PHISHING_SCREEN_BRANDS.find((brand) => brand.id === PLATFORM_PHISHING_BRAND_ID) as PhishingScreenBrand\n}\n\n/**\n * Words that make a name claim to SPEAK FOR a brand rather than mention it:\n * \"PayPal Support\", \"Aglyn Official\", \"Apple ID Security Team\".\n */\nconst OFFICIAL_CLAIM_PATTERN =\n /\\b(?:official|staff|team|verified|certified|support|helpdesk|admin|administrator|security|billing|trust|safety|compliance|core|headquarters|hq)\\b/i\n\n/**\n * The brand a display NAME claims to speak for (AGL-3365), or null.\n *\n * For names a stranger reads as an identity — a marketplace publisher, a\n * listing's title — rather than copy. A name that merely mentions a brand\n * (\"Sync for PayPal\", \"Bookings for Airbnb hosts\") passes: that is how an\n * integration is named. A name that pairs the brand with a claim of\n * officialness (\"PayPal Support\", \"Aglyn Official Plugins\") is the disguise.\n */\nexport function brandClaimedByName(name: string): PhishingScreenBrand | null {\n const text = String(name ?? '')\n if (!text.trim() || !OFFICIAL_CLAIM_PATTERN.test(text)) return null\n return PHISHING_SCREEN_BRANDS.find((brand) => brand.mention.test(text)) ?? null\n}\n"],"names":["PHISHING_SCREEN_BRANDS","id","label","mention","hostTokens","officialDomains","countryDomains","wordToken","hostedDomains","PHISHING_LURE_PATTERNS","COMMON_LINK_DOMAINS","Set","TWO_LABEL_SUFFIXES","registrableDomain","host","labels","String","toLowerCase","replace","split","filter","Boolean","length","join","lastTwo","slice","has","splitRegistrable","registrable","dot","indexOf","suffix","isOfficialDomain","brand","includes","base","test","isAnyOfficialDomain","some","foldLeet","labelNamesBrand","token","candidate","parts","tokenParts","at","every","part","offset","lookalikeBrandForHost","normalized","primary","startsWith","decodeEntities","text","_","hex","safeCodePoint","parseInt","dec","value","Number","isFinite","fromCodePoint","URL_PATTERN","linkHostsIn","hosts","match","rest","lastIndexOf","disguise","add","ADDRESS_HOST_PATTERN","addressHostsIn","matchAll","squash","isOwnBrand","ownNames","ownRegistrables","map","squashScreenText","isWorkspaceOwnBrand","isAnyOfficialBrandDomain","isCommonLinkDomain","screenOutboundEmail","input","signals","ownHosts","ownDomains","domain","trim","isOwnHost","own","endsWith","copy","subject","preheader","bodies","decodedCopy","replyTo","Array","isArray","linkHosts","seenLookalike","fromAddress","push","code","fromName","lure","pattern","find","foreign","namedInCopy","hold","phishingScreenBrandLabel","describePhishingScreenSignals","signal","field","action","OUTBOUND_REVIEW_YOUNG_DAYS","STRONG_PHISHING_SIGNAL_CODES","phishingSignalTier","isYoungWorkspaceAge","ageDays","signalsThatHold","policy","soft","owed","BRAND_IMPERSONATION_WORDS","brandForSubdomainLabel","apex","byHost","words","word","PLATFORM_PHISHING_BRAND_ID","platformPhishingBrand","OFFICIAL_CLAIM_PATTERN","brandClaimedByName","name"],"mappings":"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"}
@@ -756,6 +756,406 @@ const BASE_SYSTEM_EMAIL_TEMPLATES = [
756
756
  footerReason: 'You’re receiving this because someone asked to add this address ' + 'to a {{brand.productName}} account.',
757
757
  source: 'apps/console/app/api/account/emails/route.ts'
758
758
  },
759
+ // AGL-3367. The platform's other senders, each of which composed its
760
+ // own plain text and so went out with no header or footer. A sender whose
761
+ // message is assembled per send (a digest's list, an alert's figures)
762
+ // hands its composed text in as one body token: the built-in copy is that
763
+ // text in the brand's chrome, and a design can wrap it in anything else.
764
+ {
765
+ key: 'notification',
766
+ name: 'Notification',
767
+ description: 'The email copy of a console notification, sent to people who ' + 'turned email on for that kind of notification: staff alerts, ' + 'workspace-admin and site-manager notices.',
768
+ deliveredBy: 'resend',
769
+ defaultSubject: '{{notification.title}}',
770
+ mergeTokens: [
771
+ {
772
+ name: 'notification.title',
773
+ description: 'The notification, in one line',
774
+ sample: 'Subscription canceled'
775
+ },
776
+ {
777
+ name: 'notification.body',
778
+ description: 'The detail under it, when it has any',
779
+ sample: 'Test Org canceled, leaving the Advanced plan.'
780
+ },
781
+ {
782
+ name: 'notification.url',
783
+ description: 'Where the notification opens in the console',
784
+ sample: `${SAMPLE_CONSOLE_ORIGIN}/manage/notifications`
785
+ },
786
+ {
787
+ name: 'settingsUrl',
788
+ description: 'Notification settings, where email can be turned off',
789
+ sample: `${SAMPLE_CONSOLE_ORIGIN}/manage/notifications/settings`
790
+ }
791
+ ],
792
+ defaultBody: [
793
+ {
794
+ block: 'text',
795
+ text: '{{notification.title}}',
796
+ variant: 'heading'
797
+ },
798
+ {
799
+ block: 'text',
800
+ text: '{{notification.body}}',
801
+ variant: 'body'
802
+ },
803
+ {
804
+ block: 'button',
805
+ label: 'Open in {{brand.productName}}',
806
+ href: '{{notification.url}}'
807
+ },
808
+ {
809
+ block: 'text',
810
+ text: 'Change what you are emailed about: {{settingsUrl}}',
811
+ variant: 'caption'
812
+ }
813
+ ],
814
+ footerReason: 'You’re receiving this because you turned on email for these ' + 'notifications in {{brand.productName}}.',
815
+ source: 'libs/tenant/data/admin/src/lib/server/notifications.ts'
816
+ },
817
+ {
818
+ key: 'staff-alert',
819
+ name: 'Staff alert',
820
+ description: 'Operational alerts to STAFF_ALERT_EMAIL: free-tier AI spend, usage ' + 'margins, SSO re-verification and the like.',
821
+ deliveredBy: 'resend',
822
+ defaultSubject: '{{alert.subject}}',
823
+ mergeTokens: [
824
+ {
825
+ name: 'alert.subject',
826
+ description: 'The alert, in one line',
827
+ sample: 'Free AI spend at 80% of today’s ceiling'
828
+ },
829
+ {
830
+ name: 'alert.body',
831
+ description: 'The alert as its sender wrote it',
832
+ sample: 'Free-tier AI spend for 2026-09-28 (UTC) is at $40.00 of the $50.00 ceiling.'
833
+ }
834
+ ],
835
+ defaultBody: [
836
+ {
837
+ block: 'text',
838
+ text: '{{alert.body}}',
839
+ variant: 'body'
840
+ }
841
+ ],
842
+ footerReason: 'You’re receiving this because this address gets ' + '{{brand.productName}}’s staff alerts.',
843
+ source: 'libs/tenant/data/admin/src/lib/server/staff-alert-email.ts'
844
+ },
845
+ {
846
+ key: 'support-ticket-alert',
847
+ name: 'Support ticket to success manager',
848
+ description: 'Copies an organization’s named success manager on a support ticket ' + 'it opened or replied to.',
849
+ deliveredBy: 'resend',
850
+ defaultSubject: '{{ticket.heading}} — {{org.name}}: {{ticket.subject}}',
851
+ mergeTokens: [
852
+ {
853
+ name: 'ticket.heading',
854
+ description: '“New ticket” or “Ticket reply”',
855
+ sample: 'New ticket'
856
+ },
857
+ {
858
+ name: 'ticket.subject',
859
+ description: 'The ticket’s subject',
860
+ sample: 'Domain will not verify'
861
+ },
862
+ {
863
+ name: 'ticket.body',
864
+ description: 'Who wrote, and what they wrote',
865
+ sample: 'Test Org opened a support ticket.\nFrom: owner@example.com'
866
+ },
867
+ {
868
+ name: 'ticket.url',
869
+ description: 'The ticket in the staff console',
870
+ sample: `${SAMPLE_CONSOLE_ORIGIN}/admin/support`
871
+ },
872
+ {
873
+ name: 'manager.name',
874
+ description: 'The success manager’s name',
875
+ sample: 'Sam'
876
+ },
877
+ {
878
+ name: 'org.name',
879
+ description: 'The organization the ticket is from',
880
+ sample: 'Test Org'
881
+ }
882
+ ],
883
+ defaultBody: [
884
+ {
885
+ block: 'text',
886
+ text: '{{manager.name}},',
887
+ variant: 'body'
888
+ },
889
+ {
890
+ block: 'text',
891
+ text: '{{ticket.body}}',
892
+ variant: 'body'
893
+ },
894
+ {
895
+ block: 'button',
896
+ label: 'Open the ticket',
897
+ href: '{{ticket.url}}'
898
+ }
899
+ ],
900
+ footerReason: 'You’re receiving this because you are the success manager for ' + '{{org.name}} on {{brand.productName}}.',
901
+ source: 'apps/console/app/api/_lib/success-manager.ts'
902
+ },
903
+ {
904
+ key: 'workspace-notice',
905
+ name: 'Workspace notice',
906
+ description: 'Operational notices to a workspace’s people: usage alerts and SSO ' + 'domain lapses to its owners and admins, and a stopped outreach ' + 'mailbox to the member who connected it.',
907
+ deliveredBy: 'resend',
908
+ defaultSubject: '{{notice.subject}}',
909
+ mergeTokens: [
910
+ {
911
+ name: 'notice.subject',
912
+ description: 'The notice, in one line',
913
+ sample: 'Test Org has used 80% of this month’s emails'
914
+ },
915
+ {
916
+ name: 'notice.body',
917
+ description: 'The notice as its sender wrote it',
918
+ sample: 'Test Org has sent 8,000 of its 10,000 emails this month.'
919
+ },
920
+ {
921
+ name: 'org.name',
922
+ description: 'The workspace the notice is about',
923
+ sample: 'Test Org'
924
+ }
925
+ ],
926
+ defaultBody: [
927
+ {
928
+ block: 'text',
929
+ text: '{{notice.body}}',
930
+ variant: 'body'
931
+ }
932
+ ],
933
+ footerReason: 'You’re receiving this because you’re a member of {{org.name}} on ' + '{{brand.productName}}.',
934
+ source: 'libs/tenant/data/admin/src/lib/server/org-member-notice.ts'
935
+ },
936
+ {
937
+ key: 'member-email-alias-confirmation',
938
+ name: 'Member email address confirmation',
939
+ description: 'Sent to an address a workspace member asked to add as one of their ' + 'own, so mail from it is filed under the people it was written to.',
940
+ deliveredBy: 'resend',
941
+ defaultSubject: 'Confirm your address for {{org.name}}',
942
+ mergeTokens: [
943
+ {
944
+ name: 'member.name',
945
+ description: 'The member who asked',
946
+ sample: 'Alex'
947
+ },
948
+ {
949
+ name: 'address',
950
+ description: 'The address being confirmed',
951
+ sample: 'alex@example.com'
952
+ },
953
+ {
954
+ name: 'org.name',
955
+ description: 'The workspace',
956
+ sample: 'Test Org'
957
+ },
958
+ {
959
+ name: 'confirmUrl',
960
+ description: 'The one-time confirmation link',
961
+ sample: `${SAMPLE_CONSOLE_ORIGIN}/manage/account`
962
+ }
963
+ ],
964
+ defaultBody: [
965
+ {
966
+ block: 'text',
967
+ text: 'Confirm this address',
968
+ variant: 'heading'
969
+ },
970
+ {
971
+ block: 'text',
972
+ text: '{{member.name}} asked to add {{address}} as one of their own ' + 'email addresses in the {{org.name}} workspace on ' + '{{brand.productName}}. To confirm it, open the link while ' + 'signed in as that member.',
973
+ variant: 'body'
974
+ },
975
+ {
976
+ block: 'button',
977
+ label: 'Confirm the address',
978
+ href: '{{confirmUrl}}'
979
+ },
980
+ {
981
+ block: 'text',
982
+ text: 'Once it is confirmed, email sent from {{address}} with the ' + 'workspace’s capture address in BCC is filed under the person it ' + 'was written to. The link works for 24 hours.',
983
+ variant: 'body'
984
+ },
985
+ {
986
+ block: 'text',
987
+ text: 'If you did not expect this email, ignore it. Nothing changes ' + 'unless the member who asked opens the link.',
988
+ variant: 'caption'
989
+ }
990
+ ],
991
+ footerReason: 'You’re receiving this because a member of {{org.name}} on ' + '{{brand.productName}} asked to add this address to their account.',
992
+ source: 'libs/tenant/data/admin/src/lib/server/member-email-aliases.ts'
993
+ },
994
+ {
995
+ key: 'crm-daily-digest',
996
+ name: 'Daily CRM digest',
997
+ description: 'Each morning, a member’s overdue and due-today tasks and unworked ' + 'leads, for members who turned the digest on.',
998
+ deliveredBy: 'resend',
999
+ defaultSubject: '{{digest.subject}}',
1000
+ mergeTokens: [
1001
+ {
1002
+ name: 'digest.subject',
1003
+ description: 'The summary line',
1004
+ sample: '2 tasks overdue, 3 new leads'
1005
+ },
1006
+ {
1007
+ name: 'digest.body',
1008
+ description: 'The tasks and leads, one per line, with their links',
1009
+ sample: 'Overdue (2)\n- Call back Jordan · Sep 27, 9:00 AM'
1010
+ },
1011
+ {
1012
+ name: 'settingsUrl',
1013
+ description: 'Notification settings, where the digest is turned off',
1014
+ sample: `${SAMPLE_CONSOLE_ORIGIN}/manage/notifications/settings`
1015
+ }
1016
+ ],
1017
+ defaultBody: [
1018
+ {
1019
+ block: 'text',
1020
+ text: '{{digest.body}}',
1021
+ variant: 'body'
1022
+ },
1023
+ {
1024
+ block: 'text',
1025
+ text: 'Change what you are emailed about: {{settingsUrl}}',
1026
+ variant: 'caption'
1027
+ }
1028
+ ],
1029
+ footerReason: 'You’re receiving this each morning because the Daily CRM digest is ' + 'on in your {{brand.productName}} notification settings.',
1030
+ source: 'apps/console/app/api/crm/daily-digest/route.ts'
1031
+ },
1032
+ {
1033
+ key: 'crm-task-reminder',
1034
+ name: 'CRM task reminder',
1035
+ description: 'A reminder for CRM tasks that have come due.',
1036
+ deliveredBy: 'resend',
1037
+ defaultSubject: '{{reminder.subject}}',
1038
+ mergeTokens: [
1039
+ {
1040
+ name: 'reminder.subject',
1041
+ description: 'The task, or how many are due',
1042
+ sample: 'Reminder: Call back Jordan'
1043
+ },
1044
+ {
1045
+ name: 'reminder.body',
1046
+ description: 'The tasks, each with its link',
1047
+ sample: '- Call back Jordan · due today at 9:00 AM'
1048
+ },
1049
+ {
1050
+ name: 'settingsUrl',
1051
+ description: 'Notification settings, where reminders are turned off',
1052
+ sample: `${SAMPLE_CONSOLE_ORIGIN}/manage/notifications/settings`
1053
+ }
1054
+ ],
1055
+ defaultBody: [
1056
+ {
1057
+ block: 'text',
1058
+ text: '{{reminder.body}}',
1059
+ variant: 'body'
1060
+ },
1061
+ {
1062
+ block: 'text',
1063
+ text: 'Change what you are emailed about: {{settingsUrl}}',
1064
+ variant: 'caption'
1065
+ }
1066
+ ],
1067
+ footerReason: 'You’re receiving this because task reminders are on in your ' + '{{brand.productName}} notification settings.',
1068
+ source: 'apps/console/app/api/crm/task-reminders/route.ts'
1069
+ },
1070
+ {
1071
+ key: 'ai-insights-digest',
1072
+ name: 'Weekly insights digest',
1073
+ description: 'The week’s AI insights for a site, for members who turned the ' + 'digest on for that workspace.',
1074
+ deliveredBy: 'resend',
1075
+ defaultSubject: 'Your weekly insights for {{site.name}}',
1076
+ mergeTokens: [
1077
+ {
1078
+ name: 'site.name',
1079
+ description: 'The site the insights are about',
1080
+ sample: 'Test Site'
1081
+ },
1082
+ {
1083
+ name: 'digest.body',
1084
+ description: 'The insights, numbered, with their links',
1085
+ sample: '1. Visits from search rose 18% on last week.'
1086
+ }
1087
+ ],
1088
+ defaultBody: [
1089
+ {
1090
+ block: 'text',
1091
+ text: 'Weekly insights for {{site.name}}',
1092
+ variant: 'heading'
1093
+ },
1094
+ {
1095
+ block: 'text',
1096
+ text: '{{digest.body}}',
1097
+ variant: 'body'
1098
+ }
1099
+ ],
1100
+ footerReason: 'You’re receiving this because you turned on weekly insights in ' + '{{brand.productName}}.',
1101
+ source: 'libs/plugins/ai/src/lib/insights/ai-insight-digest.ts'
1102
+ },
1103
+ {
1104
+ key: 'legal-intake-receipt',
1105
+ name: 'Report and notice receipt',
1106
+ description: 'The receipt for an abuse report, a copyright notice or a ' + 'counter-notice, carrying its reference.',
1107
+ deliveredBy: 'resend',
1108
+ defaultSubject: '{{receipt.subject}}',
1109
+ mergeTokens: [
1110
+ {
1111
+ name: 'receipt.subject',
1112
+ description: 'What was received, and its reference',
1113
+ sample: 'Report received — AR-3F9K2'
1114
+ },
1115
+ {
1116
+ name: 'receipt.body',
1117
+ description: 'The receipt as the intake wrote it',
1118
+ sample: 'We have received your report about https://example.com.\n\nYour reference is AR-3F9K2.'
1119
+ }
1120
+ ],
1121
+ defaultBody: [
1122
+ {
1123
+ block: 'text',
1124
+ text: '{{receipt.body}}',
1125
+ variant: 'body'
1126
+ }
1127
+ ],
1128
+ footerReason: 'You’re receiving this because this address was given on a report ' + 'or notice sent to {{brand.productName}}.',
1129
+ source: 'apps/tenant/app/api/_legal-intake/acknowledge.ts'
1130
+ },
1131
+ {
1132
+ key: 'plugin-review-update',
1133
+ name: 'Plugin review update',
1134
+ description: 'Tells a marketplace publisher’s owners and admins what review ' + 'decided about their submission.',
1135
+ deliveredBy: 'resend',
1136
+ defaultSubject: '{{review.subject}}',
1137
+ mergeTokens: [
1138
+ {
1139
+ name: 'review.subject',
1140
+ description: 'The decision, in one line',
1141
+ sample: 'Your plugin was approved'
1142
+ },
1143
+ {
1144
+ name: 'review.body',
1145
+ description: 'The decision as review wrote it',
1146
+ sample: 'Test Plugin 1.2.0 passed review and is live in the marketplace.'
1147
+ }
1148
+ ],
1149
+ defaultBody: [
1150
+ {
1151
+ block: 'text',
1152
+ text: '{{review.body}}',
1153
+ variant: 'body'
1154
+ }
1155
+ ],
1156
+ footerReason: 'You’re receiving this because you manage a plugin published on ' + '{{brand.productName}}.',
1157
+ source: 'libs/plugins/marketplace/src/lib/server/publisher-review-email.ts'
1158
+ },
759
1159
  // Stripe-delivered billing email (AGL-767). Aglyn never composes these —
760
1160
  // Stripe sends them from the Dashboard's Customer-emails and Subscription
761
1161
  // settings, so they are listed for visibility only and are not designable.