@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/system-email-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 * Origins used as merge-token SAMPLE values in the staff email designer\n * (AGL-2202).\n *\n * These are never sent — they render in the template preview so an author can\n * see what `{{console.url}}` will become. That still made them wrong on a\n * self-host install: the preview showed Aglyn's console as the example for a\n * link that will resolve to the operator's, which is exactly the sort of\n * quiet mis-teaching that ends up copied into a hand-written template.\n *\n * Read from the environment rather than through `@aglyn/aglyn`'s\n * `platform-brand` / `constants`: `shared-util-email` is tagged `scope:shared`\n * and `@nx/enforce-module-boundaries` refuses the edge. Same defaults, so a\n * deployment that configures nothing sees exactly what it saw before.\n */\nconst SAMPLE_CONSOLE_ORIGIN: string =\n (process.env.NEXT_PUBLIC_CONSOLE_URL || '').trim().replace(/\\/+$/, '') ||\n 'https://app.aglyn.com'\n\n/**\n * Mirrors `PLATFORM_SUPPORT_URL`'s precedence, including the step that makes\n * the operator identity sufficient on its own: a configured support URL, else\n * the operator's support mailbox as a `mailto:`, else the console's own\n * support page.\n *\n * The last step is built from `SAMPLE_CONSOLE_ORIGIN` rather than written out\n * (AGL-3262). The support entry point is a CONSOLE route — `/support`\n * resolves the workspace from the session (AGL-3265) — so the origin it hangs\n * off is the one directly above, and an operator who set\n * `NEXT_PUBLIC_CONSOLE_URL` gets their own console in the sample instead of\n * ours. The literal it replaces was `aglyn.com/support`, which answers 404 and\n * never existed.\n */\nconst SAMPLE_SUPPORT_URL: string =\n (process.env.NEXT_PUBLIC_PLATFORM_SUPPORT_URL || '').trim() ||\n ((process.env.NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL || '').trim()\n ? `mailto:${(process.env.NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL || '').trim()}`\n : `${SAMPLE_CONSOLE_ORIGIN}/support`)\n\n// Deep import, NOT the barrel (AGL-1151): `@aglyn/shared-data-enums` re-exports\n// `firebase-auth`, whose `AuthErrorCodes` is a VALUE import of `firebase/auth`.\n// This module is reachable from the email plugin's site half, which a PUBLISHED\n// tenant page loads, so the barrel put the Firebase auth client into that\n// page's chunks for one brand constant.\nimport { BRAND } from '@aglyn/shared-data-enums/aglyn'\nimport { EMAIL_NODE_ROOT_ID } from './email-render'\n\n/**\n * Who actually puts the message on the wire.\n *\n * `resend` — Aglyn composes and sends it, so a besigner template can change\n * what the recipient sees.\n *\n * `firebase` — Firebase Auth sends it from its own templates, configured in\n * the Firebase console. **A besigner template cannot affect these at all**\n * because the sender owns the copy (AGL-767).\n *\n * `stripe` — Stripe sends it on Aglyn's behalf from the Dashboard's\n * Customer-emails settings (billing receipts, dunning, refunds, expiring-card\n * notices). Aglyn never composes these, so like `firebase` they cannot be\n * designed here (AGL-767).\n *\n * The non-`resend` entries are listed so staff can see the full set of mail\n * the product sends and where each one is controlled; the UI must present\n * them as non-editable rather than offering an editor that silently does\n * nothing.\n */\n/**\n * Who actually puts the message on the wire.\n *\n * `'firebase'` was removed by AGL-1112, once nothing was left that Firebase\n * Auth composed. Deliberately not kept \"just in case\": with the variant gone,\n * reintroducing a Firebase-sent email is a compile error rather than a row in\n * a table that quietly stops being editable. The staff editor is wired to\n * `'resend'`, so any other value silently produces a template that renders\n * nothing — which is exactly how the `[aglyn.io]` subject survived so long.\n */\nexport type SystemEmailDeliveredBy = 'resend' | 'stripe'\n\nexport interface SystemEmailMergeToken {\n /** Token as written in the template, without braces. */\n name: string\n description: string\n /** Stand-in used by the staff preview. */\n sample: string\n}\n\n/**\n * One block of a template's starting content (AGL-764). Declarative on\n * purpose: the catalog stays a dependency-free data module, and the console\n * turns these into email-plugin nodes (`emailSection` → `emailText`/\n * `emailButton`) when it seeds the first version. Mirror the copy the send\n * site uses today, with the same `{{tokens}}`, so a staffer who opens the\n * editor starts from what recipients actually get rather than a blank canvas.\n */\nexport type SystemEmailDefaultBlock =\n | {\n block: 'text'\n text: string\n variant?: 'heading' | 'subheading' | 'body' | 'caption'\n }\n | { block: 'button'; label: string; href: string }\n\nexport interface SystemEmailTemplateDefinition {\n /** Stable storage key — the Firestore document id. Never rename. */\n key: string\n name: string\n description: string\n deliveredBy: SystemEmailDeliveredBy\n /** Subject used when no template has been published. */\n defaultSubject: string\n mergeTokens: SystemEmailMergeToken[]\n /**\n * Starting content for the first version staff design (AGL-764). Every\n * `resend` template should carry one; a `firebase` template has no editor\n * so it needs none. Absent → the editor seeds a minimal placeholder.\n */\n defaultBody?: readonly SystemEmailDefaultBlock[]\n /**\n * The footer's \"why am I getting this\" line (AGL-3322), drawn under the\n * built-in copy, and under any design sent without the platform's own\n * email blocks.\n *\n * Written against the merge tokens, never a product name: on a white-label\n * send `{{brand.productName}}` is the agency's, and on a renamed deployment\n * it is the operator's, so one sentence is right for every sender. Like the\n * body, it may only use tokens the email declares — a token the send does\n * not supply is blanked, and leaves a hole in the sentence. Every `resend`\n * template carries one; the others are never rendered here.\n */\n footerReason?: string\n /**\n * Where the fallback copy lives, for staff wondering what recipients get\n * today. Informational — nothing reads it at runtime.\n */\n source: string\n}\n\n/**\n * Every system email Aglyn sends.\n *\n * Deliberately **code-defined and fixed**: staff edit the system emails that\n * exist, they do not create or delete them. Adding one is a code change,\n * which is what keeps this list from drifting into advertising an email the\n * product never actually sends — the failure mode a Firestore-backed list\n * would have.\n *\n * The staff page renders from this registry, not from Firestore, so an email\n * with no template designed yet still appears (as \"Using default\").\n */\n/**\n * Brand tokens EVERY system email resolves (AGL-2139).\n *\n * White-label used to invert precisely when staff published a template: the\n * designed template wins over each sender's branded fallback, and the copy\n * here hard-coded \"Aglyn\". Nor was there anything to design against — the\n * merge maps carried `org.name`, `invite.role`, `signInUrl` and friends and\n * nothing about the brand, while `blankUnresolvedTokens` DELETES any token\n * the caller did not supply, so a designer who typed `{{brand.productName}}`\n * would have shipped an email with a hole in the sentence.\n *\n * Declared once and appended to every template's own list rather than copied\n * into each: 13 hand-maintained copies is how the token list falls behind the\n * copy, and the editor renders this list as the palette a designer picks\n * from — a token missing here is a token nobody knows exists.\n *\n * The samples are the DEPLOYMENT's own values (`BRAND.ORG_NAME`, AGL-2319),\n * because that is what an unbranded org genuinely renders — on a self-host\n * install a hardcoded \"Aglyn\" would preview a brand that appears nowhere in\n * the mail the designer is editing.\n */\nexport const BRAND_MERGE_TOKENS: readonly SystemEmailMergeToken[] = [\n {\n name: 'brand.productName',\n description: \"The sender's product name — the org's own on white-label\",\n sample: BRAND.ORG_NAME,\n },\n {\n name: 'brand.fromName',\n description: 'Sender display name the mail is delivered under',\n sample: BRAND.ORG_NAME,\n },\n {\n name: 'brand.supportUrl',\n description: 'Where the recipient should go for help',\n sample: SAMPLE_SUPPORT_URL,\n },\n]\n\n// NOT declared: a `brand.logoUrl` token. The logo is STRUCTURAL — the\n// renderer emits it as a header row above the designed body, or emits\n// nothing — so a designer never places it, and a palette entry for it would\n// need a sample URL that a staff test-send would render as a broken image.\n// The three tokens above are the ones that belong in copy.\n\nconst BASE_SYSTEM_EMAIL_TEMPLATES: readonly SystemEmailTemplateDefinition[] =\n [\n {\n key: 'org-invite',\n name: 'Organization invite',\n description:\n 'Sent when an admin invites someone to an organization from the ' +\n 'Team page.',\n deliveredBy: 'resend',\n defaultSubject: \"You've been invited to {{org.name}} on {{brand.productName}}\",\n mergeTokens: [\n {\n name: 'org.name',\n description: 'Name of the inviting organization',\n sample: 'Test Org',\n },\n {\n name: 'invite.role',\n description: 'Role the person is invited as',\n sample: 'editor',\n },\n {\n name: 'signInUrl',\n description: 'Console URL to sign in and accept',\n sample: SAMPLE_CONSOLE_ORIGIN,\n },\n ],\n // Mirrors the fallbackText in invites/route.ts.\n defaultBody: [\n {\n block: 'text',\n text: \"You've been invited to join {{org.name}} as {{invite.role}}.\",\n variant: 'body',\n },\n {\n block: 'text',\n text:\n 'Sign in with this email address and accept the invite from ' +\n 'your dashboard.',\n variant: 'body',\n },\n { block: 'button', label: 'Sign in', href: '{{signInUrl}}' },\n ],\n footerReason:\n 'You’re receiving this because someone invited you to join ' +\n '{{org.name}} on {{brand.productName}}.',\n source: 'apps/console/app/api/orgs/invites/route.ts',\n },\n {\n key: 'usage-summary',\n name: 'Monthly usage summary',\n description:\n 'Monthly per-organization usage and metered-cost summary, sent by ' +\n 'the scheduled job.',\n deliveredBy: 'resend',\n defaultSubject: 'Your {{brand.productName}} usage summary for {{month}}',\n mergeTokens: [\n {\n name: 'month',\n description: 'Billing month as YYYY-MM',\n sample: '2026-06',\n },\n {\n name: 'org.name',\n description: 'Organization the summary is for',\n sample: 'Test Org',\n },\n {\n name: 'usage.summary',\n description: 'Pre-formatted usage lines',\n sample: 'Page views: 12,400',\n },\n ],\n // Mirrors the summary lines assembled in usage-email/route.ts.\n defaultBody: [\n { block: 'text', text: 'Usage summary for {{org.name}}', variant: 'heading' },\n {\n block: 'text',\n text: 'Here is your {{brand.productName}} usage summary for {{month}}.',\n variant: 'body',\n },\n { block: 'text', text: '{{usage.summary}}', variant: 'body' },\n ],\n footerReason:\n 'You’re receiving this monthly summary because you manage ' +\n '{{org.name}} on {{brand.productName}}.',\n source: 'apps/console/app/api/billing/usage-email/route.ts',\n },\n {\n key: 'erasure-hold-alert',\n name: 'Erasure hold staff alert',\n description:\n 'Internal alert to STAFF_ALERT_EMAIL when GDPR erasure requests ' +\n 'pass their 7-day hold.',\n deliveredBy: 'resend',\n defaultSubject: '{{count}} erasure request(s) past the 7-day hold',\n mergeTokens: [\n {\n name: 'count',\n description: 'How many organizations are past the hold',\n sample: '3',\n },\n {\n name: 'orgs.list',\n description: 'The organizations, one per line',\n sample: '- Test Org (org_123), requested 2026-07-01',\n },\n ],\n // Mirrors the staff-alert text in audit-archive/route.ts.\n defaultBody: [\n {\n block: 'text',\n text:\n 'These organizations are past their GDPR erasure hold. Run ' +\n 'tools/scripts/erase-tenant.mjs to hard-delete. No copy is kept:',\n variant: 'body',\n },\n { block: 'text', text: '{{orgs.list}}', variant: 'body' },\n ],\n footerReason:\n 'You’re receiving this because this address gets ' +\n '{{brand.productName}}’s staff alerts.',\n source: 'apps/console/app/api/admin/audit-archive/route.ts',\n },\n {\n key: 'welcome',\n name: 'Welcome',\n description:\n 'Sent to a new owner the first time they create an organization ' +\n '(not on every organization they later create).',\n deliveredBy: 'resend',\n defaultSubject: 'Welcome to {{brand.productName}}',\n mergeTokens: [\n {\n name: 'name',\n description: \"The new owner's display name\",\n sample: 'Alex',\n },\n {\n name: 'org.name',\n description: 'The organization they just created',\n sample: 'Test Org',\n },\n {\n name: 'consoleUrl',\n description: 'Console dashboard URL',\n sample: SAMPLE_CONSOLE_ORIGIN,\n },\n ],\n // Mirrors the fallbackText in orgs/create/route.ts.\n defaultBody: [\n { block: 'text', text: 'Welcome to {{brand.productName}}', variant: 'heading' },\n {\n block: 'text',\n text:\n 'Hi {{name}}, thanks for creating {{org.name}}. Your workspace ' +\n 'is ready.',\n variant: 'body',\n },\n {\n block: 'text',\n text:\n 'From your dashboard you can add a site, invite your team, and ' +\n 'start building.',\n variant: 'body',\n },\n { block: 'button', label: 'Open your dashboard', href: '{{consoleUrl}}' },\n ],\n footerReason:\n 'You’re receiving this because you created an organization on ' +\n '{{brand.productName}}.',\n source: 'apps/console/app/api/orgs/create/route.ts',\n },\n {\n key: 'member-added',\n name: 'Added to an organization',\n description:\n `Sent when an existing ${BRAND.ORG_NAME} account is added directly ` +\n 'to an organization. People who do not have an account yet get the ' +\n 'organization invite email instead.',\n deliveredBy: 'resend',\n defaultSubject: \"You've been added to {{org.name}}\",\n mergeTokens: [\n {\n name: 'org.name',\n description: 'The organization they were added to',\n sample: 'Test Org',\n },\n {\n name: 'member.role',\n description: 'Role they were given',\n sample: 'editor',\n },\n {\n name: 'signInUrl',\n description: 'Console URL to open the organization',\n sample: SAMPLE_CONSOLE_ORIGIN,\n },\n ],\n // Mirrors the fallbackText in orgs/members/route.ts.\n defaultBody: [\n {\n block: 'text',\n text: 'You were added to {{org.name}} as {{member.role}}.',\n variant: 'body',\n },\n {\n block: 'text',\n text: 'Sign in to switch to it from your dashboard.',\n variant: 'body',\n },\n { block: 'button', label: 'Open {{brand.productName}}', href: '{{signInUrl}}' },\n ],\n footerReason:\n 'You’re receiving this because you were added to {{org.name}} ' +\n 'on {{brand.productName}}.',\n source: 'apps/console/app/api/orgs/members/route.ts',\n },\n {\n key: 'erasure-confirmation',\n name: 'Erasure completed',\n description:\n 'Sent to an organization owner once its data has been permanently ' +\n 'erased under a GDPR request.',\n deliveredBy: 'resend',\n defaultSubject: 'Your {{brand.productName}} data has been erased',\n mergeTokens: [\n {\n name: 'org.name',\n description: 'The organization that was erased',\n sample: 'Test Org',\n },\n ],\n // Mirrors the fallbackText in admin/run-erasures/route.ts.\n defaultBody: [\n { block: 'text', text: 'Your data has been erased', variant: 'heading' },\n {\n block: 'text',\n text:\n '{{org.name}} and all of its data have been permanently erased ' +\n 'from {{brand.productName}}, as requested.',\n variant: 'body',\n },\n {\n block: 'text',\n text: 'This is complete and cannot be undone.',\n variant: 'body',\n },\n ],\n footerReason:\n 'You’re receiving this because you asked {{brand.productName}} ' +\n 'to erase {{org.name}}’s data.',\n source: 'apps/console/app/api/admin/run-erasures/route.ts',\n },\n {\n key: 'erasure-requested',\n name: 'Erasure requested',\n description:\n 'Confirms to an organization owner that a GDPR erasure request was ' +\n 'recorded, and that deletion follows after a 7-day hold.',\n deliveredBy: 'resend',\n defaultSubject: 'We received your erasure request',\n mergeTokens: [\n {\n name: 'org.name',\n description: 'The organization to be erased',\n sample: 'Test Org',\n },\n ],\n // Mirrors the fallbackText in admin/erasure-request/route.ts.\n defaultBody: [\n { block: 'text', text: 'Erasure request received', variant: 'heading' },\n {\n block: 'text',\n text:\n 'We have recorded a request to erase {{org.name}} and all of ' +\n 'its data from {{brand.productName}}.',\n variant: 'body',\n },\n {\n block: 'text',\n text:\n 'Deletion is permanent and happens after a 7-day hold. If this ' +\n 'was not intended, contact support before then to cancel.',\n variant: 'body',\n },\n ],\n footerReason:\n 'You’re receiving this because you own {{org.name}} on ' +\n '{{brand.productName}}.',\n source: 'apps/console/app/api/admin/erasure-request/route.ts',\n },\n // Admin-initiated password mail (AGL-910). Unlike the self-serve\n // `password-reset` below, Aglyn composes and sends these itself — the\n // reset LINK still comes from Firebase (generatePasswordResetLink), but\n // the message around it is ours, so both are designable.\n {\n key: 'admin-password-reset',\n name: 'Password reset (sent by an admin)',\n description:\n 'Password reset link sent on someone else\\'s behalf from the ' +\n 'console — by staff from a user\\'s detail page, or by an ' +\n 'organization admin from a team member\\'s page.',\n deliveredBy: 'resend',\n defaultSubject: 'Reset your {{brand.productName}} password',\n mergeTokens: [\n {\n name: 'resetUrl',\n description: 'One-time link that opens the reset form',\n sample: `${SAMPLE_CONSOLE_ORIGIN}/reset-password?oobCode=…`,\n },\n {\n name: 'actor.name',\n description: 'Who asked for the reset',\n sample: '{{brand.productName}} support',\n },\n ],\n // Mirrors the fallbackText in _lib/password-admin.ts.\n defaultBody: [\n { block: 'text', text: 'Reset your password', variant: 'heading' },\n {\n block: 'text',\n text:\n '{{actor.name}} started a password reset for your {{brand.productName}} ' +\n 'account. Choose a new password here:',\n variant: 'body',\n },\n { block: 'button', label: 'Set a new password', href: '{{resetUrl}}' },\n {\n block: 'text',\n text:\n 'The link expires shortly. If you were not expecting this, you ' +\n 'can ignore this email — your password stays as it is.',\n variant: 'caption',\n },\n ],\n footerReason:\n 'You’re receiving this because an administrator started a ' +\n 'password reset for your {{brand.productName}} account.',\n source: 'apps/console/app/api/_lib/password-admin.ts',\n },\n {\n key: 'password-changed-by-admin',\n name: 'Password changed by an admin',\n description:\n 'Notice sent to the account holder after an admin sets their ' +\n 'password directly. Always sent — it is how someone finds out an ' +\n 'administrator changed their credentials.',\n deliveredBy: 'resend',\n defaultSubject: 'Your {{brand.productName}} password was changed',\n mergeTokens: [\n {\n name: 'actor.name',\n description: 'Who changed the password',\n sample: '{{brand.productName}} support',\n },\n {\n name: 'signInUrl',\n description: 'Where to sign in with the new password',\n sample: SAMPLE_CONSOLE_ORIGIN,\n },\n ],\n // Mirrors the fallbackText in _lib/password-admin.ts.\n defaultBody: [\n { block: 'text', text: 'Your password was changed', variant: 'heading' },\n {\n block: 'text',\n text:\n '{{actor.name}} set a new password on your account. You have ' +\n 'been signed out everywhere and will need the new password to ' +\n 'sign back in.',\n variant: 'body',\n },\n { block: 'button', label: 'Sign in', href: '{{signInUrl}}' },\n {\n block: 'text',\n text:\n 'If you did not expect this, contact whoever administers your ' +\n 'account straight away.',\n variant: 'caption',\n },\n ],\n footerReason:\n 'You’re receiving this because an administrator changed the ' +\n 'password on your {{brand.productName}} account.',\n source: 'apps/console/app/api/_lib/password-admin.ts',\n },\n // Security alerts (AGL-665). Factual and actionable: what happened, from\n // where, and one button to the account-security surface. These must be\n // believed, so the send path only fires them on genuinely new devices —\n // never on every sign-in.\n {\n key: 'security-new-device',\n name: 'New device sign-in',\n description:\n 'Security alert sent when an account signs in from a device it has ' +\n 'not used before. Not sent on routine sign-ins from known devices.',\n deliveredBy: 'resend',\n defaultSubject: 'New sign-in to your {{brand.productName}} account',\n mergeTokens: [\n {\n name: 'device.name',\n description: 'Browser and operating system that signed in',\n sample: 'Chrome on macOS',\n },\n {\n name: 'device.location',\n description: 'Approximate location, from the request IP',\n sample: 'Denver, CO, US',\n },\n {\n name: 'device.ip',\n description: 'IP address the sign-in came from',\n sample: '203.0.113.7',\n },\n {\n name: 'device.time',\n description: 'When the sign-in happened (UTC)',\n sample: '2026-08-08 14:03 UTC',\n },\n {\n name: 'accountSecurityUrl',\n description:\n 'Manage-account Security section — Recent sign-ins, where the ' +\n 'device can be signed out, plus password and passkeys',\n sample: `${SAMPLE_CONSOLE_ORIGIN}/manage/user/security`,\n },\n ],\n // Mirrors the fallbackText in _lib/security-alerts.ts.\n defaultBody: [\n { block: 'text', text: 'New device sign-in', variant: 'heading' },\n {\n block: 'text',\n text:\n 'Your {{brand.productName}} account was just signed in to from a device it has ' +\n 'not used before.',\n variant: 'body',\n },\n { block: 'text', text: 'Device: {{device.name}}', variant: 'body' },\n { block: 'text', text: 'Location: {{device.location}}', variant: 'body' },\n { block: 'text', text: 'IP address: {{device.ip}}', variant: 'body' },\n { block: 'text', text: 'When: {{device.time}}', variant: 'body' },\n {\n // AGL-1959. The button used to say \"Review account security\" and\n // land on the account page's DEFAULT tab, which offered a password\n // change and nothing about the device the mail is about. AGL-665\n // asked for this: \"'Manage your devices' has to land somewhere that\n // can list and revoke sessions, or the alert tells you something is\n // wrong and offers no way to act on it — which is worse than sending\n // nothing.\"\n block: 'button',\n label: 'Manage your devices',\n href: '{{accountSecurityUrl}}',\n },\n {\n block: 'text',\n text:\n 'If this was you, no action is needed. If it was not, sign this ' +\n 'device out from Recent sign-ins and change your password right ' +\n 'away.',\n variant: 'caption',\n },\n ],\n footerReason:\n 'You’re receiving this because your {{brand.productName}} ' +\n 'account signed in from a new device.',\n source: 'apps/console/app/api/_lib/security-alerts.ts',\n },\n {\n key: 'security-passkey-added',\n name: 'New passkey added',\n description:\n 'Security alert sent when a passkey is registered on an account. ' +\n 'The trigger ships with passkey support (AGL-662); until then no ' +\n 'call site sends this.',\n deliveredBy: 'resend',\n defaultSubject: 'A passkey was added to your {{brand.productName}} account',\n mergeTokens: [\n {\n name: 'passkey.label',\n description: 'Name the passkey was saved under',\n sample: 'MacBook Touch ID',\n },\n {\n name: 'device.time',\n description: 'When the passkey was added (UTC)',\n sample: '2026-08-08 14:03 UTC',\n },\n {\n name: 'accountSecurityUrl',\n description: 'Manage-account page where credentials are reviewed',\n sample: `${SAMPLE_CONSOLE_ORIGIN}/manage/user`,\n },\n ],\n // Mirrors the fallbackText in _lib/security-alerts.ts.\n defaultBody: [\n { block: 'text', text: 'New passkey added', variant: 'heading' },\n {\n block: 'text',\n text:\n 'A passkey (\"{{passkey.label}}\") was just added to your {{brand.productName}} ' +\n 'account. It can be used to sign in without your password.',\n variant: 'body',\n },\n { block: 'text', text: 'When: {{device.time}}', variant: 'body' },\n {\n block: 'button',\n label: 'Review account security',\n href: '{{accountSecurityUrl}}',\n },\n {\n block: 'text',\n text:\n 'If you did not add this passkey, change your password from ' +\n 'Manage account right away.',\n variant: 'caption',\n },\n ],\n footerReason:\n 'You’re receiving this because a passkey was added to your ' +\n '{{brand.productName}} account.',\n source: 'apps/console/app/api/_lib/security-alerts.ts',\n },\n // Aglyn composes and sends both of these now (AGL-1112). They were\n // Firebase's, from a template nobody could edit — the subject still\n // carried `[aglyn.io]`, a domain the company no longer uses, and the link\n // landed on `aglyn-main.firebaseapp.com`. Neither could be fixed: every\n // write under `notification.sendEmail` is refused with\n // `EMAIL_TEMPLATE_UPDATE_NOT_ALLOWED`.\n //\n // Firebase still MINTS the one-time code, which is the part that has to\n // be its. Only the message around it, and the host the link points at,\n // became ours. So editing these here now does what it says.\n {\n key: 'password-reset',\n name: 'Forgot password',\n description:\n 'Password reset link, sent when someone asks for one from Account ' +\n 'recovery.',\n deliveredBy: 'resend',\n defaultSubject: 'Reset your {{brand.productName}} password',\n mergeTokens: [\n {\n name: 'resetUrl',\n description: 'One-time link that opens the choose-a-new-password page',\n sample: `${SAMPLE_CONSOLE_ORIGIN}/reset-password?oobCode=\\u2026`,\n },\n ],\n // Mirrors the fallbackText in the route, so the designed and undesigned\n // versions say the same thing.\n defaultBody: [\n { block: 'text', text: 'Reset your password', variant: 'heading' },\n {\n block: 'text',\n text:\n 'Someone asked to reset the password for your {{brand.productName}} account. ' +\n 'Choose a new one here:',\n variant: 'body',\n },\n { block: 'button', label: 'Choose a new password', href: '{{resetUrl}}' },\n {\n block: 'text',\n text:\n 'The link expires shortly. If this was not you, you can ignore ' +\n 'this email — your password stays as it is.',\n variant: 'caption',\n },\n ],\n footerReason:\n 'You’re receiving this because someone asked to reset the ' +\n 'password on your {{brand.productName}} account.',\n source: 'apps/console/app/api/auth/send-password-reset/route.ts',\n },\n {\n key: 'email-verification',\n name: 'Confirm email',\n description:\n 'Address verification link, sent after sign-up and whenever someone ' +\n 'asks for another from the verify screen.',\n deliveredBy: 'resend',\n defaultSubject: 'Confirm your email address',\n mergeTokens: [\n {\n name: 'verifyUrl',\n description: 'One-time link that confirms the address',\n sample: `${SAMPLE_CONSOLE_ORIGIN}/verify-email?oobCode=\\u2026`,\n },\n ],\n defaultBody: [\n { block: 'text', text: 'Confirm your email', variant: 'heading' },\n {\n block: 'text',\n text:\n 'Confirm this address to finish setting up your {{brand.productName}} account:',\n variant: 'body',\n },\n { block: 'button', label: 'Confirm my email', href: '{{verifyUrl}}' },\n {\n block: 'text',\n text:\n 'If you did not create an {{brand.productName}} account, you can ignore this ' +\n 'email.',\n variant: 'caption',\n },\n ],\n footerReason:\n 'You’re receiving this because this address was used to sign ' +\n 'up for {{brand.productName}}.',\n source: 'apps/console/app/api/auth/send-verification/route.ts',\n },\n // Its own entry rather than a second use of the one above, because every\n // line of that one is about signing up: \"finish setting up your account\",\n // \"if you did not create an account\", a footer saying the address was\n // used to sign up. This mail goes to an address someone is ADDING to an\n // account that already exists, and the one thing its reader needs to hear\n // is that ignoring it changes nothing.\n {\n key: 'email-address-confirmation',\n name: 'Confirm added email',\n description:\n 'Confirmation link, sent when someone adds another email address to ' +\n 'their account under Manage Account → Email addresses.',\n deliveredBy: 'resend',\n defaultSubject: 'Confirm your email address',\n mergeTokens: [\n {\n name: 'confirmUrl',\n description: 'One-time link that adds the address to the account',\n sample: `${SAMPLE_CONSOLE_ORIGIN}/manage/user?confirmEmail=…`,\n },\n ],\n // Mirrors the fallbackText in the route.\n defaultBody: [\n { block: 'text', text: 'Confirm this email address', variant: 'heading' },\n {\n block: 'text',\n text:\n 'Confirm this address so you can use it with your ' +\n '{{brand.productName}} account:',\n variant: 'body',\n },\n { block: 'button', label: 'Confirm email address', href: '{{confirmUrl}}' },\n {\n block: 'text',\n text:\n 'If you did not ask to add this address, you can ignore this ' +\n 'email — nothing has changed and the address has not been added ' +\n 'to any account.',\n variant: 'caption',\n },\n ],\n footerReason:\n 'You’re receiving this because someone asked to add this address ' +\n 'to a {{brand.productName}} account.',\n source: 'apps/console/app/api/account/emails/route.ts',\n },\n // Stripe-delivered billing email (AGL-767). Aglyn never composes these —\n // Stripe sends them from the Dashboard's Customer-emails and Subscription\n // settings, so they are listed for visibility only and are not designable.\n // Whether each actually sends is a Dashboard toggle the code cannot read;\n // the descriptions say \"if enabled\" rather than asserting it is on.\n {\n key: 'stripe-receipt',\n name: 'Payment receipt',\n description:\n 'Receipt for a successful subscription or invoice payment. Sent by ' +\n 'Stripe if \"Successful payments\" is enabled under Customer emails.',\n deliveredBy: 'stripe',\n defaultSubject: `Your receipt from ${BRAND.ORG_NAME}`,\n mergeTokens: [],\n source: 'Stripe Dashboard → Settings → Customer emails',\n },\n {\n key: 'stripe-payment-failed',\n name: 'Payment failed',\n description:\n 'Dunning notice when a subscription charge fails and Stripe retries ' +\n 'it. Sent by Stripe if failed-payment emails are enabled under ' +\n 'Subscriptions and emails.',\n deliveredBy: 'stripe',\n defaultSubject: `Your ${BRAND.ORG_NAME} payment could not be processed`,\n mergeTokens: [],\n source: 'Stripe Dashboard → Settings → Subscriptions and emails',\n },\n {\n key: 'stripe-refund',\n name: 'Refund confirmation',\n description:\n 'Confirms a refund back to the customer. Sent by Stripe if ' +\n '\"Refunds\" is enabled under Customer emails.',\n deliveredBy: 'stripe',\n defaultSubject: `Your ${BRAND.ORG_NAME} refund`,\n mergeTokens: [],\n source: 'Stripe Dashboard → Settings → Customer emails',\n },\n {\n key: 'stripe-card-expiring',\n name: 'Card expiring soon',\n description:\n 'Reminder that the card on file is about to expire. Sent by Stripe ' +\n 'if expiring-card reminders are enabled under Subscriptions and ' +\n 'emails.',\n deliveredBy: 'stripe',\n defaultSubject: 'Your card on file is expiring soon',\n mergeTokens: [],\n source: 'Stripe Dashboard → Settings → Subscriptions and emails',\n },\n {\n key: 'stripe-invoice',\n name: 'Invoice',\n description:\n 'A finalized invoice emailed to the organization. Sent by Stripe ' +\n 'when invoice emails are enabled for the billing configuration.',\n deliveredBy: 'stripe',\n defaultSubject: `Your ${BRAND.ORG_NAME} invoice`,\n mergeTokens: [],\n source: 'Stripe Dashboard → Settings → Customer emails',\n },\n ]\n\n/**\n * Every system email Aglyn sends, each with the universal brand tokens\n * appended to its own (AGL-2139).\n *\n * The template's own tokens come FIRST so the editor's palette still leads\n * with what is specific to that email; the brand block reads as the shared\n * footer it is.\n */\nexport const SYSTEM_EMAIL_TEMPLATES: readonly SystemEmailTemplateDefinition[] =\n BASE_SYSTEM_EMAIL_TEMPLATES.map((entry) => ({\n ...entry,\n mergeTokens: [...entry.mergeTokens, ...BRAND_MERGE_TOKENS],\n }))\n\n/** Firestore collection holding the designed templates. */\nexport const SYSTEM_EMAIL_COLLECTION = 'systemEmailTemplates'\n\nexport function getSystemEmailTemplate(\n key: string,\n): SystemEmailTemplateDefinition | undefined {\n return SYSTEM_EMAIL_TEMPLATES.find((entry) => entry.key === key)\n}\n\n/** True when a besigner template can actually change what is delivered. */\nexport function isSystemEmailEditable(\n definition: SystemEmailTemplateDefinition,\n): boolean {\n return definition.deliveredBy === 'resend'\n}\n\n/** The stored template document, when staff have designed one. */\nexport interface SystemEmailTemplateDoc {\n subject?: string\n preheader?: string\n versionId?: string\n updatedAt?: unknown\n updatedByEmail?: string\n}\n\n/** A besigner node in the flat, denormalized map a version stores. */\nexport interface SystemEmailNode {\n $id: string\n componentId: string\n pluginId?: string\n parentId?: string\n props?: Record<string, unknown>\n nodes?: string[]\n}\n\nconst PLACEHOLDER_DEFAULT_BODY: readonly SystemEmailDefaultBlock[] = [\n { block: 'text', text: 'Hello,', variant: 'body' },\n]\n\n/**\n * Turns a template's `defaultBody` into the besigner node map the editor\n * seeds and the send-time default renders (AGL-764/766).\n *\n * One place builds this so the two never diverge — the version a staffer\n * opens is byte-for-byte what a test/send renders when nothing is published.\n * Rooted at {@link EMAIL_NODE_ROOT_ID} so `renderEmailHtml` finds it (AGL-765),\n * with deterministic ids: a version doc only needs ids unique within itself,\n * and determinism keeps the map stable and diffable.\n */\nexport function buildDefaultEmailNodeMap(\n // Structural so the tenant catalog (AGL-770) can seed the same way — every\n // definition that carries a `defaultBody` builds an identical node map.\n definition: { defaultBody?: readonly SystemEmailDefaultBlock[] },\n): Record<string, SystemEmailNode> {\n const blocks = definition.defaultBody?.length\n ? definition.defaultBody\n : PLACEHOLDER_DEFAULT_BODY\n const sectionId = 'default-section'\n const childIds: string[] = []\n const map: Record<string, SystemEmailNode> = {}\n\n blocks.forEach((block, index) => {\n const id = `default-${index}`\n childIds.push(id)\n map[id] =\n block.block === 'button'\n ? {\n $id: id,\n componentId: 'emailButton',\n pluginId: 'email',\n parentId: sectionId,\n props: { children: block.label, href: block.href },\n }\n : {\n $id: id,\n componentId: 'emailText',\n pluginId: 'email',\n parentId: sectionId,\n props: { children: block.text, variant: block.variant ?? 'body' },\n }\n })\n\n map[EMAIL_NODE_ROOT_ID] = {\n $id: EMAIL_NODE_ROOT_ID,\n componentId: 'div',\n nodes: [sectionId],\n }\n map[sectionId] = {\n $id: sectionId,\n componentId: 'emailSection',\n pluginId: 'email',\n parentId: EMAIL_NODE_ROOT_ID,\n nodes: childIds,\n }\n return map\n}\n"],"names":["SAMPLE_CONSOLE_ORIGIN","process","env","NEXT_PUBLIC_CONSOLE_URL","trim","replace","SAMPLE_SUPPORT_URL","NEXT_PUBLIC_PLATFORM_SUPPORT_URL","NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL","BRAND","EMAIL_NODE_ROOT_ID","BRAND_MERGE_TOKENS","name","description","sample","ORG_NAME","BASE_SYSTEM_EMAIL_TEMPLATES","key","deliveredBy","defaultSubject","mergeTokens","defaultBody","block","text","variant","label","href","footerReason","source","SYSTEM_EMAIL_TEMPLATES","map","entry","SYSTEM_EMAIL_COLLECTION","getSystemEmailTemplate","find","isSystemEmailEditable","definition","PLACEHOLDER_DEFAULT_BODY","buildDefaultEmailNodeMap","blocks","length","sectionId","childIds","forEach","index","id","push","$id","componentId","pluginId","parentId","props","children","nodes"],"mappings":"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OAAO;gBACPC,MACE,iEACA;gBACFC,SAAS;YACX;YACA;gBACEF,OAAO;gBACPC,MACE,mEACA;gBACFC,SAAS;YACX;SACD;QACDG,cACE,2DACA;QACFC,QAAQ;IACV;IACA,iEAAiE;IACjE,sEAAsE;IACtE,wEAAwE;IACxE,yDAAyD;IACzD;QACEX,KAAK;QACLL,MAAM;QACNC,aACE,iEACA,6DACA;QACFK,aAAa;QACbC,gBAAgB;QAChBC,aAAa;YACX;gBACER,MAAM;gBACNC,aAAa;gBACbC,QAAQ,GAAGd,sBAAsB,yBAAyB,CAAC;YAC7D;YACA;gBACEY,MAAM;gBACNC,aAAa;gBACbC,QAAQ;YACV;SACD;QACD,sDAAsD;QACtDO,aAAa;YACX;gBAAEC,OAAO;gBAAQC,MAAM;gBAAuBC,SAAS;YAAU;YACjE;gBACEF,OAAO;gBACPC,MACE,4EACA;gBACFC,SAAS;YACX;YACA;gBAAEF,OAAO;gBAAUG,OAAO;gBAAsBC,MAAM;YAAe;YACrE;gBACEJ,OAAO;gBACPC,MACE,mEACA;gBACFC,SAAS;YACX;SACD;QACDG,cACE,8DACA;QACFC,QAAQ;IACV;IACA;QACEX,KAAK;QACLL,MAAM;QACNC,aACE,iEACA,qEACA;QACFK,aAAa;QACbC,gBAAgB;QAChBC,aAAa;YACX;gBACER,MAAM;gBACNC,aAAa;gBACbC,QAAQ;YACV;YACA;gBACEF,MAAM;gBACNC,aAAa;gBACbC,QAAQd;YACV;SACD;QACD,sDAAsD;QACtDqB,aAAa;YACX;gBAAEC,OAAO;gBAAQC,MAAM;gBAA6BC,SAAS;YAAU;YACvE;gBACEF,OAAO;gBACPC,MACE,iEACA,kEACA;gBACFC,SAAS;YACX;YACA;gBAAEF,OAAO;gBAAUG,OAAO;gBAAWC,MAAM;YAAgB;YAC3D;gBACEJ,OAAO;gBACPC,MACE,kEACA;gBACFC,SAAS;YACX;SACD;QACDG,cACE,gEACA;QACFC,QAAQ;IACV;IACA,yEAAyE;IACzE,uEAAuE;IACvE,wEAAwE;IACxE,0BAA0B;IAC1B;QACEX,KAAK;QACLL,MAAM;QACNC,aACE,uEAC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"}
1
+ {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/system-email-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 * Origins used as merge-token SAMPLE values in the staff email designer\n * (AGL-2202).\n *\n * These are never sent — they render in the template preview so an author can\n * see what `{{console.url}}` will become. That still made them wrong on a\n * self-host install: the preview showed Aglyn's console as the example for a\n * link that will resolve to the operator's, which is exactly the sort of\n * quiet mis-teaching that ends up copied into a hand-written template.\n *\n * Read from the environment rather than through `@aglyn/aglyn`'s\n * `platform-brand` / `constants`: `shared-util-email` is tagged `scope:shared`\n * and `@nx/enforce-module-boundaries` refuses the edge. Same defaults, so a\n * deployment that configures nothing sees exactly what it saw before.\n */\nconst SAMPLE_CONSOLE_ORIGIN: string =\n (process.env.NEXT_PUBLIC_CONSOLE_URL || '').trim().replace(/\\/+$/, '') ||\n 'https://app.aglyn.com'\n\n/**\n * Mirrors `PLATFORM_SUPPORT_URL`'s precedence, including the step that makes\n * the operator identity sufficient on its own: a configured support URL, else\n * the operator's support mailbox as a `mailto:`, else the console's own\n * support page.\n *\n * The last step is built from `SAMPLE_CONSOLE_ORIGIN` rather than written out\n * (AGL-3262). The support entry point is a CONSOLE route — `/support`\n * resolves the workspace from the session (AGL-3265) — so the origin it hangs\n * off is the one directly above, and an operator who set\n * `NEXT_PUBLIC_CONSOLE_URL` gets their own console in the sample instead of\n * ours. The literal it replaces was `aglyn.com/support`, which answers 404 and\n * never existed.\n */\nconst SAMPLE_SUPPORT_URL: string =\n (process.env.NEXT_PUBLIC_PLATFORM_SUPPORT_URL || '').trim() ||\n ((process.env.NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL || '').trim()\n ? `mailto:${(process.env.NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL || '').trim()}`\n : `${SAMPLE_CONSOLE_ORIGIN}/support`)\n\n// Deep import, NOT the barrel (AGL-1151): `@aglyn/shared-data-enums` re-exports\n// `firebase-auth`, whose `AuthErrorCodes` is a VALUE import of `firebase/auth`.\n// This module is reachable from the email plugin's site half, which a PUBLISHED\n// tenant page loads, so the barrel put the Firebase auth client into that\n// page's chunks for one brand constant.\nimport { BRAND } from '@aglyn/shared-data-enums/aglyn'\nimport { EMAIL_NODE_ROOT_ID } from './email-render'\n\n/**\n * Who actually puts the message on the wire.\n *\n * `resend` — Aglyn composes and sends it, so a besigner template can change\n * what the recipient sees.\n *\n * `firebase` — Firebase Auth sends it from its own templates, configured in\n * the Firebase console. **A besigner template cannot affect these at all**\n * because the sender owns the copy (AGL-767).\n *\n * `stripe` — Stripe sends it on Aglyn's behalf from the Dashboard's\n * Customer-emails settings (billing receipts, dunning, refunds, expiring-card\n * notices). Aglyn never composes these, so like `firebase` they cannot be\n * designed here (AGL-767).\n *\n * The non-`resend` entries are listed so staff can see the full set of mail\n * the product sends and where each one is controlled; the UI must present\n * them as non-editable rather than offering an editor that silently does\n * nothing.\n */\n/**\n * Who actually puts the message on the wire.\n *\n * `'firebase'` was removed by AGL-1112, once nothing was left that Firebase\n * Auth composed. Deliberately not kept \"just in case\": with the variant gone,\n * reintroducing a Firebase-sent email is a compile error rather than a row in\n * a table that quietly stops being editable. The staff editor is wired to\n * `'resend'`, so any other value silently produces a template that renders\n * nothing — which is exactly how the `[aglyn.io]` subject survived so long.\n */\nexport type SystemEmailDeliveredBy = 'resend' | 'stripe'\n\nexport interface SystemEmailMergeToken {\n /** Token as written in the template, without braces. */\n name: string\n description: string\n /** Stand-in used by the staff preview. */\n sample: string\n}\n\n/**\n * One block of a template's starting content (AGL-764). Declarative on\n * purpose: the catalog stays a dependency-free data module, and the console\n * turns these into email-plugin nodes (`emailSection` → `emailText`/\n * `emailButton`) when it seeds the first version. Mirror the copy the send\n * site uses today, with the same `{{tokens}}`, so a staffer who opens the\n * editor starts from what recipients actually get rather than a blank canvas.\n */\nexport type SystemEmailDefaultBlock =\n | {\n block: 'text'\n text: string\n variant?: 'heading' | 'subheading' | 'body' | 'caption'\n }\n | { block: 'button'; label: string; href: string }\n\nexport interface SystemEmailTemplateDefinition {\n /** Stable storage key — the Firestore document id. Never rename. */\n key: string\n name: string\n description: string\n deliveredBy: SystemEmailDeliveredBy\n /** Subject used when no template has been published. */\n defaultSubject: string\n mergeTokens: SystemEmailMergeToken[]\n /**\n * Starting content for the first version staff design (AGL-764). Every\n * `resend` template should carry one; a `firebase` template has no editor\n * so it needs none. Absent → the editor seeds a minimal placeholder.\n */\n defaultBody?: readonly SystemEmailDefaultBlock[]\n /**\n * The footer's \"why am I getting this\" line (AGL-3322), drawn under the\n * built-in copy, and under any design sent without the platform's own\n * email blocks.\n *\n * Written against the merge tokens, never a product name: on a white-label\n * send `{{brand.productName}}` is the agency's, and on a renamed deployment\n * it is the operator's, so one sentence is right for every sender. Like the\n * body, it may only use tokens the email declares — a token the send does\n * not supply is blanked, and leaves a hole in the sentence. Every `resend`\n * template carries one; the others are never rendered here.\n */\n footerReason?: string\n /**\n * Where the fallback copy lives, for staff wondering what recipients get\n * today. Informational — nothing reads it at runtime.\n */\n source: string\n}\n\n/**\n * Every system email Aglyn sends.\n *\n * Deliberately **code-defined and fixed**: staff edit the system emails that\n * exist, they do not create or delete them. Adding one is a code change,\n * which is what keeps this list from drifting into advertising an email the\n * product never actually sends — the failure mode a Firestore-backed list\n * would have.\n *\n * The staff page renders from this registry, not from Firestore, so an email\n * with no template designed yet still appears (as \"Using default\").\n */\n/**\n * Brand tokens EVERY system email resolves (AGL-2139).\n *\n * White-label used to invert precisely when staff published a template: the\n * designed template wins over each sender's branded fallback, and the copy\n * here hard-coded \"Aglyn\". Nor was there anything to design against — the\n * merge maps carried `org.name`, `invite.role`, `signInUrl` and friends and\n * nothing about the brand, while `blankUnresolvedTokens` DELETES any token\n * the caller did not supply, so a designer who typed `{{brand.productName}}`\n * would have shipped an email with a hole in the sentence.\n *\n * Declared once and appended to every template's own list rather than copied\n * into each: 13 hand-maintained copies is how the token list falls behind the\n * copy, and the editor renders this list as the palette a designer picks\n * from — a token missing here is a token nobody knows exists.\n *\n * The samples are the DEPLOYMENT's own values (`BRAND.ORG_NAME`, AGL-2319),\n * because that is what an unbranded org genuinely renders — on a self-host\n * install a hardcoded \"Aglyn\" would preview a brand that appears nowhere in\n * the mail the designer is editing.\n */\nexport const BRAND_MERGE_TOKENS: readonly SystemEmailMergeToken[] = [\n {\n name: 'brand.productName',\n description: \"The sender's product name — the org's own on white-label\",\n sample: BRAND.ORG_NAME,\n },\n {\n name: 'brand.fromName',\n description: 'Sender display name the mail is delivered under',\n sample: BRAND.ORG_NAME,\n },\n {\n name: 'brand.supportUrl',\n description: 'Where the recipient should go for help',\n sample: SAMPLE_SUPPORT_URL,\n },\n]\n\n// NOT declared: a `brand.logoUrl` token. The logo is STRUCTURAL — the\n// renderer emits it as a header row above the designed body, or emits\n// nothing — so a designer never places it, and a palette entry for it would\n// need a sample URL that a staff test-send would render as a broken image.\n// The three tokens above are the ones that belong in copy.\n\nconst BASE_SYSTEM_EMAIL_TEMPLATES: readonly SystemEmailTemplateDefinition[] =\n [\n {\n key: 'org-invite',\n name: 'Organization invite',\n description:\n 'Sent when an admin invites someone to an organization from the ' +\n 'Team page.',\n deliveredBy: 'resend',\n defaultSubject: \"You've been invited to {{org.name}} on {{brand.productName}}\",\n mergeTokens: [\n {\n name: 'org.name',\n description: 'Name of the inviting organization',\n sample: 'Test Org',\n },\n {\n name: 'invite.role',\n description: 'Role the person is invited as',\n sample: 'editor',\n },\n {\n name: 'signInUrl',\n description: 'Console URL to sign in and accept',\n sample: SAMPLE_CONSOLE_ORIGIN,\n },\n ],\n // Mirrors the fallbackText in invites/route.ts.\n defaultBody: [\n {\n block: 'text',\n text: \"You've been invited to join {{org.name}} as {{invite.role}}.\",\n variant: 'body',\n },\n {\n block: 'text',\n text:\n 'Sign in with this email address and accept the invite from ' +\n 'your dashboard.',\n variant: 'body',\n },\n { block: 'button', label: 'Sign in', href: '{{signInUrl}}' },\n ],\n footerReason:\n 'You’re receiving this because someone invited you to join ' +\n '{{org.name}} on {{brand.productName}}.',\n source: 'apps/console/app/api/orgs/invites/route.ts',\n },\n {\n key: 'usage-summary',\n name: 'Monthly usage summary',\n description:\n 'Monthly per-organization usage and metered-cost summary, sent by ' +\n 'the scheduled job.',\n deliveredBy: 'resend',\n defaultSubject: 'Your {{brand.productName}} usage summary for {{month}}',\n mergeTokens: [\n {\n name: 'month',\n description: 'Billing month as YYYY-MM',\n sample: '2026-06',\n },\n {\n name: 'org.name',\n description: 'Organization the summary is for',\n sample: 'Test Org',\n },\n {\n name: 'usage.summary',\n description: 'Pre-formatted usage lines',\n sample: 'Page views: 12,400',\n },\n ],\n // Mirrors the summary lines assembled in usage-email/route.ts.\n defaultBody: [\n { block: 'text', text: 'Usage summary for {{org.name}}', variant: 'heading' },\n {\n block: 'text',\n text: 'Here is your {{brand.productName}} usage summary for {{month}}.',\n variant: 'body',\n },\n { block: 'text', text: '{{usage.summary}}', variant: 'body' },\n ],\n footerReason:\n 'You’re receiving this monthly summary because you manage ' +\n '{{org.name}} on {{brand.productName}}.',\n source: 'apps/console/app/api/billing/usage-email/route.ts',\n },\n {\n key: 'erasure-hold-alert',\n name: 'Erasure hold staff alert',\n description:\n 'Internal alert to STAFF_ALERT_EMAIL when GDPR erasure requests ' +\n 'pass their 7-day hold.',\n deliveredBy: 'resend',\n defaultSubject: '{{count}} erasure request(s) past the 7-day hold',\n mergeTokens: [\n {\n name: 'count',\n description: 'How many organizations are past the hold',\n sample: '3',\n },\n {\n name: 'orgs.list',\n description: 'The organizations, one per line',\n sample: '- Test Org (org_123), requested 2026-07-01',\n },\n ],\n // Mirrors the staff-alert text in audit-archive/route.ts.\n defaultBody: [\n {\n block: 'text',\n text:\n 'These organizations are past their GDPR erasure hold. Run ' +\n 'tools/scripts/erase-tenant.mjs to hard-delete. No copy is kept:',\n variant: 'body',\n },\n { block: 'text', text: '{{orgs.list}}', variant: 'body' },\n ],\n footerReason:\n 'You’re receiving this because this address gets ' +\n '{{brand.productName}}’s staff alerts.',\n source: 'apps/console/app/api/admin/audit-archive/route.ts',\n },\n {\n key: 'welcome',\n name: 'Welcome',\n description:\n 'Sent to a new owner the first time they create an organization ' +\n '(not on every organization they later create).',\n deliveredBy: 'resend',\n defaultSubject: 'Welcome to {{brand.productName}}',\n mergeTokens: [\n {\n name: 'name',\n description: \"The new owner's display name\",\n sample: 'Alex',\n },\n {\n name: 'org.name',\n description: 'The organization they just created',\n sample: 'Test Org',\n },\n {\n name: 'consoleUrl',\n description: 'Console dashboard URL',\n sample: SAMPLE_CONSOLE_ORIGIN,\n },\n ],\n // Mirrors the fallbackText in orgs/create/route.ts.\n defaultBody: [\n { block: 'text', text: 'Welcome to {{brand.productName}}', variant: 'heading' },\n {\n block: 'text',\n text:\n 'Hi {{name}}, thanks for creating {{org.name}}. Your workspace ' +\n 'is ready.',\n variant: 'body',\n },\n {\n block: 'text',\n text:\n 'From your dashboard you can add a site, invite your team, and ' +\n 'start building.',\n variant: 'body',\n },\n { block: 'button', label: 'Open your dashboard', href: '{{consoleUrl}}' },\n ],\n footerReason:\n 'You’re receiving this because you created an organization on ' +\n '{{brand.productName}}.',\n source: 'apps/console/app/api/orgs/create/route.ts',\n },\n {\n key: 'member-added',\n name: 'Added to an organization',\n description:\n `Sent when an existing ${BRAND.ORG_NAME} account is added directly ` +\n 'to an organization. People who do not have an account yet get the ' +\n 'organization invite email instead.',\n deliveredBy: 'resend',\n defaultSubject: \"You've been added to {{org.name}}\",\n mergeTokens: [\n {\n name: 'org.name',\n description: 'The organization they were added to',\n sample: 'Test Org',\n },\n {\n name: 'member.role',\n description: 'Role they were given',\n sample: 'editor',\n },\n {\n name: 'signInUrl',\n description: 'Console URL to open the organization',\n sample: SAMPLE_CONSOLE_ORIGIN,\n },\n ],\n // Mirrors the fallbackText in orgs/members/route.ts.\n defaultBody: [\n {\n block: 'text',\n text: 'You were added to {{org.name}} as {{member.role}}.',\n variant: 'body',\n },\n {\n block: 'text',\n text: 'Sign in to switch to it from your dashboard.',\n variant: 'body',\n },\n { block: 'button', label: 'Open {{brand.productName}}', href: '{{signInUrl}}' },\n ],\n footerReason:\n 'You’re receiving this because you were added to {{org.name}} ' +\n 'on {{brand.productName}}.',\n source: 'apps/console/app/api/orgs/members/route.ts',\n },\n {\n key: 'erasure-confirmation',\n name: 'Erasure completed',\n description:\n 'Sent to an organization owner once its data has been permanently ' +\n 'erased under a GDPR request.',\n deliveredBy: 'resend',\n defaultSubject: 'Your {{brand.productName}} data has been erased',\n mergeTokens: [\n {\n name: 'org.name',\n description: 'The organization that was erased',\n sample: 'Test Org',\n },\n ],\n // Mirrors the fallbackText in admin/run-erasures/route.ts.\n defaultBody: [\n { block: 'text', text: 'Your data has been erased', variant: 'heading' },\n {\n block: 'text',\n text:\n '{{org.name}} and all of its data have been permanently erased ' +\n 'from {{brand.productName}}, as requested.',\n variant: 'body',\n },\n {\n block: 'text',\n text: 'This is complete and cannot be undone.',\n variant: 'body',\n },\n ],\n footerReason:\n 'You’re receiving this because you asked {{brand.productName}} ' +\n 'to erase {{org.name}}’s data.',\n source: 'apps/console/app/api/admin/run-erasures/route.ts',\n },\n {\n key: 'erasure-requested',\n name: 'Erasure requested',\n description:\n 'Confirms to an organization owner that a GDPR erasure request was ' +\n 'recorded, and that deletion follows after a 7-day hold.',\n deliveredBy: 'resend',\n defaultSubject: 'We received your erasure request',\n mergeTokens: [\n {\n name: 'org.name',\n description: 'The organization to be erased',\n sample: 'Test Org',\n },\n ],\n // Mirrors the fallbackText in admin/erasure-request/route.ts.\n defaultBody: [\n { block: 'text', text: 'Erasure request received', variant: 'heading' },\n {\n block: 'text',\n text:\n 'We have recorded a request to erase {{org.name}} and all of ' +\n 'its data from {{brand.productName}}.',\n variant: 'body',\n },\n {\n block: 'text',\n text:\n 'Deletion is permanent and happens after a 7-day hold. If this ' +\n 'was not intended, contact support before then to cancel.',\n variant: 'body',\n },\n ],\n footerReason:\n 'You’re receiving this because you own {{org.name}} on ' +\n '{{brand.productName}}.',\n source: 'apps/console/app/api/admin/erasure-request/route.ts',\n },\n // Admin-initiated password mail (AGL-910). Unlike the self-serve\n // `password-reset` below, Aglyn composes and sends these itself — the\n // reset LINK still comes from Firebase (generatePasswordResetLink), but\n // the message around it is ours, so both are designable.\n {\n key: 'admin-password-reset',\n name: 'Password reset (sent by an admin)',\n description:\n 'Password reset link sent on someone else\\'s behalf from the ' +\n 'console — by staff from a user\\'s detail page, or by an ' +\n 'organization admin from a team member\\'s page.',\n deliveredBy: 'resend',\n defaultSubject: 'Reset your {{brand.productName}} password',\n mergeTokens: [\n {\n name: 'resetUrl',\n description: 'One-time link that opens the reset form',\n sample: `${SAMPLE_CONSOLE_ORIGIN}/reset-password?oobCode=…`,\n },\n {\n name: 'actor.name',\n description: 'Who asked for the reset',\n sample: '{{brand.productName}} support',\n },\n ],\n // Mirrors the fallbackText in _lib/password-admin.ts.\n defaultBody: [\n { block: 'text', text: 'Reset your password', variant: 'heading' },\n {\n block: 'text',\n text:\n '{{actor.name}} started a password reset for your {{brand.productName}} ' +\n 'account. Choose a new password here:',\n variant: 'body',\n },\n { block: 'button', label: 'Set a new password', href: '{{resetUrl}}' },\n {\n block: 'text',\n text:\n 'The link expires shortly. If you were not expecting this, you ' +\n 'can ignore this email — your password stays as it is.',\n variant: 'caption',\n },\n ],\n footerReason:\n 'You’re receiving this because an administrator started a ' +\n 'password reset for your {{brand.productName}} account.',\n source: 'apps/console/app/api/_lib/password-admin.ts',\n },\n {\n key: 'password-changed-by-admin',\n name: 'Password changed by an admin',\n description:\n 'Notice sent to the account holder after an admin sets their ' +\n 'password directly. Always sent — it is how someone finds out an ' +\n 'administrator changed their credentials.',\n deliveredBy: 'resend',\n defaultSubject: 'Your {{brand.productName}} password was changed',\n mergeTokens: [\n {\n name: 'actor.name',\n description: 'Who changed the password',\n sample: '{{brand.productName}} support',\n },\n {\n name: 'signInUrl',\n description: 'Where to sign in with the new password',\n sample: SAMPLE_CONSOLE_ORIGIN,\n },\n ],\n // Mirrors the fallbackText in _lib/password-admin.ts.\n defaultBody: [\n { block: 'text', text: 'Your password was changed', variant: 'heading' },\n {\n block: 'text',\n text:\n '{{actor.name}} set a new password on your account. You have ' +\n 'been signed out everywhere and will need the new password to ' +\n 'sign back in.',\n variant: 'body',\n },\n { block: 'button', label: 'Sign in', href: '{{signInUrl}}' },\n {\n block: 'text',\n text:\n 'If you did not expect this, contact whoever administers your ' +\n 'account straight away.',\n variant: 'caption',\n },\n ],\n footerReason:\n 'You’re receiving this because an administrator changed the ' +\n 'password on your {{brand.productName}} account.',\n source: 'apps/console/app/api/_lib/password-admin.ts',\n },\n // Security alerts (AGL-665). Factual and actionable: what happened, from\n // where, and one button to the account-security surface. These must be\n // believed, so the send path only fires them on genuinely new devices —\n // never on every sign-in.\n {\n key: 'security-new-device',\n name: 'New device sign-in',\n description:\n 'Security alert sent when an account signs in from a device it has ' +\n 'not used before. Not sent on routine sign-ins from known devices.',\n deliveredBy: 'resend',\n defaultSubject: 'New sign-in to your {{brand.productName}} account',\n mergeTokens: [\n {\n name: 'device.name',\n description: 'Browser and operating system that signed in',\n sample: 'Chrome on macOS',\n },\n {\n name: 'device.location',\n description: 'Approximate location, from the request IP',\n sample: 'Denver, CO, US',\n },\n {\n name: 'device.ip',\n description: 'IP address the sign-in came from',\n sample: '203.0.113.7',\n },\n {\n name: 'device.time',\n description: 'When the sign-in happened (UTC)',\n sample: '2026-08-08 14:03 UTC',\n },\n {\n name: 'accountSecurityUrl',\n description:\n 'Manage-account Security section — Recent sign-ins, where the ' +\n 'device can be signed out, plus password and passkeys',\n sample: `${SAMPLE_CONSOLE_ORIGIN}/manage/user/security`,\n },\n ],\n // Mirrors the fallbackText in _lib/security-alerts.ts.\n defaultBody: [\n { block: 'text', text: 'New device sign-in', variant: 'heading' },\n {\n block: 'text',\n text:\n 'Your {{brand.productName}} account was just signed in to from a device it has ' +\n 'not used before.',\n variant: 'body',\n },\n { block: 'text', text: 'Device: {{device.name}}', variant: 'body' },\n { block: 'text', text: 'Location: {{device.location}}', variant: 'body' },\n { block: 'text', text: 'IP address: {{device.ip}}', variant: 'body' },\n { block: 'text', text: 'When: {{device.time}}', variant: 'body' },\n {\n // AGL-1959. The button used to say \"Review account security\" and\n // land on the account page's DEFAULT tab, which offered a password\n // change and nothing about the device the mail is about. AGL-665\n // asked for this: \"'Manage your devices' has to land somewhere that\n // can list and revoke sessions, or the alert tells you something is\n // wrong and offers no way to act on it — which is worse than sending\n // nothing.\"\n block: 'button',\n label: 'Manage your devices',\n href: '{{accountSecurityUrl}}',\n },\n {\n block: 'text',\n text:\n 'If this was you, no action is needed. If it was not, sign this ' +\n 'device out from Recent sign-ins and change your password right ' +\n 'away.',\n variant: 'caption',\n },\n ],\n footerReason:\n 'You’re receiving this because your {{brand.productName}} ' +\n 'account signed in from a new device.',\n source: 'apps/console/app/api/_lib/security-alerts.ts',\n },\n {\n key: 'security-passkey-added',\n name: 'New passkey added',\n description:\n 'Security alert sent when a passkey is registered on an account. ' +\n 'The trigger ships with passkey support (AGL-662); until then no ' +\n 'call site sends this.',\n deliveredBy: 'resend',\n defaultSubject: 'A passkey was added to your {{brand.productName}} account',\n mergeTokens: [\n {\n name: 'passkey.label',\n description: 'Name the passkey was saved under',\n sample: 'MacBook Touch ID',\n },\n {\n name: 'device.time',\n description: 'When the passkey was added (UTC)',\n sample: '2026-08-08 14:03 UTC',\n },\n {\n name: 'accountSecurityUrl',\n description: 'Manage-account page where credentials are reviewed',\n sample: `${SAMPLE_CONSOLE_ORIGIN}/manage/user`,\n },\n ],\n // Mirrors the fallbackText in _lib/security-alerts.ts.\n defaultBody: [\n { block: 'text', text: 'New passkey added', variant: 'heading' },\n {\n block: 'text',\n text:\n 'A passkey (\"{{passkey.label}}\") was just added to your {{brand.productName}} ' +\n 'account. It can be used to sign in without your password.',\n variant: 'body',\n },\n { block: 'text', text: 'When: {{device.time}}', variant: 'body' },\n {\n block: 'button',\n label: 'Review account security',\n href: '{{accountSecurityUrl}}',\n },\n {\n block: 'text',\n text:\n 'If you did not add this passkey, change your password from ' +\n 'Manage account right away.',\n variant: 'caption',\n },\n ],\n footerReason:\n 'You’re receiving this because a passkey was added to your ' +\n '{{brand.productName}} account.',\n source: 'apps/console/app/api/_lib/security-alerts.ts',\n },\n // Aglyn composes and sends both of these now (AGL-1112). They were\n // Firebase's, from a template nobody could edit — the subject still\n // carried `[aglyn.io]`, a domain the company no longer uses, and the link\n // landed on `aglyn-main.firebaseapp.com`. Neither could be fixed: every\n // write under `notification.sendEmail` is refused with\n // `EMAIL_TEMPLATE_UPDATE_NOT_ALLOWED`.\n //\n // Firebase still MINTS the one-time code, which is the part that has to\n // be its. Only the message around it, and the host the link points at,\n // became ours. So editing these here now does what it says.\n {\n key: 'password-reset',\n name: 'Forgot password',\n description:\n 'Password reset link, sent when someone asks for one from Account ' +\n 'recovery.',\n deliveredBy: 'resend',\n defaultSubject: 'Reset your {{brand.productName}} password',\n mergeTokens: [\n {\n name: 'resetUrl',\n description: 'One-time link that opens the choose-a-new-password page',\n sample: `${SAMPLE_CONSOLE_ORIGIN}/reset-password?oobCode=\\u2026`,\n },\n ],\n // Mirrors the fallbackText in the route, so the designed and undesigned\n // versions say the same thing.\n defaultBody: [\n { block: 'text', text: 'Reset your password', variant: 'heading' },\n {\n block: 'text',\n text:\n 'Someone asked to reset the password for your {{brand.productName}} account. ' +\n 'Choose a new one here:',\n variant: 'body',\n },\n { block: 'button', label: 'Choose a new password', href: '{{resetUrl}}' },\n {\n block: 'text',\n text:\n 'The link expires shortly. If this was not you, you can ignore ' +\n 'this email — your password stays as it is.',\n variant: 'caption',\n },\n ],\n footerReason:\n 'You’re receiving this because someone asked to reset the ' +\n 'password on your {{brand.productName}} account.',\n source: 'apps/console/app/api/auth/send-password-reset/route.ts',\n },\n {\n key: 'email-verification',\n name: 'Confirm email',\n description:\n 'Address verification link, sent after sign-up and whenever someone ' +\n 'asks for another from the verify screen.',\n deliveredBy: 'resend',\n defaultSubject: 'Confirm your email address',\n mergeTokens: [\n {\n name: 'verifyUrl',\n description: 'One-time link that confirms the address',\n sample: `${SAMPLE_CONSOLE_ORIGIN}/verify-email?oobCode=\\u2026`,\n },\n ],\n defaultBody: [\n { block: 'text', text: 'Confirm your email', variant: 'heading' },\n {\n block: 'text',\n text:\n 'Confirm this address to finish setting up your {{brand.productName}} account:',\n variant: 'body',\n },\n { block: 'button', label: 'Confirm my email', href: '{{verifyUrl}}' },\n {\n block: 'text',\n text:\n 'If you did not create an {{brand.productName}} account, you can ignore this ' +\n 'email.',\n variant: 'caption',\n },\n ],\n footerReason:\n 'You’re receiving this because this address was used to sign ' +\n 'up for {{brand.productName}}.',\n source: 'apps/console/app/api/auth/send-verification/route.ts',\n },\n // Its own entry rather than a second use of the one above, because every\n // line of that one is about signing up: \"finish setting up your account\",\n // \"if you did not create an account\", a footer saying the address was\n // used to sign up. This mail goes to an address someone is ADDING to an\n // account that already exists, and the one thing its reader needs to hear\n // is that ignoring it changes nothing.\n {\n key: 'email-address-confirmation',\n name: 'Confirm added email',\n description:\n 'Confirmation link, sent when someone adds another email address to ' +\n 'their account under Manage Account → Email addresses.',\n deliveredBy: 'resend',\n defaultSubject: 'Confirm your email address',\n mergeTokens: [\n {\n name: 'confirmUrl',\n description: 'One-time link that adds the address to the account',\n sample: `${SAMPLE_CONSOLE_ORIGIN}/manage/user?confirmEmail=…`,\n },\n ],\n // Mirrors the fallbackText in the route.\n defaultBody: [\n { block: 'text', text: 'Confirm this email address', variant: 'heading' },\n {\n block: 'text',\n text:\n 'Confirm this address so you can use it with your ' +\n '{{brand.productName}} account:',\n variant: 'body',\n },\n { block: 'button', label: 'Confirm email address', href: '{{confirmUrl}}' },\n {\n block: 'text',\n text:\n 'If you did not ask to add this address, you can ignore this ' +\n 'email — nothing has changed and the address has not been added ' +\n 'to any account.',\n variant: 'caption',\n },\n ],\n footerReason:\n 'You’re receiving this because someone asked to add this address ' +\n 'to a {{brand.productName}} account.',\n source: 'apps/console/app/api/account/emails/route.ts',\n },\n // AGL-3367. The platform's other senders, each of which composed its\n // own plain text and so went out with no header or footer. A sender whose\n // message is assembled per send (a digest's list, an alert's figures)\n // hands its composed text in as one body token: the built-in copy is that\n // text in the brand's chrome, and a design can wrap it in anything else.\n {\n key: 'notification',\n name: 'Notification',\n description:\n 'The email copy of a console notification, sent to people who ' +\n 'turned email on for that kind of notification: staff alerts, ' +\n 'workspace-admin and site-manager notices.',\n deliveredBy: 'resend',\n defaultSubject: '{{notification.title}}',\n mergeTokens: [\n {\n name: 'notification.title',\n description: 'The notification, in one line',\n sample: 'Subscription canceled',\n },\n {\n name: 'notification.body',\n description: 'The detail under it, when it has any',\n sample: 'Test Org canceled, leaving the Advanced plan.',\n },\n {\n name: 'notification.url',\n description: 'Where the notification opens in the console',\n sample: `${SAMPLE_CONSOLE_ORIGIN}/manage/notifications`,\n },\n {\n name: 'settingsUrl',\n description: 'Notification settings, where email can be turned off',\n sample: `${SAMPLE_CONSOLE_ORIGIN}/manage/notifications/settings`,\n },\n ],\n defaultBody: [\n { block: 'text', text: '{{notification.title}}', variant: 'heading' },\n { block: 'text', text: '{{notification.body}}', variant: 'body' },\n { block: 'button', label: 'Open in {{brand.productName}}', href: '{{notification.url}}' },\n {\n block: 'text',\n text: 'Change what you are emailed about: {{settingsUrl}}',\n variant: 'caption',\n },\n ],\n footerReason:\n 'You’re receiving this because you turned on email for these ' +\n 'notifications in {{brand.productName}}.',\n source: 'libs/tenant/data/admin/src/lib/server/notifications.ts',\n },\n {\n key: 'staff-alert',\n name: 'Staff alert',\n description:\n 'Operational alerts to STAFF_ALERT_EMAIL: free-tier AI spend, usage ' +\n 'margins, SSO re-verification and the like.',\n deliveredBy: 'resend',\n defaultSubject: '{{alert.subject}}',\n mergeTokens: [\n {\n name: 'alert.subject',\n description: 'The alert, in one line',\n sample: 'Free AI spend at 80% of today’s ceiling',\n },\n {\n name: 'alert.body',\n description: 'The alert as its sender wrote it',\n sample: 'Free-tier AI spend for 2026-09-28 (UTC) is at $40.00 of the $50.00 ceiling.',\n },\n ],\n defaultBody: [{ block: 'text', text: '{{alert.body}}', variant: 'body' }],\n footerReason:\n 'You’re receiving this because this address gets ' +\n '{{brand.productName}}’s staff alerts.',\n source: 'libs/tenant/data/admin/src/lib/server/staff-alert-email.ts',\n },\n {\n key: 'support-ticket-alert',\n name: 'Support ticket to success manager',\n description:\n 'Copies an organization’s named success manager on a support ticket ' +\n 'it opened or replied to.',\n deliveredBy: 'resend',\n defaultSubject: '{{ticket.heading}} — {{org.name}}: {{ticket.subject}}',\n mergeTokens: [\n {\n name: 'ticket.heading',\n description: '“New ticket” or “Ticket reply”',\n sample: 'New ticket',\n },\n {\n name: 'ticket.subject',\n description: 'The ticket’s subject',\n sample: 'Domain will not verify',\n },\n {\n name: 'ticket.body',\n description: 'Who wrote, and what they wrote',\n sample: 'Test Org opened a support ticket.\\nFrom: owner@example.com',\n },\n {\n name: 'ticket.url',\n description: 'The ticket in the staff console',\n sample: `${SAMPLE_CONSOLE_ORIGIN}/admin/support`,\n },\n {\n name: 'manager.name',\n description: 'The success manager’s name',\n sample: 'Sam',\n },\n {\n name: 'org.name',\n description: 'The organization the ticket is from',\n sample: 'Test Org',\n },\n ],\n defaultBody: [\n { block: 'text', text: '{{manager.name}},', variant: 'body' },\n { block: 'text', text: '{{ticket.body}}', variant: 'body' },\n { block: 'button', label: 'Open the ticket', href: '{{ticket.url}}' },\n ],\n footerReason:\n 'You’re receiving this because you are the success manager for ' +\n '{{org.name}} on {{brand.productName}}.',\n source: 'apps/console/app/api/_lib/success-manager.ts',\n },\n {\n key: 'workspace-notice',\n name: 'Workspace notice',\n description:\n 'Operational notices to a workspace’s people: usage alerts and SSO ' +\n 'domain lapses to its owners and admins, and a stopped outreach ' +\n 'mailbox to the member who connected it.',\n deliveredBy: 'resend',\n defaultSubject: '{{notice.subject}}',\n mergeTokens: [\n {\n name: 'notice.subject',\n description: 'The notice, in one line',\n sample: 'Test Org has used 80% of this month’s emails',\n },\n {\n name: 'notice.body',\n description: 'The notice as its sender wrote it',\n sample: 'Test Org has sent 8,000 of its 10,000 emails this month.',\n },\n {\n name: 'org.name',\n description: 'The workspace the notice is about',\n sample: 'Test Org',\n },\n ],\n defaultBody: [{ block: 'text', text: '{{notice.body}}', variant: 'body' }],\n footerReason:\n 'You’re receiving this because you’re a member of {{org.name}} on ' +\n '{{brand.productName}}.',\n source: 'libs/tenant/data/admin/src/lib/server/org-member-notice.ts',\n },\n {\n key: 'member-email-alias-confirmation',\n name: 'Member email address confirmation',\n description:\n 'Sent to an address a workspace member asked to add as one of their ' +\n 'own, so mail from it is filed under the people it was written to.',\n deliveredBy: 'resend',\n defaultSubject: 'Confirm your address for {{org.name}}',\n mergeTokens: [\n {\n name: 'member.name',\n description: 'The member who asked',\n sample: 'Alex',\n },\n {\n name: 'address',\n description: 'The address being confirmed',\n sample: 'alex@example.com',\n },\n {\n name: 'org.name',\n description: 'The workspace',\n sample: 'Test Org',\n },\n {\n name: 'confirmUrl',\n description: 'The one-time confirmation link',\n sample: `${SAMPLE_CONSOLE_ORIGIN}/manage/account`,\n },\n ],\n defaultBody: [\n { block: 'text', text: 'Confirm this address', variant: 'heading' },\n {\n block: 'text',\n text:\n '{{member.name}} asked to add {{address}} as one of their own ' +\n 'email addresses in the {{org.name}} workspace on ' +\n '{{brand.productName}}. To confirm it, open the link while ' +\n 'signed in as that member.',\n variant: 'body',\n },\n { block: 'button', label: 'Confirm the address', href: '{{confirmUrl}}' },\n {\n block: 'text',\n text:\n 'Once it is confirmed, email sent from {{address}} with the ' +\n 'workspace’s capture address in BCC is filed under the person it ' +\n 'was written to. The link works for 24 hours.',\n variant: 'body',\n },\n {\n block: 'text',\n text:\n 'If you did not expect this email, ignore it. Nothing changes ' +\n 'unless the member who asked opens the link.',\n variant: 'caption',\n },\n ],\n footerReason:\n 'You’re receiving this because a member of {{org.name}} on ' +\n '{{brand.productName}} asked to add this address to their account.',\n source: 'libs/tenant/data/admin/src/lib/server/member-email-aliases.ts',\n },\n {\n key: 'crm-daily-digest',\n name: 'Daily CRM digest',\n description:\n 'Each morning, a member’s overdue and due-today tasks and unworked ' +\n 'leads, for members who turned the digest on.',\n deliveredBy: 'resend',\n defaultSubject: '{{digest.subject}}',\n mergeTokens: [\n {\n name: 'digest.subject',\n description: 'The summary line',\n sample: '2 tasks overdue, 3 new leads',\n },\n {\n name: 'digest.body',\n description: 'The tasks and leads, one per line, with their links',\n sample: 'Overdue (2)\\n- Call back Jordan · Sep 27, 9:00 AM',\n },\n {\n name: 'settingsUrl',\n description: 'Notification settings, where the digest is turned off',\n sample: `${SAMPLE_CONSOLE_ORIGIN}/manage/notifications/settings`,\n },\n ],\n defaultBody: [\n { block: 'text', text: '{{digest.body}}', variant: 'body' },\n {\n block: 'text',\n text: 'Change what you are emailed about: {{settingsUrl}}',\n variant: 'caption',\n },\n ],\n footerReason:\n 'You’re receiving this each morning because the Daily CRM digest is ' +\n 'on in your {{brand.productName}} notification settings.',\n source: 'apps/console/app/api/crm/daily-digest/route.ts',\n },\n {\n key: 'crm-task-reminder',\n name: 'CRM task reminder',\n description: 'A reminder for CRM tasks that have come due.',\n deliveredBy: 'resend',\n defaultSubject: '{{reminder.subject}}',\n mergeTokens: [\n {\n name: 'reminder.subject',\n description: 'The task, or how many are due',\n sample: 'Reminder: Call back Jordan',\n },\n {\n name: 'reminder.body',\n description: 'The tasks, each with its link',\n sample: '- Call back Jordan · due today at 9:00 AM',\n },\n {\n name: 'settingsUrl',\n description: 'Notification settings, where reminders are turned off',\n sample: `${SAMPLE_CONSOLE_ORIGIN}/manage/notifications/settings`,\n },\n ],\n defaultBody: [\n { block: 'text', text: '{{reminder.body}}', variant: 'body' },\n {\n block: 'text',\n text: 'Change what you are emailed about: {{settingsUrl}}',\n variant: 'caption',\n },\n ],\n footerReason:\n 'You’re receiving this because task reminders are on in your ' +\n '{{brand.productName}} notification settings.',\n source: 'apps/console/app/api/crm/task-reminders/route.ts',\n },\n {\n key: 'ai-insights-digest',\n name: 'Weekly insights digest',\n description:\n 'The week’s AI insights for a site, for members who turned the ' +\n 'digest on for that workspace.',\n deliveredBy: 'resend',\n defaultSubject: 'Your weekly insights for {{site.name}}',\n mergeTokens: [\n {\n name: 'site.name',\n description: 'The site the insights are about',\n sample: 'Test Site',\n },\n {\n name: 'digest.body',\n description: 'The insights, numbered, with their links',\n sample: '1. Visits from search rose 18% on last week.',\n },\n ],\n defaultBody: [\n { block: 'text', text: 'Weekly insights for {{site.name}}', variant: 'heading' },\n { block: 'text', text: '{{digest.body}}', variant: 'body' },\n ],\n footerReason:\n 'You’re receiving this because you turned on weekly insights in ' +\n '{{brand.productName}}.',\n source: 'libs/plugins/ai/src/lib/insights/ai-insight-digest.ts',\n },\n {\n key: 'legal-intake-receipt',\n name: 'Report and notice receipt',\n description:\n 'The receipt for an abuse report, a copyright notice or a ' +\n 'counter-notice, carrying its reference.',\n deliveredBy: 'resend',\n defaultSubject: '{{receipt.subject}}',\n mergeTokens: [\n {\n name: 'receipt.subject',\n description: 'What was received, and its reference',\n sample: 'Report received — AR-3F9K2',\n },\n {\n name: 'receipt.body',\n description: 'The receipt as the intake wrote it',\n sample: 'We have received your report about https://example.com.\\n\\nYour reference is AR-3F9K2.',\n },\n ],\n defaultBody: [{ block: 'text', text: '{{receipt.body}}', variant: 'body' }],\n footerReason:\n 'You’re receiving this because this address was given on a report ' +\n 'or notice sent to {{brand.productName}}.',\n source: 'apps/tenant/app/api/_legal-intake/acknowledge.ts',\n },\n {\n key: 'plugin-review-update',\n name: 'Plugin review update',\n description:\n 'Tells a marketplace publisher’s owners and admins what review ' +\n 'decided about their submission.',\n deliveredBy: 'resend',\n defaultSubject: '{{review.subject}}',\n mergeTokens: [\n {\n name: 'review.subject',\n description: 'The decision, in one line',\n sample: 'Your plugin was approved',\n },\n {\n name: 'review.body',\n description: 'The decision as review wrote it',\n sample: 'Test Plugin 1.2.0 passed review and is live in the marketplace.',\n },\n ],\n defaultBody: [{ block: 'text', text: '{{review.body}}', variant: 'body' }],\n footerReason:\n 'You’re receiving this because you manage a plugin published on ' +\n '{{brand.productName}}.',\n source: 'libs/plugins/marketplace/src/lib/server/publisher-review-email.ts',\n },\n // Stripe-delivered billing email (AGL-767). Aglyn never composes these —\n // Stripe sends them from the Dashboard's Customer-emails and Subscription\n // settings, so they are listed for visibility only and are not designable.\n // Whether each actually sends is a Dashboard toggle the code cannot read;\n // the descriptions say \"if enabled\" rather than asserting it is on.\n {\n key: 'stripe-receipt',\n name: 'Payment receipt',\n description:\n 'Receipt for a successful subscription or invoice payment. Sent by ' +\n 'Stripe if \"Successful payments\" is enabled under Customer emails.',\n deliveredBy: 'stripe',\n defaultSubject: `Your receipt from ${BRAND.ORG_NAME}`,\n mergeTokens: [],\n source: 'Stripe Dashboard → Settings → Customer emails',\n },\n {\n key: 'stripe-payment-failed',\n name: 'Payment failed',\n description:\n 'Dunning notice when a subscription charge fails and Stripe retries ' +\n 'it. Sent by Stripe if failed-payment emails are enabled under ' +\n 'Subscriptions and emails.',\n deliveredBy: 'stripe',\n defaultSubject: `Your ${BRAND.ORG_NAME} payment could not be processed`,\n mergeTokens: [],\n source: 'Stripe Dashboard → Settings → Subscriptions and emails',\n },\n {\n key: 'stripe-refund',\n name: 'Refund confirmation',\n description:\n 'Confirms a refund back to the customer. Sent by Stripe if ' +\n '\"Refunds\" is enabled under Customer emails.',\n deliveredBy: 'stripe',\n defaultSubject: `Your ${BRAND.ORG_NAME} refund`,\n mergeTokens: [],\n source: 'Stripe Dashboard → Settings → Customer emails',\n },\n {\n key: 'stripe-card-expiring',\n name: 'Card expiring soon',\n description:\n 'Reminder that the card on file is about to expire. Sent by Stripe ' +\n 'if expiring-card reminders are enabled under Subscriptions and ' +\n 'emails.',\n deliveredBy: 'stripe',\n defaultSubject: 'Your card on file is expiring soon',\n mergeTokens: [],\n source: 'Stripe Dashboard → Settings → Subscriptions and emails',\n },\n {\n key: 'stripe-invoice',\n name: 'Invoice',\n description:\n 'A finalized invoice emailed to the organization. Sent by Stripe ' +\n 'when invoice emails are enabled for the billing configuration.',\n deliveredBy: 'stripe',\n defaultSubject: `Your ${BRAND.ORG_NAME} invoice`,\n mergeTokens: [],\n source: 'Stripe Dashboard → Settings → Customer emails',\n },\n ]\n\n/**\n * Every system email Aglyn sends, each with the universal brand tokens\n * appended to its own (AGL-2139).\n *\n * The template's own tokens come FIRST so the editor's palette still leads\n * with what is specific to that email; the brand block reads as the shared\n * footer it is.\n */\nexport const SYSTEM_EMAIL_TEMPLATES: readonly SystemEmailTemplateDefinition[] =\n BASE_SYSTEM_EMAIL_TEMPLATES.map((entry) => ({\n ...entry,\n mergeTokens: [...entry.mergeTokens, ...BRAND_MERGE_TOKENS],\n }))\n\n/** Firestore collection holding the designed templates. */\nexport const SYSTEM_EMAIL_COLLECTION = 'systemEmailTemplates'\n\nexport function getSystemEmailTemplate(\n key: string,\n): SystemEmailTemplateDefinition | undefined {\n return SYSTEM_EMAIL_TEMPLATES.find((entry) => entry.key === key)\n}\n\n/** True when a besigner template can actually change what is delivered. */\nexport function isSystemEmailEditable(\n definition: SystemEmailTemplateDefinition,\n): boolean {\n return definition.deliveredBy === 'resend'\n}\n\n/** The stored template document, when staff have designed one. */\nexport interface SystemEmailTemplateDoc {\n subject?: string\n preheader?: string\n versionId?: string\n updatedAt?: unknown\n updatedByEmail?: string\n}\n\n/** A besigner node in the flat, denormalized map a version stores. */\nexport interface SystemEmailNode {\n $id: string\n componentId: string\n pluginId?: string\n parentId?: string\n props?: Record<string, unknown>\n nodes?: string[]\n}\n\nconst PLACEHOLDER_DEFAULT_BODY: readonly SystemEmailDefaultBlock[] = [\n { block: 'text', text: 'Hello,', variant: 'body' },\n]\n\n/**\n * Turns a template's `defaultBody` into the besigner node map the editor\n * seeds and the send-time default renders (AGL-764/766).\n *\n * One place builds this so the two never diverge — the version a staffer\n * opens is byte-for-byte what a test/send renders when nothing is published.\n * Rooted at {@link EMAIL_NODE_ROOT_ID} so `renderEmailHtml` finds it (AGL-765),\n * with deterministic ids: a version doc only needs ids unique within itself,\n * and determinism keeps the map stable and diffable.\n */\nexport function buildDefaultEmailNodeMap(\n // Structural so the tenant catalog (AGL-770) can seed the same way — every\n // definition that carries a `defaultBody` builds an identical node map.\n definition: { defaultBody?: readonly SystemEmailDefaultBlock[] },\n): Record<string, SystemEmailNode> {\n const blocks = definition.defaultBody?.length\n ? definition.defaultBody\n : PLACEHOLDER_DEFAULT_BODY\n const sectionId = 'default-section'\n const childIds: string[] = []\n const map: Record<string, SystemEmailNode> = {}\n\n blocks.forEach((block, index) => {\n const id = `default-${index}`\n childIds.push(id)\n map[id] =\n block.block === 'button'\n ? {\n $id: id,\n componentId: 'emailButton',\n pluginId: 'email',\n parentId: sectionId,\n props: { children: block.label, href: block.href },\n }\n : {\n $id: id,\n componentId: 'emailText',\n pluginId: 'email',\n parentId: sectionId,\n props: { children: block.text, variant: block.variant ?? 'body' },\n }\n })\n\n map[EMAIL_NODE_ROOT_ID] = {\n $id: EMAIL_NODE_ROOT_ID,\n componentId: 'div',\n nodes: [sectionId],\n }\n map[sectionId] = {\n $id: sectionId,\n componentId: 'emailSection',\n pluginId: 'email',\n parentId: EMAIL_NODE_ROOT_ID,\n nodes: childIds,\n }\n return map\n}\n"],"names":["SAMPLE_CONSOLE_ORIGIN","process","env","NEXT_PUBLIC_CONSOLE_URL","trim","replace","SAMPLE_SUPPORT_URL","NEXT_PUBLIC_PLATFORM_SUPPORT_URL","NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL","BRAND","EMAIL_NODE_ROOT_ID","BRAND_MERGE_TOKENS","name","description","sample","ORG_NAME","BASE_SYSTEM_EMAIL_TEMPLATES","key","deliveredBy","defaultSubject","mergeTokens","defaultBody","block","text","variant","label","href","footerReason","source","SYSTEM_EMAIL_TEMPLATES","map","entry","SYSTEM_EMAIL_COLLECTION","getSystemEmailTemplate","find","isSystemEmailEditable","definition","PLACEHOLDER_DEFAULT_BODY","buildDefaultEmailNodeMap","blocks","length","sectionId","childIds","forEach","index","id","push","$id","componentId","pluginId","parentId","props","children","nodes"],"mappings":"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OAAO;gBACPC,MACE,iEACA;gBACFC,SAAS;YACX;YACA;gBACEF,OAAO;gBACPC,MACE,mEACA;gBACFC,SAAS;YACX;SACD;QACDG,cACE,2DACA;QACFC,QAAQ;IACV;IACA,iEAAiE;IACjE,sEAAsE;IACtE,wEAAwE;IACxE,yDAAyD;IACzD;QACEX,KAAK;QACLL,MAAM;QACNC,aACE,iEACA,6DACA;QACFK,aAAa;QACbC,gBAAgB;QAChBC,aAAa;YACX;gBACER,MAAM;gBACNC,aAAa;gBACbC,QAAQ,GAAGd,sBAAsB,yBAAyB,CAAC;YAC7D;YACA;gBACEY,MAAM;gBACNC,aAAa;gBACbC,QAAQ;YACV;SACD;QACD,sDAAsD;QACtDO,aAAa;YACX;gBAAEC,OAAO;gBAAQC,MAAM;gBAAuBC,SAAS;YAAU;YACjE;gBACEF,OAAO;gBACPC,MACE,4EACA;gBACFC,SAAS;YACX;YACA;gBAAEF,OAAO;gBAAUG,OAAO;gBAAsBC,MAAM;YAAe;YACrE;gBACEJ,OAAO;gBACPC,MACE,mEACA;gBACFC,SAAS;YACX;SACD;QACDG,cACE,8DACA;QACFC,QAAQ;IACV;IACA;QACEX,KAAK;QACLL,MAAM;QACNC,aACE,iEACA,qEACA;QACFK,aAAa;QACbC,gBAAgB;QAChBC,aAAa;YACX;gBACER,MAAM;gBACNC,aAAa;gBACbC,QAAQ;YACV;YACA;gBACEF,MAAM;gBACNC,aAAa;gBACbC,QAAQd;YACV;SACD;QACD,sDAAsD;QACtDqB,aAAa;YACX;gBAAEC,OAAO;gBAAQC,MAAM;gBAA6BC,SAAS;YAAU;YACvE;gBACEF,OAAO;gBACPC,MACE,iEACA,kEACA;gBACFC,SAAS;YACX;YACA;gBAAEF,OAAO;gBAAUG,OAAO;gBAAWC,MAAM;YAAgB;YAC3D;gBACEJ,OAAO;gBACPC,MACE,kEACA;gBACFC,SAAS;YACX;SACD;QACDG,cACE,gEACA;QACFC,QAAQ;IACV;IACA,yEAAyE;IACzE,uEAAuE;IACvE,wEAAwE;IACxE,0BAA0B;IAC1B;QACEX,KAAK;QACLL,MAAM;QACNC,aACE,uEAC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"}
@@ -14,6 +14,44 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
+ /**
18
+ * THE HTML PART EVERY MESSAGE GETS.
19
+ *
20
+ * ## What was wrong
21
+ *
22
+ * `sendEmail` forwarded exactly what it was handed, and almost nothing hands
23
+ * it `html`. Twelve senders have no HTML path at all; the other twenty-seven
24
+ * write `...(designed?.html ? { html: designed.html } : {})`, which produces
25
+ * an HTML part only when a staff-designed template is published for that key.
26
+ * With none published, every message on the domain went out as `"html": ""` —
27
+ * a `text/plain` part and nothing else.
28
+ *
29
+ * Two consequences, and the second is the one that hid the first:
30
+ *
31
+ * 1. A URL in a plain-text part is not a link. It renders as the characters
32
+ * of the URL; whether it is clickable at all is the mail client's guess,
33
+ * and a long one wraps mid-string and stops being followable.
34
+ * 2. **Click tracking cannot work.** Resend measures clicks by rewriting
35
+ * `<a href>` in the HTML part. No HTML part means no anchors, which means
36
+ * no rewriting, which means the click rate is structurally 0% and would
37
+ * have stayed there however long anyone waited on it.
38
+ *
39
+ * ## Why it lives here rather than at the call sites
40
+ *
41
+ * The same reasoning `contextTag` records one file over: threading an HTML
42
+ * body through thirty-nine call sites asks thirty-nine places to remember,
43
+ * which is the shape that produces the fortieth that does not. A sender that
44
+ * builds real HTML still wins — this is a fallback, consulted only when
45
+ * `html` is absent — so no existing caller changes and no designed template
46
+ * is overridden.
47
+ *
48
+ * ## What it is not
49
+ *
50
+ * Not a template. It carries no logo, no brand color and no footer, because
51
+ * it stands in for copy that was written to be read as plain text and it must
52
+ * not imply a design decision nobody made. It is the plain-text body, in a
53
+ * readable column, with its links actually linked.
54
+ */
17
55
  /**
18
56
  * Renders a plain-text body as the HTML part of the same message.
19
57
  *
@@ -13,7 +13,8 @@
13
13
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
- */ import { escapeEmailHtml } from "./email-render.js";
16
+ */ import { linkifyEscapedText } from "./email-linkify.js";
17
+ import { escapeEmailHtml } from "./email-render.js";
17
18
  /**
18
19
  * THE HTML PART EVERY MESSAGE GETS.
19
20
  *
@@ -52,48 +53,6 @@
52
53
  * not imply a design decision nobody made. It is the plain-text body, in a
53
54
  * readable column, with its links actually linked.
54
55
  */ /**
55
- * Trailing characters stripped from a matched URL.
56
- *
57
- * A sentence that ends "…see {@link https://example.com/billing}." puts the
58
- * period inside the match, because a period is a legal URL character and the
59
- * regex cannot tell prose from path. Closing brackets are handled separately
60
- * below, since a URL may legitimately end in one.
61
- *
62
- * `;` is deliberately absent: by the time this runs the text is already
63
- * escaped, so a query string reads `?a=1&amp;b=2` and trimming `;` would cut
64
- * an entity in half and corrupt the link.
65
- */ const URL_TRAILING_PUNCTUATION = /[.,!?'"]+$/;
66
- /**
67
- * Bare absolute URLs in already-escaped text.
68
- *
69
- * Matched AFTER escaping, not before, so the href and the visible label are
70
- * the same string and neither can reintroduce markup: `&` inside a query
71
- * string is `&amp;` by then, which is what an href attribute is supposed to
72
- * carry and parses back to `&` in the client.
73
- *
74
- * `http`/`https` only. Every URL our system copy emits is absolute — the
75
- * senders share one `consoleOrigin()` precisely because a mail client has no
76
- * page to resolve a relative path against — and matching bare `www.` or
77
- * addresses would turn ordinary prose into links nobody wrote.
78
- */ const BARE_URL = /https?:\/\/[^\s<>"]+/g;
79
- /**
80
- * Links the bare URLs in one escaped line.
81
- *
82
- * Balanced closing parens are kept, because a URL can genuinely end in one
83
- * and a wrapping "(see https://…/a_(b))" is the rarer case. Anything the
84
- * paren count says is unbalanced belongs to the prose.
85
- */ function linkifyEscaped(escaped) {
86
- return escaped.replace(BARE_URL, (match)=>{
87
- let url = match.replace(URL_TRAILING_PUNCTUATION, '');
88
- while(url.endsWith(')') && url.split(')').length > url.split('(').length){
89
- url = url.slice(0, -1);
90
- }
91
- if (!url) return match;
92
- const trailer = match.slice(url.length);
93
- return `<a href="${url}" target="_blank" ` + `style="color:#1a73e8;text-decoration:underline;word-break:break-word;">` + `${url}</a>${trailer}`;
94
- });
95
- }
96
- /**
97
56
  * Renders a plain-text body as the HTML part of the same message.
98
57
  *
99
58
  * Blank-line-separated blocks become paragraphs and single newlines become
@@ -111,7 +70,7 @@
111
70
  if (!body) return '';
112
71
  const preview = String(preheader != null ? preheader : '').trim();
113
72
  const preheaderHtml = preview ? `<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">` + escapeEmailHtml(preview) + `</div>` : '';
114
- const paragraphs = body.split(/\n\s*\n/).map((block)=>block.trim()).filter(Boolean).map((block)=>`<p style="margin:0 0 16px;">` + linkifyEscaped(escapeEmailHtml(block)).replace(/\n/g, '<br />') + `</p>`).join('');
73
+ const paragraphs = body.split(/\n\s*\n/).map((block)=>block.trim()).filter(Boolean).map((block)=>`<p style="margin:0 0 16px;">` + linkifyEscapedText(escapeEmailHtml(block)).replace(/\n/g, '<br />') + `</p>`).join('');
115
74
  return `<!DOCTYPE html><html><head><meta charset="utf-8" />` + `<meta name="viewport" content="width=device-width, initial-scale=1" />` + `<title>${escapeEmailHtml(String(subject != null ? subject : ''))}</title></head>` + `<body style="margin:0;padding:0;background-color:#f4f4f4;">` + preheaderHtml + `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" ` + `style="background-color:#f4f4f4;"><tr><td style="padding:24px 8px;">` + `<table role="presentation" width="600" cellpadding="0" cellspacing="0" ` + `align="center" style="max-width:600px;width:100%;margin:0 auto;">` + `<tr><td style="padding:24px;background-color:#ffffff;border-radius:8px;` + `font-family:Helvetica, Arial, sans-serif;font-size:15px;line-height:1.6;` + `color:#1a1a1a;">` + paragraphs + `</td></tr></table></td></tr></table></body></html>`;
116
75
  }
117
76
  export default renderTextEmailHtml;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/text-email-html.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\nimport { escapeEmailHtml } from './email-render'\n\n/**\n * THE HTML PART EVERY MESSAGE GETS.\n *\n * ## What was wrong\n *\n * `sendEmail` forwarded exactly what it was handed, and almost nothing hands\n * it `html`. Twelve senders have no HTML path at all; the other twenty-seven\n * write `...(designed?.html ? { html: designed.html } : {})`, which produces\n * an HTML part only when a staff-designed template is published for that key.\n * With none published, every message on the domain went out as `\"html\": \"\"` —\n * a `text/plain` part and nothing else.\n *\n * Two consequences, and the second is the one that hid the first:\n *\n * 1. A URL in a plain-text part is not a link. It renders as the characters\n * of the URL; whether it is clickable at all is the mail client's guess,\n * and a long one wraps mid-string and stops being followable.\n * 2. **Click tracking cannot work.** Resend measures clicks by rewriting\n * `<a href>` in the HTML part. No HTML part means no anchors, which means\n * no rewriting, which means the click rate is structurally 0% and would\n * have stayed there however long anyone waited on it.\n *\n * ## Why it lives here rather than at the call sites\n *\n * The same reasoning `contextTag` records one file over: threading an HTML\n * body through thirty-nine call sites asks thirty-nine places to remember,\n * which is the shape that produces the fortieth that does not. A sender that\n * builds real HTML still wins — this is a fallback, consulted only when\n * `html` is absent — so no existing caller changes and no designed template\n * is overridden.\n *\n * ## What it is not\n *\n * Not a template. It carries no logo, no brand color and no footer, because\n * it stands in for copy that was written to be read as plain text and it must\n * not imply a design decision nobody made. It is the plain-text body, in a\n * readable column, with its links actually linked.\n */\n\n/**\n * Trailing characters stripped from a matched URL.\n *\n * A sentence that ends \"…see {@link https://example.com/billing}.\" puts the\n * period inside the match, because a period is a legal URL character and the\n * regex cannot tell prose from path. Closing brackets are handled separately\n * below, since a URL may legitimately end in one.\n *\n * `;` is deliberately absent: by the time this runs the text is already\n * escaped, so a query string reads `?a=1&amp;b=2` and trimming `;` would cut\n * an entity in half and corrupt the link.\n */\nconst URL_TRAILING_PUNCTUATION = /[.,!?'\"]+$/\n\n/**\n * Bare absolute URLs in already-escaped text.\n *\n * Matched AFTER escaping, not before, so the href and the visible label are\n * the same string and neither can reintroduce markup: `&` inside a query\n * string is `&amp;` by then, which is what an href attribute is supposed to\n * carry and parses back to `&` in the client.\n *\n * `http`/`https` only. Every URL our system copy emits is absolute — the\n * senders share one `consoleOrigin()` precisely because a mail client has no\n * page to resolve a relative path against — and matching bare `www.` or\n * addresses would turn ordinary prose into links nobody wrote.\n */\nconst BARE_URL = /https?:\\/\\/[^\\s<>\"]+/g\n\n/**\n * Links the bare URLs in one escaped line.\n *\n * Balanced closing parens are kept, because a URL can genuinely end in one\n * and a wrapping \"(see https://…/a_(b))\" is the rarer case. Anything the\n * paren count says is unbalanced belongs to the prose.\n */\nfunction linkifyEscaped(escaped: string): string {\n return escaped.replace(BARE_URL, (match) => {\n let url = match.replace(URL_TRAILING_PUNCTUATION, '')\n while (\n url.endsWith(')') &&\n url.split(')').length > url.split('(').length\n ) {\n url = url.slice(0, -1)\n }\n if (!url) return match\n const trailer = match.slice(url.length)\n return (\n `<a href=\"${url}\" target=\"_blank\" ` +\n `style=\"color:#1a73e8;text-decoration:underline;word-break:break-word;\">` +\n `${url}</a>${trailer}`\n )\n })\n}\n\n/**\n * Renders a plain-text body as the HTML part of the same message.\n *\n * Blank-line-separated blocks become paragraphs and single newlines become\n * `<br />`, which is what the senders' template literals already mean by\n * them. Returns an empty string for empty input so a caller can treat \"no\n * text\" and \"no html\" the same way.\n *\n * @param text The plain-text body being sent alongside this.\n * @param subject Used only for the document `<title>`.\n * @param preheader The hidden line inboxes show after the subject. Same\n * markup `renderEmailHtml` emits for a designed email, so a campaign\n * gets the same treatment whichever body it carries.\n */\nexport function renderTextEmailHtml(\n text: string,\n subject = '',\n preheader = '',\n): string {\n const body = String(text ?? '').trim()\n if (!body) return ''\n\n const preview = String(preheader ?? '').trim()\n const preheaderHtml = preview\n ? `<div style=\"display:none;max-height:0;overflow:hidden;mso-hide:all;\">` +\n escapeEmailHtml(preview) +\n `</div>`\n : ''\n\n const paragraphs = body\n .split(/\\n\\s*\\n/)\n .map((block) => block.trim())\n .filter(Boolean)\n .map(\n (block) =>\n `<p style=\"margin:0 0 16px;\">` +\n linkifyEscaped(escapeEmailHtml(block)).replace(/\\n/g, '<br />') +\n `</p>`,\n )\n .join('')\n\n return (\n `<!DOCTYPE html><html><head><meta charset=\"utf-8\" />` +\n `<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />` +\n `<title>${escapeEmailHtml(String(subject ?? ''))}</title></head>` +\n `<body style=\"margin:0;padding:0;background-color:#f4f4f4;\">` +\n preheaderHtml +\n `<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" ` +\n `style=\"background-color:#f4f4f4;\"><tr><td style=\"padding:24px 8px;\">` +\n `<table role=\"presentation\" width=\"600\" cellpadding=\"0\" cellspacing=\"0\" ` +\n `align=\"center\" style=\"max-width:600px;width:100%;margin:0 auto;\">` +\n `<tr><td style=\"padding:24px;background-color:#ffffff;border-radius:8px;` +\n `font-family:Helvetica, Arial, sans-serif;font-size:15px;line-height:1.6;` +\n `color:#1a1a1a;\">` +\n paragraphs +\n `</td></tr></table></td></tr></table></body></html>`\n )\n}\n\nexport default renderTextEmailHtml\n"],"names":["escapeEmailHtml","URL_TRAILING_PUNCTUATION","BARE_URL","linkifyEscaped","escaped","replace","match","url","endsWith","split","length","slice","trailer","renderTextEmailHtml","text","subject","preheader","body","String","trim","preview","preheaderHtml","paragraphs","map","block","filter","Boolean","join"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,eAAe,QAAQ,oBAAgB;AAEhD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqCC,GAED;;;;;;;;;;;CAWC,GACD,MAAMC,2BAA2B;AAEjC;;;;;;;;;;;;CAYC,GACD,MAAMC,WAAW;AAEjB;;;;;;CAMC,GACD,SAASC,eAAeC,OAAe;IACrC,OAAOA,QAAQC,OAAO,CAACH,UAAU,CAACI;QAChC,IAAIC,MAAMD,MAAMD,OAAO,CAACJ,0BAA0B;QAClD,MACEM,IAAIC,QAAQ,CAAC,QACbD,IAAIE,KAAK,CAAC,KAAKC,MAAM,GAAGH,IAAIE,KAAK,CAAC,KAAKC,MAAM,CAC7C;YACAH,MAAMA,IAAII,KAAK,CAAC,GAAG,CAAC;QACtB;QACA,IAAI,CAACJ,KAAK,OAAOD;QACjB,MAAMM,UAAUN,MAAMK,KAAK,CAACJ,IAAIG,MAAM;QACtC,OACE,CAAC,SAAS,EAAEH,IAAI,kBAAkB,CAAC,GACnC,CAAC,uEAAuE,CAAC,GACzE,GAAGA,IAAI,IAAI,EAAEK,SAAS;IAE1B;AACF;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASC,oBACdC,IAAY,EACZC,UAAU,EAAE,EACZC,YAAY,EAAE;IAEd,MAAMC,OAAOC,OAAOJ,eAAAA,OAAQ,IAAIK,IAAI;IACpC,IAAI,CAACF,MAAM,OAAO;IAElB,MAAMG,UAAUF,OAAOF,oBAAAA,YAAa,IAAIG,IAAI;IAC5C,MAAME,gBAAgBD,UAClB,CAAC,qEAAqE,CAAC,GACvEpB,gBAAgBoB,WAChB,CAAC,MAAM,CAAC,GACR;IAEJ,MAAME,aAAaL,KAChBR,KAAK,CAAC,WACNc,GAAG,CAAC,CAACC,QAAUA,MAAML,IAAI,IACzBM,MAAM,CAACC,SACPH,GAAG,CACF,CAACC,QACC,CAAC,4BAA4B,CAAC,GAC9BrB,eAAeH,gBAAgBwB,QAAQnB,OAAO,CAAC,OAAO,YACtD,CAAC,IAAI,CAAC,EAETsB,IAAI,CAAC;IAER,OACE,CAAC,mDAAmD,CAAC,GACrD,CAAC,sEAAsE,CAAC,GACxE,CAAC,OAAO,EAAE3B,gBAAgBkB,OAAOH,kBAAAA,UAAW,KAAK,eAAe,CAAC,GACjE,CAAC,2DAA2D,CAAC,GAC7DM,gBACA,CAAC,wEAAwE,CAAC,GAC1E,CAAC,oEAAoE,CAAC,GACtE,CAAC,uEAAuE,CAAC,GACzE,CAAC,iEAAiE,CAAC,GACnE,CAAC,uEAAuE,CAAC,GACzE,CAAC,wEAAwE,CAAC,GAC1E,CAAC,gBAAgB,CAAC,GAClBC,aACA,CAAC,kDAAkD,CAAC;AAExD;AAEA,eAAeT,oBAAmB"}
1
+ {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/text-email-html.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\nimport { linkifyEscapedText } from './email-linkify'\nimport { escapeEmailHtml } from './email-render'\n\n/**\n * THE HTML PART EVERY MESSAGE GETS.\n *\n * ## What was wrong\n *\n * `sendEmail` forwarded exactly what it was handed, and almost nothing hands\n * it `html`. Twelve senders have no HTML path at all; the other twenty-seven\n * write `...(designed?.html ? { html: designed.html } : {})`, which produces\n * an HTML part only when a staff-designed template is published for that key.\n * With none published, every message on the domain went out as `\"html\": \"\"` —\n * a `text/plain` part and nothing else.\n *\n * Two consequences, and the second is the one that hid the first:\n *\n * 1. A URL in a plain-text part is not a link. It renders as the characters\n * of the URL; whether it is clickable at all is the mail client's guess,\n * and a long one wraps mid-string and stops being followable.\n * 2. **Click tracking cannot work.** Resend measures clicks by rewriting\n * `<a href>` in the HTML part. No HTML part means no anchors, which means\n * no rewriting, which means the click rate is structurally 0% and would\n * have stayed there however long anyone waited on it.\n *\n * ## Why it lives here rather than at the call sites\n *\n * The same reasoning `contextTag` records one file over: threading an HTML\n * body through thirty-nine call sites asks thirty-nine places to remember,\n * which is the shape that produces the fortieth that does not. A sender that\n * builds real HTML still wins — this is a fallback, consulted only when\n * `html` is absent — so no existing caller changes and no designed template\n * is overridden.\n *\n * ## What it is not\n *\n * Not a template. It carries no logo, no brand color and no footer, because\n * it stands in for copy that was written to be read as plain text and it must\n * not imply a design decision nobody made. It is the plain-text body, in a\n * readable column, with its links actually linked.\n */\n\n/**\n * Renders a plain-text body as the HTML part of the same message.\n *\n * Blank-line-separated blocks become paragraphs and single newlines become\n * `<br />`, which is what the senders' template literals already mean by\n * them. Returns an empty string for empty input so a caller can treat \"no\n * text\" and \"no html\" the same way.\n *\n * @param text The plain-text body being sent alongside this.\n * @param subject Used only for the document `<title>`.\n * @param preheader The hidden line inboxes show after the subject. Same\n * markup `renderEmailHtml` emits for a designed email, so a campaign\n * gets the same treatment whichever body it carries.\n */\nexport function renderTextEmailHtml(\n text: string,\n subject = '',\n preheader = '',\n): string {\n const body = String(text ?? '').trim()\n if (!body) return ''\n\n const preview = String(preheader ?? '').trim()\n const preheaderHtml = preview\n ? `<div style=\"display:none;max-height:0;overflow:hidden;mso-hide:all;\">` +\n escapeEmailHtml(preview) +\n `</div>`\n : ''\n\n const paragraphs = body\n .split(/\\n\\s*\\n/)\n .map((block) => block.trim())\n .filter(Boolean)\n .map(\n (block) =>\n `<p style=\"margin:0 0 16px;\">` +\n linkifyEscapedText(escapeEmailHtml(block)).replace(/\\n/g, '<br />') +\n `</p>`,\n )\n .join('')\n\n return (\n `<!DOCTYPE html><html><head><meta charset=\"utf-8\" />` +\n `<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />` +\n `<title>${escapeEmailHtml(String(subject ?? ''))}</title></head>` +\n `<body style=\"margin:0;padding:0;background-color:#f4f4f4;\">` +\n preheaderHtml +\n `<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" ` +\n `style=\"background-color:#f4f4f4;\"><tr><td style=\"padding:24px 8px;\">` +\n `<table role=\"presentation\" width=\"600\" cellpadding=\"0\" cellspacing=\"0\" ` +\n `align=\"center\" style=\"max-width:600px;width:100%;margin:0 auto;\">` +\n `<tr><td style=\"padding:24px;background-color:#ffffff;border-radius:8px;` +\n `font-family:Helvetica, Arial, sans-serif;font-size:15px;line-height:1.6;` +\n `color:#1a1a1a;\">` +\n paragraphs +\n `</td></tr></table></td></tr></table></body></html>`\n )\n}\n\nexport default renderTextEmailHtml\n"],"names":["linkifyEscapedText","escapeEmailHtml","renderTextEmailHtml","text","subject","preheader","body","String","trim","preview","preheaderHtml","paragraphs","split","map","block","filter","Boolean","replace","join"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,kBAAkB,QAAQ,qBAAiB;AACpD,SAASC,eAAe,QAAQ,oBAAgB;AAEhD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqCC,GAED;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASC,oBACdC,IAAY,EACZC,UAAU,EAAE,EACZC,YAAY,EAAE;IAEd,MAAMC,OAAOC,OAAOJ,eAAAA,OAAQ,IAAIK,IAAI;IACpC,IAAI,CAACF,MAAM,OAAO;IAElB,MAAMG,UAAUF,OAAOF,oBAAAA,YAAa,IAAIG,IAAI;IAC5C,MAAME,gBAAgBD,UAClB,CAAC,qEAAqE,CAAC,GACvER,gBAAgBQ,WAChB,CAAC,MAAM,CAAC,GACR;IAEJ,MAAME,aAAaL,KAChBM,KAAK,CAAC,WACNC,GAAG,CAAC,CAACC,QAAUA,MAAMN,IAAI,IACzBO,MAAM,CAACC,SACPH,GAAG,CACF,CAACC,QACC,CAAC,4BAA4B,CAAC,GAC9Bd,mBAAmBC,gBAAgBa,QAAQG,OAAO,CAAC,OAAO,YAC1D,CAAC,IAAI,CAAC,EAETC,IAAI,CAAC;IAER,OACE,CAAC,mDAAmD,CAAC,GACrD,CAAC,sEAAsE,CAAC,GACxE,CAAC,OAAO,EAAEjB,gBAAgBM,OAAOH,kBAAAA,UAAW,KAAK,eAAe,CAAC,GACjE,CAAC,2DAA2D,CAAC,GAC7DM,gBACA,CAAC,wEAAwE,CAAC,GAC1E,CAAC,oEAAoE,CAAC,GACtE,CAAC,uEAAuE,CAAC,GACzE,CAAC,iEAAiE,CAAC,GACnE,CAAC,uEAAuE,CAAC,GACzE,CAAC,wEAAwE,CAAC,GAC1E,CAAC,gBAAgB,CAAC,GAClBC,aACA,CAAC,kDAAkD,CAAC;AAExD;AAEA,eAAeT,oBAAmB"}