@aglyn/shared-util-email 1.0.0-beta.194 → 1.0.0-beta.196
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/shared-util-email",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.196",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
28
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.196",
|
|
29
|
+
"@aglyn/shared-util-http": "1.0.0-beta.196",
|
|
30
30
|
"@msgpack/msgpack": "^3.1.3",
|
|
31
31
|
"@swc/helpers": "0.5.23"
|
|
32
32
|
},
|
|
@@ -92,8 +92,68 @@ export interface EmailRenderOptions {
|
|
|
92
92
|
* with a gap where a logo should be reads as broken; one without a logo
|
|
93
93
|
* reads as plain, which is the correct appearance for an org that has not
|
|
94
94
|
* set one.
|
|
95
|
+
*
|
|
96
|
+
* Ignored when {@link EmailRenderOptions.chrome} carries a header: that
|
|
97
|
+
* header is the logo row, and a caller that has one passes the org's logo
|
|
98
|
+
* through it, so a message never opens with two.
|
|
95
99
|
*/
|
|
96
100
|
brandLogoUrl?: string;
|
|
101
|
+
/**
|
|
102
|
+
* The sender's header and footer, drawn around the body (AGL-3322). See
|
|
103
|
+
* {@link EmailChrome}. Absent draws neither.
|
|
104
|
+
*/
|
|
105
|
+
chrome?: EmailChrome;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The row above the body that says who sent the mail.
|
|
109
|
+
*
|
|
110
|
+
* `logoAlt` is required because it does two jobs. It is the logo's `alt`,
|
|
111
|
+
* which is what most inboxes show, since they block images by default. And
|
|
112
|
+
* it is the header itself when there is no logo to draw — none was given, or
|
|
113
|
+
* the one given cannot be fetched from an inbox — so the sender is named in
|
|
114
|
+
* bold text rather than by a broken-image box.
|
|
115
|
+
*/
|
|
116
|
+
export interface EmailChromeHeader {
|
|
117
|
+
/** The logo. Resolved and scheme-checked like every other image here. */
|
|
118
|
+
logoUrl?: string;
|
|
119
|
+
/** The sender's name: the logo's alt text, or the header when no logo draws. */
|
|
120
|
+
logoAlt: string;
|
|
121
|
+
/** Where the logo or name links. A refused scheme drops the link, not the header. */
|
|
122
|
+
href?: string;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The rows under the body, in the order they draw. Each is optional, and a
|
|
126
|
+
* footer with none of them draws nothing.
|
|
127
|
+
*/
|
|
128
|
+
export interface EmailChromeFooter {
|
|
129
|
+
/** Why the recipient is getting this mail. */
|
|
130
|
+
reason?: string;
|
|
131
|
+
/** Where to get help. Drawn as a link; dropped whole when the href is refused. */
|
|
132
|
+
support?: {
|
|
133
|
+
label: string;
|
|
134
|
+
href: string;
|
|
135
|
+
};
|
|
136
|
+
/** Who sent it, and from where: a copyright and postal line. */
|
|
137
|
+
legal?: string;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* A sender's header and footer, drawn by the renderer rather than placed by a
|
|
141
|
+
* designer (AGL-3322).
|
|
142
|
+
*
|
|
143
|
+
* For mail whose body carries no header or footer of its own: a system
|
|
144
|
+
* email's built-in copy, or a design sent under a brand whose own blocks it
|
|
145
|
+
* may not carry. The caller decides WHICH brand; this only draws.
|
|
146
|
+
*
|
|
147
|
+
* Every string goes through the same merge substitution as the body, so
|
|
148
|
+
* `{{org.name}}` in a footer line reads as the organization it names, and is
|
|
149
|
+
* escaped like body text. Every URL goes through the same scheme checks as a
|
|
150
|
+
* body link or image. The footer lines are appended to the plain-text part
|
|
151
|
+
* too, the support link as its bare address, because a reader of that part is
|
|
152
|
+
* owed the same reason and the same way to get help.
|
|
153
|
+
*/
|
|
154
|
+
export interface EmailChrome {
|
|
155
|
+
header?: EmailChromeHeader;
|
|
156
|
+
footer?: EmailChromeFooter;
|
|
97
157
|
}
|
|
98
158
|
export interface RenderedEmail {
|
|
99
159
|
html: string;
|
package/src/lib/email-render.js
CHANGED
|
@@ -44,6 +44,20 @@
|
|
|
44
44
|
*/ import { hasSafeLinkScheme, hasSafeMediaScheme } from "@aglyn/shared-util-http/safe-url-scheme";
|
|
45
45
|
import { resolveEmailMediaSrc } from "./email-media-src.js";
|
|
46
46
|
const FONT = 'Helvetica, Arial, sans-serif';
|
|
47
|
+
/**
|
|
48
|
+
* The colours the chrome is drawn in (AGL-3322), named once.
|
|
49
|
+
*
|
|
50
|
+
* Literal because this is email HTML: mail clients strip `<style>` and read no
|
|
51
|
+
* CSS variables, so no theme token reaches the wire. They are the values
|
|
52
|
+
* already in the mail, not new ones — the ink and card of a default body
|
|
53
|
+
* block, and the band and caption grey of the email footer block the
|
|
54
|
+
* platform's own mail wears — so the chrome and the body it frames agree.
|
|
55
|
+
*/ const CHROME_PALETTE = {
|
|
56
|
+
/** The sender's name in the header: body text's ink. */ ink: '#1a1a1a',
|
|
57
|
+
/** The header band: a default section's card. */ card: '#ffffff',
|
|
58
|
+
/** The footer band. */ band: '#F8F9FA',
|
|
59
|
+
/** Footer lines and the support link. */ caption: '#757575'
|
|
60
|
+
};
|
|
47
61
|
export function escapeEmailHtml(value) {
|
|
48
62
|
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
49
63
|
}
|
|
@@ -71,7 +85,7 @@ const TEXT_STYLES = {
|
|
|
71
85
|
export function renderEmailHtml(options) {
|
|
72
86
|
var _imageSrc, _ref;
|
|
73
87
|
var _nodes_rootId;
|
|
74
|
-
const { nodes, rootId = 'root', subject = '', preheader = '', merge, products, sanitize, mediaOrigin, mediaHostId, brandLogoUrl } = options;
|
|
88
|
+
const { nodes, rootId = 'root', subject = '', preheader = '', merge, products, sanitize, mediaOrigin, mediaHostId, brandLogoUrl, chrome } = options;
|
|
75
89
|
const textParts = [];
|
|
76
90
|
const sub = (value)=>substituteMergeTokens(String(value != null ? value : ''), merge);
|
|
77
91
|
/**
|
|
@@ -164,7 +178,14 @@ export function renderEmailHtml(options) {
|
|
|
164
178
|
const background = props.backgroundColor || '#ffffff';
|
|
165
179
|
const padding = Number.isFinite(Number(props.padding)) ? Number(props.padding) : 24;
|
|
166
180
|
const align = props.align || 'left';
|
|
167
|
-
|
|
181
|
+
// A ROW, like every other block, with the band's table inside its
|
|
182
|
+
// cell. Whatever encloses a section is a table — the 600px column, or
|
|
183
|
+
// another section's inner table — and a `<table>` placed straight into
|
|
184
|
+
// a table's rows is not a child an HTML parser keeps there: it closes
|
|
185
|
+
// the enclosing table and lands after it. Emitted bare, a section would
|
|
186
|
+
// draw outside the column, as wide as the viewport rather than the
|
|
187
|
+
// 600px the editor shows, and take every row after it out too.
|
|
188
|
+
return row(`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" ` + `style="background-color:${background};">` + row(`<table role="presentation" width="100%" cellpadding="0" cellspacing="0">` + renderChildren(node.nodes) + `</table>`, `padding:${padding}px;text-align:${align};`) + `</table>`);
|
|
168
189
|
}
|
|
169
190
|
case 'emailText':
|
|
170
191
|
{
|
|
@@ -283,7 +304,71 @@ export function renderEmailHtml(options) {
|
|
|
283
304
|
// reintroduce the hard-coded brand this whole change removes.
|
|
284
305
|
const brandLogoAlt = (_ref = merge == null ? void 0 : merge['brand.productName']) != null ? _ref : '';
|
|
285
306
|
const brandLogoHtml = brandLogo ? row(`<img src="${escapeEmailHtml(brandLogo)}" alt="${escapeEmailHtml(brandLogoAlt)}" ` + `style="max-height:48px;max-width:200px;display:block;margin:0 auto;border:0;" />`, 'padding:24px 24px 0;text-align:center;') : '';
|
|
286
|
-
|
|
307
|
+
/**
|
|
308
|
+
* The chrome header row, or '' when it has nothing to draw.
|
|
309
|
+
*
|
|
310
|
+
* Same placement and the same `imageSrc` as the logo row above, which it
|
|
311
|
+
* replaces. Height is capped at 40px rather than set, so a wordmark and a
|
|
312
|
+
* square mark both land at a sane size; the `height` attribute is for the
|
|
313
|
+
* desktop clients that ignore CSS `max-height` and would otherwise draw the
|
|
314
|
+
* file at its own size. A logo that cannot be fetched from an inbox falls
|
|
315
|
+
* back to the name in bold, never to an `<img>` that draws a broken box.
|
|
316
|
+
* White, so the header and a default body read as one card.
|
|
317
|
+
*/ const renderChromeHeader = (header)=>{
|
|
318
|
+
var _header_logoUrl;
|
|
319
|
+
const logo = imageSrc(String((_header_logoUrl = header.logoUrl) != null ? _header_logoUrl : '').trim());
|
|
320
|
+
const name = sub(header.logoAlt).trim();
|
|
321
|
+
const href = linkHref(header.href);
|
|
322
|
+
const link = (inner, style)=>href ? `<a href="${escapeEmailHtml(href)}" target="_blank" style="${style}">${inner}</a>` : inner;
|
|
323
|
+
let mark;
|
|
324
|
+
if (logo) {
|
|
325
|
+
mark = link(`<img src="${escapeEmailHtml(logo)}" alt="${escapeEmailHtml(name)}" height="40" ` + `style="display:block;margin:0 auto;border:0;height:auto;width:auto;max-height:40px;max-width:200px;" />`, 'text-decoration:none;');
|
|
326
|
+
} else if (name) {
|
|
327
|
+
mark = `<div style="font-family:${FONT};font-size:20px;font-weight:700;line-height:1.3;color:${CHROME_PALETTE.ink};text-align:center;">` + link(escapeEmailHtml(name), `color:${CHROME_PALETTE.ink};text-decoration:none;`) + `</div>`;
|
|
328
|
+
} else {
|
|
329
|
+
return '';
|
|
330
|
+
}
|
|
331
|
+
return row(mark, `padding:24px 24px 0;text-align:center;background-color:${CHROME_PALETTE.card};`);
|
|
332
|
+
};
|
|
333
|
+
const headerHtml = (chrome == null ? void 0 : chrome.header) ? renderChromeHeader(chrome.header) : brandLogoHtml;
|
|
334
|
+
/**
|
|
335
|
+
* The chrome footer: rows under the body in the look of the email footer
|
|
336
|
+
* block the platform's own mail wears (a `#F8F9FA` section, padded 24,
|
|
337
|
+
* centered captions), and the same lines appended to the plain-text part.
|
|
338
|
+
*
|
|
339
|
+
* Rendered AFTER the body on purpose: the body's blocks push their text as
|
|
340
|
+
* they draw, and the footer's lines belong after all of it. A line that
|
|
341
|
+
* resolves to nothing is left out rather than drawn as an empty row.
|
|
342
|
+
*/ const renderChromeFooter = (footer)=>{
|
|
343
|
+
var _footer_support, _footer_support1;
|
|
344
|
+
const htmlLines = [];
|
|
345
|
+
const textLines = [];
|
|
346
|
+
const reason = sub(footer.reason).trim();
|
|
347
|
+
if (reason) {
|
|
348
|
+
htmlLines.push(escapeEmailHtml(reason));
|
|
349
|
+
textLines.push(reason);
|
|
350
|
+
}
|
|
351
|
+
const supportLabel = sub((_footer_support = footer.support) == null ? void 0 : _footer_support.label).trim();
|
|
352
|
+
const supportHref = linkHref((_footer_support1 = footer.support) == null ? void 0 : _footer_support1.href);
|
|
353
|
+
if (supportLabel && supportHref) {
|
|
354
|
+
htmlLines.push(`<a href="${escapeEmailHtml(supportHref)}" target="_blank" ` + `style="color:${CHROME_PALETTE.caption};text-decoration:underline;">${escapeEmailHtml(supportLabel)}</a>`);
|
|
355
|
+
// A plain-text reader gets the address itself, bare: a mailto's
|
|
356
|
+
// mailbox, or the URL. "Get help: https://…" names where it goes; a
|
|
357
|
+
// label that already IS the mailbox is not repeated.
|
|
358
|
+
const bare = /^mailto:/i.test(supportHref) ? supportHref.slice('mailto:'.length).split('?')[0] : supportHref;
|
|
359
|
+
textLines.push(supportLabel === bare ? bare : `${supportLabel}: ${bare}`);
|
|
360
|
+
}
|
|
361
|
+
const legal = sub(footer.legal).trim();
|
|
362
|
+
if (legal) {
|
|
363
|
+
htmlLines.push(escapeEmailHtml(legal));
|
|
364
|
+
textLines.push(legal);
|
|
365
|
+
}
|
|
366
|
+
if (!htmlLines.length) return '';
|
|
367
|
+
textParts.push(textLines.join('\n'));
|
|
368
|
+
return row(htmlLines.map((line, index)=>`<div style="font-family:${FONT};font-size:12px;font-weight:400;line-height:1.4;color:${CHROME_PALETTE.caption};` + `${index ? 'margin-top:8px;' : ''}">${line.replace(/\n/g, '<br />')}</div>`).join(''), `padding:24px;text-align:center;background-color:${CHROME_PALETTE.band};`);
|
|
369
|
+
};
|
|
370
|
+
const footerHtml = (chrome == null ? void 0 : chrome.footer) ? renderChromeFooter(chrome.footer) : '';
|
|
371
|
+
const html = `<!DOCTYPE html><html><head><meta charset="utf-8" />` + `<meta name="viewport" content="width=device-width, initial-scale=1" />` + `<title>${escapeEmailHtml(sub(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;">` + row(`<table role="presentation" width="600" cellpadding="0" cellspacing="0" align="center" style="max-width:600px;width:100%;margin:0 auto;">` + headerHtml + body + footerHtml + `</table>`, 'padding:24px 8px;') + `</table></body></html>`;
|
|
287
372
|
return {
|
|
288
373
|
html,
|
|
289
374
|
text: textParts.join('\n\n')
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/email-render.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 * Email render pipeline (AGL-348): converts a designed email screen's\n * node map into email-client-compatible HTML (600px table layout,\n * inlined styles, bulletproof buttons) plus a plain-text alternative.\n * Pure — no I/O; callers resolve products/merge data first.\n *\n * Merge tokens ({{contact.firstName}}, {{unsubscribeUrl}}, …) substitute\n * from the provided map; unknown tokens are left in place so a missing\n * field is visible in test sends instead of silently blank.\n *\n * ## The safety contract, in one place\n *\n * Everything this emits is author-controlled, and it is emitted into mail\n * that has left the building. Three rules, all applied here rather than\n * left to a caller:\n *\n * 1. Block HTML goes through {@link EmailRenderOptions.sanitize}, which is\n * REQUIRED. There is no identity default to fall back into.\n * 2. Every URL is scheme-checked against `@aglyn/shared-util-http` before it\n * is escaped into an attribute — escaping stops an attribute breakout and\n * says nothing at all about `javascript:`.\n * 3. Merge substitution into a URL cannot draw from the `contact.` namespace,\n * so no template can route a recipient's own address into a query string.\n *\n * Mail clients strip most of this themselves, so none of the three is the\n * last line of defense in an inbox. They are the only line the moment one of\n * these documents renders outside one — a \"view this email in your browser\"\n * page is the ordinary way that happens.\n */\n\nimport {\n hasSafeLinkScheme,\n hasSafeMediaScheme,\n} from '@aglyn/shared-util-http/safe-url-scheme'\n\nimport { resolveEmailMediaSrc } from './email-media-src'\n\nexport interface EmailRenderProduct {\n name: string\n priceLabel?: string\n imageUrl?: string\n url?: string\n}\n\nexport interface EmailRenderNode {\n componentId?: string\n props?: Record<string, any>\n nodes?: string[]\n}\n\nexport interface EmailRenderOptions {\n /** Flat node map (screen version `nodes`). */\n nodes: Record<string, EmailRenderNode | undefined>\n /** Root node id (defaults to 'root'). */\n rootId?: string\n subject?: string\n /** Hidden preview line shown next to the subject in inboxes. */\n preheader?: string\n /** Merge values keyed by token body, e.g. 'contact.firstName'. */\n merge?: Record<string, string>\n /** Product data by id for emailProduct blocks. */\n products?: Record<string, EmailRenderProduct | undefined>\n /**\n * The HTML policy applied to every `emailRichtext` / `emailHtml` block.\n *\n * REQUIRED, and required is the point. A policy a caller may omit is a\n * policy that gets omitted, and an omitted one here is not a rendering\n * bug: the console renders these same nodes through `sanitizeCustomHtml`\n * before an author ever sees them, so an unpoliced mail render means two\n * renderers of one document disagreeing about safety, with the unsafe one\n * being the one that sends.\n *\n * This module cannot pick the policy for itself — it is `scope:shared` and\n * the sanitizer is aglyn-scoped, which nx forbids it to import — so the\n * choice belongs to the caller, and the type is what makes the caller make\n * it. Every send path passes `sanitizeAuthorHtml`, which is exactly what\n * `sanitizeCustomHtml` delegates to, so the mailed copy and the previewed\n * copy are the same function of the same string.\n */\n sanitize: (html: string) => string\n /**\n * Absolute origin serving this email's media, e.g. `https://acme.com`\n * (AGL-1224).\n *\n * An image an author picked with \"Browse media\" is stored as a\n * `media:{scope}/{mediaId}` reference, which resolves to the SITE-RELATIVE\n * CDN path `/api/media/cdn/…`. A browser has a page to resolve that\n * against; an inbox has nothing. Without this, every picked image is a\n * broken-image box in the delivered mail.\n *\n * It is an input rather than a lookup because this module is pure, and\n * because the CDN route is mounted in BOTH the console and the tenant app\n * — which origin is correct depends on whose email this is.\n */\n mediaOrigin?: string\n /**\n * Host doc id of the site sending, so an `org:`-scoped reference is\n * host-qualified. The CDN is unauthenticated and decides from the URL\n * alone, so an org asset restricted to particular sites is only served\n * through the qualified form.\n */\n mediaHostId?: string\n /**\n * Absolute URL of the sender's EMAIL logo, prepended as a centred row at\n * the top of the 600px table (AGL-2139).\n *\n * `emailLogoUrl` was a first-class field of `OrgBrandingProfile`, resolved,\n * collected in the branding editor, https-validated and persisted — and read\n * at ZERO render sites. An agency admin on the tier that costs the most\n * filled it in, the form saved, the value round-tripped, and it appeared in\n * no email ever. This is the consumer that makes it a live capability\n * rather than a stored string.\n *\n * Absent or blank emits NOTHING — not an empty row, not a spacer. An email\n * with a gap where a logo should be reads as broken; one without a logo\n * reads as plain, which is the correct appearance for an org that has not\n * set one.\n */\n brandLogoUrl?: string\n}\n\nexport interface RenderedEmail {\n html: string\n text: string\n}\n\nconst FONT = 'Helvetica, Arial, sans-serif'\n\nexport function escapeEmailHtml(value: string): string {\n return value\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n}\n\n/** Substitutes {{token}} occurrences; unknown tokens stay visible. */\nexport function substituteMergeTokens(\n value: string,\n merge: Record<string, string> | undefined,\n): string {\n return value.replace(/\\{\\{\\s*([\\w.]+)\\s*\\}\\}/g, (match, token: string) => {\n const replacement = merge?.[token]\n return replacement !== undefined ? replacement : match\n })\n}\n\nconst TEXT_STYLES: Record<string, string> = {\n heading: `font-size:28px;font-weight:700;line-height:1.25`,\n subheading: `font-size:20px;font-weight:600;line-height:1.3`,\n body: `font-size:15px;font-weight:400;line-height:1.55`,\n caption: `font-size:12px;font-weight:400;line-height:1.4`,\n}\n\n/** One full-width table row wrapping arbitrary cell HTML. */\nconst row = (cellHtml: string, cellStyle = ''): string =>\n `<tr><td style=\"${cellStyle}\">${cellHtml}</td></tr>`\n\n/**\n * The id the besigner roots every stored node map at — `CANVAS_ROOT_ELEMENT_ID`\n * in `@aglyn/aglyn`. `renderEmailHtml` still defaults `rootId` to `'root'` for\n * ad-hoc callers, but anything rendering a real besigner document MUST pass\n * this: rendering a besigner map as `'root'` finds no root and emits nothing\n * (AGL-765). Kept here so server code need not import the heavy `@aglyn/aglyn`\n * barrel; a drift guard in the console specs asserts the two stay equal.\n */\nexport const EMAIL_NODE_ROOT_ID = '_@_'\n\nexport function renderEmailHtml(options: EmailRenderOptions): RenderedEmail {\n const {\n nodes,\n rootId = 'root',\n subject = '',\n preheader = '',\n merge,\n products,\n sanitize,\n mediaOrigin,\n mediaHostId,\n brandLogoUrl,\n } = options\n\n const textParts: string[] = []\n const sub = (value: unknown): string =>\n substituteMergeTokens(String(value ?? ''), merge)\n\n /**\n * The merge map as it applies inside a URL: everything except the\n * `contact.` namespace.\n *\n * That namespace is the recipient — `contact.email`, `contact.name`,\n * `contact.firstName` are what campaign-send puts in the map. Substituting\n * them into an `href` or a `src` is how a template like\n * `https://example.test/?e={{contact.email}}` turns every send into the\n * recipient's own address on somebody's query string, and putting personal\n * data in a URL is a line this codebase does not cross. The refusal has to\n * live here because it is the SUBSTITUTION that creates the value: no\n * scheme check and no sanitizer can see the address, since the template\n * the author wrote and the editor reviewed contains only the token.\n *\n * Nothing else narrows: `{{unsubscribeUrl}}` and `{{site.url}}` ARE URLs\n * and are the reason a token appears in this position at all.\n *\n * A refused token is left standing rather than blanked, which is this\n * module's existing convention for a token it will not fill — a test send\n * shows `?e={{contact.email}}` in the link, which names the problem to the\n * author instead of silently shipping a subtly different URL.\n */\n const urlMerge = merge\n ? Object.fromEntries(\n Object.entries(merge).filter(\n ([token]) => !token.startsWith('contact.'),\n ),\n )\n : undefined\n\n /** {@link sub} for a value that lands in a URL — see {@link urlMerge}. */\n const subUrl = (value: unknown): string =>\n substituteMergeTokens(String(value ?? ''), urlMerge)\n\n /**\n * A link target safe to emit, or undefined.\n *\n * Escaping the value into the attribute stops a breakout and says nothing\n * about the scheme, so `escapeEmailHtml` on its own passes a stored\n * `javascript:` or `data:` href through verbatim. The check runs on the\n * SUBSTITUTED string, because a merge value is part of the final URL and a\n * template that reads clean can resolve to one that does not.\n */\n const linkHref = (value: unknown): string | undefined => {\n const href = subUrl(value)\n if (!href) return undefined\n return hasSafeLinkScheme(href) ? href : undefined\n }\n\n const origin = mediaOrigin?.replace(/\\/+$/, '')\n\n /**\n * A stored image value turned into something an inbox can actually fetch,\n * or undefined (AGL-1224).\n *\n * `resolveEmailMediaSrc` handles the three stored generations — a `media:`\n * reference, the AGL-175 relative CDN path, and a plain absolute URL an\n * author typed — but the first two come back site-relative, which is the\n * whole bug: only a browser has a page to resolve them against.\n *\n * With no origin to absolutize against, the image is DROPPED rather than\n * emitted relative. Both are a missing picture; a dropped one leaves a gap,\n * while `src=\"/api/media/cdn/…\"` renders as a broken-image box, which reads\n * to a recipient as a broken email rather than a plain one.\n *\n * ⚠️ This comment used to claim \"neither send path relies on this — both\n * supply an origin\", and treated reaching the drop as hypothetical. It was\n * wrong, and it was wrong in the direction that hides the bug: campaign-send\n * passed no `mediaOrigin`/`mediaHostId` at all, so every author-picked image\n * was silently dropped from every marketing campaign until AGL-1394. The\n * quiet failure IS still the right one for a caller that genuinely has no\n * origin — but \"no caller reaches this\" is a claim about the whole repo, and\n * it decays the moment someone adds a third send path. Verify it before\n * relying on it; do not restore a count here.\n *\n * A protocol-relative `//host/x.png` is passed through untouched: it is\n * already absolute enough to name a host, and prefixing an origin would\n * corrupt it.\n *\n * The scheme is checked on the way out. `resolveEmailMediaSrc` recognizes\n * the two stored reference forms and passes anything else through as an\n * author-typed URL, so it is this check and nothing else that stands\n * between what an author types and the `src` attribute. A refusal drops\n * the image on the same reasoning as everything else here that cannot be\n * made fetchable: a gap reads as a plain email, and a `src` we would not\n * stand behind is not made safer by shipping it.\n */\n const imageSrc = (value: unknown): string | undefined => {\n const resolved = resolveEmailMediaSrc(subUrl(value), mediaHostId)\n if (!resolved || !hasSafeMediaScheme(resolved)) return undefined\n if (!resolved.startsWith('/') || resolved.startsWith('//')) return resolved\n return origin ? `${origin}${resolved}` : undefined\n }\n\n const renderChildren = (ids: string[] | undefined): string =>\n (ids ?? [])\n .map((id) => renderNode(id))\n .filter(Boolean)\n .join('')\n\n const renderNode = (id: string): string => {\n const node = nodes[id]\n if (!node) return ''\n const props = node.props ?? {}\n switch (node.componentId) {\n case 'emailSection': {\n const background = props.backgroundColor || '#ffffff'\n const padding = Number.isFinite(Number(props.padding))\n ? Number(props.padding)\n : 24\n const align = props.align || 'left'\n return (\n `<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" ` +\n `style=\"background-color:${background};\">` +\n row(\n `<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\">` +\n renderChildren(node.nodes) +\n `</table>`,\n `padding:${padding}px;text-align:${align};`,\n ) +\n `</table>`\n )\n }\n case 'emailText': {\n const text = sub(props.children)\n textParts.push(text)\n const style = TEXT_STYLES[props.variant as string] ?? TEXT_STYLES['body']\n const color = props.color || '#1a1a1a'\n const align = props.align || 'left'\n return row(\n `<div style=\"font-family:${FONT};${style};color:${color};text-align:${align};\">` +\n escapeEmailHtml(text).replace(/\\n/g, '<br />') +\n `</div>`,\n 'padding:4px 0;',\n )\n }\n case 'emailRichtext':\n case 'emailHtml': {\n const html = sanitize(sub(props.html))\n if (!html.trim()) return ''\n textParts.push(html.replace(/<[^>]+>/g, ' ').trim())\n return row(\n `<div style=\"font-family:${FONT};font-size:15px;line-height:1.55;\">${html}</div>`,\n 'padding:4px 0;',\n )\n }\n case 'emailImage': {\n const src = imageSrc(props.src)\n if (!src) return ''\n const width = Number(props.width) > 0 ? Number(props.width) : 600\n const alt = escapeEmailHtml(String(props.alt ?? ''))\n const align = props.align || 'center'\n const img =\n `<img src=\"${escapeEmailHtml(src)}\" alt=\"${alt}\" width=\"${Math.min(width, 600)}\" ` +\n `style=\"display:inline-block;max-width:100%;height:auto;border:0;\" />`\n // A refused href drops the WRAPPER, not the picture: the image is the\n // content and the link is decoration on it.\n const href = linkHref(props.href)\n return row(\n href\n ? `<a href=\"${escapeEmailHtml(href)}\" target=\"_blank\">${img}</a>`\n : img,\n `padding:8px 0;text-align:${align};`,\n )\n }\n case 'emailButton': {\n const label = sub(props.children ?? 'Call to action')\n // A refused href falls back to the placeholder the empty case already\n // uses, so the button keeps its place in the layout and goes nowhere.\n // Dropping the row instead would take the author's copy with it, and\n // an inert button reads as an unfinished email rather than a broken\n // one. The plain-text alternative gets the same value the anchor\n // does — a text/plain body is inert, but it is still delivered mail\n // and a refused URL does not belong in it either.\n const href = linkHref(props.href ?? '#') ?? '#'\n const background = props.backgroundColor || '#1a73e8'\n const color = props.color || '#ffffff'\n const align = props.align || 'center'\n textParts.push(`${label}: ${href}`)\n // Bulletproof-ish button: padded anchor, table-aligned.\n return row(\n `<a href=\"${escapeEmailHtml(href)}\" target=\"_blank\" ` +\n `style=\"display:inline-block;padding:12px 28px;border-radius:6px;` +\n `background-color:${background};color:${color};font-family:${FONT};` +\n `font-size:15px;font-weight:600;text-decoration:none;\">` +\n escapeEmailHtml(label) +\n `</a>`,\n `padding:12px 0;text-align:${align};`,\n )\n }\n case 'emailDivider': {\n const color = props.color || '#e0e0e0'\n return row(\n `<div style=\"border-top:1px solid ${color};font-size:0;line-height:0;\"> </div>`,\n 'padding:12px 0;',\n )\n }\n case 'emailSpacer': {\n const height = Number(props.height) > 0 ? Number(props.height) : 24\n return row(\n `<div style=\"height:${height}px;font-size:0;line-height:0;\"> </div>`,\n )\n }\n case 'emailProduct': {\n const product = props.productId\n ? products?.[String(props.productId)]\n : undefined\n if (!product) return ''\n const label = sub(props.buttonLabel ?? 'Shop now')\n // Catalog data rather than author markup, and the caller absolutizes\n // it before it arrives — but \"it came from a trusted table\" is a\n // claim about every writer of that table, so the same rule applies\n // here as to an author-typed href. Refused means no button and no\n // line in the text part; the product card itself still renders.\n const productUrl =\n product.url && hasSafeLinkScheme(product.url)\n ? product.url\n : undefined\n textParts.push(\n `${product.name}${product.priceLabel ? ` — ${product.priceLabel}` : ''}` +\n (productUrl ? `: ${productUrl}` : ''),\n )\n // Same treatment as emailImage: a catalog image can be a picked\n // asset, so it carries the same reference/relative forms (AGL-1224).\n const productImage = imageSrc(product.imageUrl)\n const image = productImage\n ? `<img src=\"${escapeEmailHtml(productImage)}\" alt=\"${escapeEmailHtml(product.name)}\" width=\"280\" style=\"max-width:100%;height:auto;border:0;border-radius:6px;\" /><br />`\n : ''\n const button = productUrl\n ? `<a href=\"${escapeEmailHtml(productUrl)}\" target=\"_blank\" style=\"display:inline-block;margin-top:8px;padding:10px 24px;border-radius:6px;background-color:#1a73e8;color:#ffffff;font-family:${FONT};font-size:14px;font-weight:600;text-decoration:none;\">${escapeEmailHtml(label)}</a>`\n : ''\n return row(\n `<div style=\"border:1px solid #e0e0e0;border-radius:8px;padding:16px;text-align:center;font-family:${FONT};\">` +\n image +\n `<div style=\"font-size:16px;font-weight:600;margin-top:8px;\">${escapeEmailHtml(product.name)}</div>` +\n (product.priceLabel\n ? `<div style=\"font-size:14px;color:#555555;margin-top:2px;\">${escapeEmailHtml(product.priceLabel)}</div>`\n : '') +\n button +\n `</div>`,\n 'padding:8px 0;',\n )\n }\n default: {\n // Unknown/web components: render their children so mixed documents\n // degrade gracefully instead of dropping content.\n return renderChildren(node.nodes)\n }\n }\n }\n\n const body = renderNode(rootId) || renderChildren(nodes[rootId]?.nodes)\n const preheaderHtml = preheader\n ? `<div style=\"display:none;max-height:0;overflow:hidden;mso-hide:all;\">` +\n escapeEmailHtml(sub(preheader)) +\n `</div>`\n : ''\n\n // INSIDE the 600px table, above the designed body, so it inherits the\n // column's width on a phone instead of being centred against the viewport.\n // `alt` is the product name where one is known, because a logo blocked by\n // the client (which is the default in most inboxes) must still say who sent\n // the mail. Height is capped rather than set, so a wordmark and a square\n // mark both land at a sane size without the sender supplying dimensions.\n //\n // The src goes through `imageSrc` like every other image in the message\n // (AGL-2230), and NOT into the tag raw. `emailLogoUrl` is collected by\n // `MediaUrlField`, whose \"Browse\" button writes the picked asset's\n // site-relative `cdnPath` — and the org DAM's other generation stores a\n // `media:{scope}/{mediaId}` reference. Neither is a URL an inbox can\n // resolve: there is no page to resolve it against, so a raw pass-through\n // put `src=\"media:h1/med9\"` at the top of every transactional email a\n // white-label org sent. `imageSrc` absolutizes both forms against\n // `mediaOrigin`, passes a real https URL through untouched, and returns\n // undefined when it cannot produce something fetchable — in which case the\n // row is dropped, on the same reasoning as AGL-1224: a gap reads as a plain\n // email, a broken-image box reads as a broken one.\n const brandLogo = imageSrc(String(brandLogoUrl ?? '').trim()) ?? ''\n // The product name from the merge map, because most inboxes block images by\n // default — a logo with no `alt` is a blank box where the sender's identity\n // should be. Falls back to nothing rather than to a literal, which would\n // reintroduce the hard-coded brand this whole change removes.\n const brandLogoAlt = merge?.['brand.productName'] ?? ''\n const brandLogoHtml = brandLogo\n ? row(\n `<img src=\"${escapeEmailHtml(brandLogo)}\" alt=\"${escapeEmailHtml(brandLogoAlt)}\" ` +\n `style=\"max-height:48px;max-width:200px;display:block;margin:0 auto;border:0;\" />`,\n 'padding:24px 24px 0;text-align:center;',\n )\n : ''\n\n const html =\n `<!DOCTYPE html><html><head><meta charset=\"utf-8\" />` +\n `<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />` +\n `<title>${escapeEmailHtml(sub(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\" style=\"background-color:#f4f4f4;\">` +\n row(\n `<table role=\"presentation\" width=\"600\" cellpadding=\"0\" cellspacing=\"0\" align=\"center\" style=\"max-width:600px;width:100%;margin:0 auto;\">` +\n brandLogoHtml +\n body +\n `</table>`,\n 'padding:24px 8px;',\n ) +\n `</table></body></html>`\n\n return { html, text: textParts.join('\\n\\n') }\n}\n"],"names":["hasSafeLinkScheme","hasSafeMediaScheme","resolveEmailMediaSrc","FONT","escapeEmailHtml","value","replace","substituteMergeTokens","merge","match","token","replacement","undefined","TEXT_STYLES","heading","subheading","body","caption","row","cellHtml","cellStyle","EMAIL_NODE_ROOT_ID","renderEmailHtml","options","imageSrc","nodes","rootId","subject","preheader","products","sanitize","mediaOrigin","mediaHostId","brandLogoUrl","textParts","sub","String","urlMerge","Object","fromEntries","entries","filter","startsWith","subUrl","linkHref","href","origin","resolved","renderChildren","ids","map","id","renderNode","Boolean","join","node","props","componentId","background","backgroundColor","padding","Number","isFinite","align","text","children","push","style","variant","color","html","trim","src","width","alt","img","Math","min","label","height","product","productId","buttonLabel","productUrl","url","name","priceLabel","productImage","imageUrl","image","button","preheaderHtml","brandLogo","brandLogoAlt","brandLogoHtml"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/email-render.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 * Email render pipeline (AGL-348): converts a designed email screen's\n * node map into email-client-compatible HTML (600px table layout,\n * inlined styles, bulletproof buttons) plus a plain-text alternative.\n * Pure — no I/O; callers resolve products/merge data first.\n *\n * Merge tokens ({{contact.firstName}}, {{unsubscribeUrl}}, …) substitute\n * from the provided map; unknown tokens are left in place so a missing\n * field is visible in test sends instead of silently blank.\n *\n * ## The safety contract, in one place\n *\n * Everything this emits is author-controlled, and it is emitted into mail\n * that has left the building. Three rules, all applied here rather than\n * left to a caller:\n *\n * 1. Block HTML goes through {@link EmailRenderOptions.sanitize}, which is\n * REQUIRED. There is no identity default to fall back into.\n * 2. Every URL is scheme-checked against `@aglyn/shared-util-http` before it\n * is escaped into an attribute — escaping stops an attribute breakout and\n * says nothing at all about `javascript:`.\n * 3. Merge substitution into a URL cannot draw from the `contact.` namespace,\n * so no template can route a recipient's own address into a query string.\n *\n * Mail clients strip most of this themselves, so none of the three is the\n * last line of defense in an inbox. They are the only line the moment one of\n * these documents renders outside one — a \"view this email in your browser\"\n * page is the ordinary way that happens.\n */\n\nimport {\n hasSafeLinkScheme,\n hasSafeMediaScheme,\n} from '@aglyn/shared-util-http/safe-url-scheme'\n\nimport { resolveEmailMediaSrc } from './email-media-src'\n\nexport interface EmailRenderProduct {\n name: string\n priceLabel?: string\n imageUrl?: string\n url?: string\n}\n\nexport interface EmailRenderNode {\n componentId?: string\n props?: Record<string, any>\n nodes?: string[]\n}\n\nexport interface EmailRenderOptions {\n /** Flat node map (screen version `nodes`). */\n nodes: Record<string, EmailRenderNode | undefined>\n /** Root node id (defaults to 'root'). */\n rootId?: string\n subject?: string\n /** Hidden preview line shown next to the subject in inboxes. */\n preheader?: string\n /** Merge values keyed by token body, e.g. 'contact.firstName'. */\n merge?: Record<string, string>\n /** Product data by id for emailProduct blocks. */\n products?: Record<string, EmailRenderProduct | undefined>\n /**\n * The HTML policy applied to every `emailRichtext` / `emailHtml` block.\n *\n * REQUIRED, and required is the point. A policy a caller may omit is a\n * policy that gets omitted, and an omitted one here is not a rendering\n * bug: the console renders these same nodes through `sanitizeCustomHtml`\n * before an author ever sees them, so an unpoliced mail render means two\n * renderers of one document disagreeing about safety, with the unsafe one\n * being the one that sends.\n *\n * This module cannot pick the policy for itself — it is `scope:shared` and\n * the sanitizer is aglyn-scoped, which nx forbids it to import — so the\n * choice belongs to the caller, and the type is what makes the caller make\n * it. Every send path passes `sanitizeAuthorHtml`, which is exactly what\n * `sanitizeCustomHtml` delegates to, so the mailed copy and the previewed\n * copy are the same function of the same string.\n */\n sanitize: (html: string) => string\n /**\n * Absolute origin serving this email's media, e.g. `https://acme.com`\n * (AGL-1224).\n *\n * An image an author picked with \"Browse media\" is stored as a\n * `media:{scope}/{mediaId}` reference, which resolves to the SITE-RELATIVE\n * CDN path `/api/media/cdn/…`. A browser has a page to resolve that\n * against; an inbox has nothing. Without this, every picked image is a\n * broken-image box in the delivered mail.\n *\n * It is an input rather than a lookup because this module is pure, and\n * because the CDN route is mounted in BOTH the console and the tenant app\n * — which origin is correct depends on whose email this is.\n */\n mediaOrigin?: string\n /**\n * Host doc id of the site sending, so an `org:`-scoped reference is\n * host-qualified. The CDN is unauthenticated and decides from the URL\n * alone, so an org asset restricted to particular sites is only served\n * through the qualified form.\n */\n mediaHostId?: string\n /**\n * Absolute URL of the sender's EMAIL logo, prepended as a centred row at\n * the top of the 600px table (AGL-2139).\n *\n * `emailLogoUrl` was a first-class field of `OrgBrandingProfile`, resolved,\n * collected in the branding editor, https-validated and persisted — and read\n * at ZERO render sites. An agency admin on the tier that costs the most\n * filled it in, the form saved, the value round-tripped, and it appeared in\n * no email ever. This is the consumer that makes it a live capability\n * rather than a stored string.\n *\n * Absent or blank emits NOTHING — not an empty row, not a spacer. An email\n * with a gap where a logo should be reads as broken; one without a logo\n * reads as plain, which is the correct appearance for an org that has not\n * set one.\n *\n * Ignored when {@link EmailRenderOptions.chrome} carries a header: that\n * header is the logo row, and a caller that has one passes the org's logo\n * through it, so a message never opens with two.\n */\n brandLogoUrl?: string\n /**\n * The sender's header and footer, drawn around the body (AGL-3322). See\n * {@link EmailChrome}. Absent draws neither.\n */\n chrome?: EmailChrome\n}\n\n/**\n * The row above the body that says who sent the mail.\n *\n * `logoAlt` is required because it does two jobs. It is the logo's `alt`,\n * which is what most inboxes show, since they block images by default. And\n * it is the header itself when there is no logo to draw — none was given, or\n * the one given cannot be fetched from an inbox — so the sender is named in\n * bold text rather than by a broken-image box.\n */\nexport interface EmailChromeHeader {\n /** The logo. Resolved and scheme-checked like every other image here. */\n logoUrl?: string\n /** The sender's name: the logo's alt text, or the header when no logo draws. */\n logoAlt: string\n /** Where the logo or name links. A refused scheme drops the link, not the header. */\n href?: string\n}\n\n/**\n * The rows under the body, in the order they draw. Each is optional, and a\n * footer with none of them draws nothing.\n */\nexport interface EmailChromeFooter {\n /** Why the recipient is getting this mail. */\n reason?: string\n /** Where to get help. Drawn as a link; dropped whole when the href is refused. */\n support?: { label: string; href: string }\n /** Who sent it, and from where: a copyright and postal line. */\n legal?: string\n}\n\n/**\n * A sender's header and footer, drawn by the renderer rather than placed by a\n * designer (AGL-3322).\n *\n * For mail whose body carries no header or footer of its own: a system\n * email's built-in copy, or a design sent under a brand whose own blocks it\n * may not carry. The caller decides WHICH brand; this only draws.\n *\n * Every string goes through the same merge substitution as the body, so\n * `{{org.name}}` in a footer line reads as the organization it names, and is\n * escaped like body text. Every URL goes through the same scheme checks as a\n * body link or image. The footer lines are appended to the plain-text part\n * too, the support link as its bare address, because a reader of that part is\n * owed the same reason and the same way to get help.\n */\nexport interface EmailChrome {\n header?: EmailChromeHeader\n footer?: EmailChromeFooter\n}\n\nexport interface RenderedEmail {\n html: string\n text: string\n}\n\nconst FONT = 'Helvetica, Arial, sans-serif'\n\n/**\n * The colours the chrome is drawn in (AGL-3322), named once.\n *\n * Literal because this is email HTML: mail clients strip `<style>` and read no\n * CSS variables, so no theme token reaches the wire. They are the values\n * already in the mail, not new ones — the ink and card of a default body\n * block, and the band and caption grey of the email footer block the\n * platform's own mail wears — so the chrome and the body it frames agree.\n */\nconst CHROME_PALETTE = {\n /** The sender's name in the header: body text's ink. */\n ink: '#1a1a1a',\n /** The header band: a default section's card. */\n card: '#ffffff',\n /** The footer band. */\n band: '#F8F9FA',\n /** Footer lines and the support link. */\n caption: '#757575',\n} as const\n\nexport function escapeEmailHtml(value: string): string {\n return value\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n}\n\n/** Substitutes {{token}} occurrences; unknown tokens stay visible. */\nexport function substituteMergeTokens(\n value: string,\n merge: Record<string, string> | undefined,\n): string {\n return value.replace(/\\{\\{\\s*([\\w.]+)\\s*\\}\\}/g, (match, token: string) => {\n const replacement = merge?.[token]\n return replacement !== undefined ? replacement : match\n })\n}\n\nconst TEXT_STYLES: Record<string, string> = {\n heading: `font-size:28px;font-weight:700;line-height:1.25`,\n subheading: `font-size:20px;font-weight:600;line-height:1.3`,\n body: `font-size:15px;font-weight:400;line-height:1.55`,\n caption: `font-size:12px;font-weight:400;line-height:1.4`,\n}\n\n/** One full-width table row wrapping arbitrary cell HTML. */\nconst row = (cellHtml: string, cellStyle = ''): string =>\n `<tr><td style=\"${cellStyle}\">${cellHtml}</td></tr>`\n\n/**\n * The id the besigner roots every stored node map at — `CANVAS_ROOT_ELEMENT_ID`\n * in `@aglyn/aglyn`. `renderEmailHtml` still defaults `rootId` to `'root'` for\n * ad-hoc callers, but anything rendering a real besigner document MUST pass\n * this: rendering a besigner map as `'root'` finds no root and emits nothing\n * (AGL-765). Kept here so server code need not import the heavy `@aglyn/aglyn`\n * barrel; a drift guard in the console specs asserts the two stay equal.\n */\nexport const EMAIL_NODE_ROOT_ID = '_@_'\n\nexport function renderEmailHtml(options: EmailRenderOptions): RenderedEmail {\n const {\n nodes,\n rootId = 'root',\n subject = '',\n preheader = '',\n merge,\n products,\n sanitize,\n mediaOrigin,\n mediaHostId,\n brandLogoUrl,\n chrome,\n } = options\n\n const textParts: string[] = []\n const sub = (value: unknown): string =>\n substituteMergeTokens(String(value ?? ''), merge)\n\n /**\n * The merge map as it applies inside a URL: everything except the\n * `contact.` namespace.\n *\n * That namespace is the recipient — `contact.email`, `contact.name`,\n * `contact.firstName` are what campaign-send puts in the map. Substituting\n * them into an `href` or a `src` is how a template like\n * `https://example.test/?e={{contact.email}}` turns every send into the\n * recipient's own address on somebody's query string, and putting personal\n * data in a URL is a line this codebase does not cross. The refusal has to\n * live here because it is the SUBSTITUTION that creates the value: no\n * scheme check and no sanitizer can see the address, since the template\n * the author wrote and the editor reviewed contains only the token.\n *\n * Nothing else narrows: `{{unsubscribeUrl}}` and `{{site.url}}` ARE URLs\n * and are the reason a token appears in this position at all.\n *\n * A refused token is left standing rather than blanked, which is this\n * module's existing convention for a token it will not fill — a test send\n * shows `?e={{contact.email}}` in the link, which names the problem to the\n * author instead of silently shipping a subtly different URL.\n */\n const urlMerge = merge\n ? Object.fromEntries(\n Object.entries(merge).filter(\n ([token]) => !token.startsWith('contact.'),\n ),\n )\n : undefined\n\n /** {@link sub} for a value that lands in a URL — see {@link urlMerge}. */\n const subUrl = (value: unknown): string =>\n substituteMergeTokens(String(value ?? ''), urlMerge)\n\n /**\n * A link target safe to emit, or undefined.\n *\n * Escaping the value into the attribute stops a breakout and says nothing\n * about the scheme, so `escapeEmailHtml` on its own passes a stored\n * `javascript:` or `data:` href through verbatim. The check runs on the\n * SUBSTITUTED string, because a merge value is part of the final URL and a\n * template that reads clean can resolve to one that does not.\n */\n const linkHref = (value: unknown): string | undefined => {\n const href = subUrl(value)\n if (!href) return undefined\n return hasSafeLinkScheme(href) ? href : undefined\n }\n\n const origin = mediaOrigin?.replace(/\\/+$/, '')\n\n /**\n * A stored image value turned into something an inbox can actually fetch,\n * or undefined (AGL-1224).\n *\n * `resolveEmailMediaSrc` handles the three stored generations — a `media:`\n * reference, the AGL-175 relative CDN path, and a plain absolute URL an\n * author typed — but the first two come back site-relative, which is the\n * whole bug: only a browser has a page to resolve them against.\n *\n * With no origin to absolutize against, the image is DROPPED rather than\n * emitted relative. Both are a missing picture; a dropped one leaves a gap,\n * while `src=\"/api/media/cdn/…\"` renders as a broken-image box, which reads\n * to a recipient as a broken email rather than a plain one.\n *\n * ⚠️ This comment used to claim \"neither send path relies on this — both\n * supply an origin\", and treated reaching the drop as hypothetical. It was\n * wrong, and it was wrong in the direction that hides the bug: campaign-send\n * passed no `mediaOrigin`/`mediaHostId` at all, so every author-picked image\n * was silently dropped from every marketing campaign until AGL-1394. The\n * quiet failure IS still the right one for a caller that genuinely has no\n * origin — but \"no caller reaches this\" is a claim about the whole repo, and\n * it decays the moment someone adds a third send path. Verify it before\n * relying on it; do not restore a count here.\n *\n * A protocol-relative `//host/x.png` is passed through untouched: it is\n * already absolute enough to name a host, and prefixing an origin would\n * corrupt it.\n *\n * The scheme is checked on the way out. `resolveEmailMediaSrc` recognizes\n * the two stored reference forms and passes anything else through as an\n * author-typed URL, so it is this check and nothing else that stands\n * between what an author types and the `src` attribute. A refusal drops\n * the image on the same reasoning as everything else here that cannot be\n * made fetchable: a gap reads as a plain email, and a `src` we would not\n * stand behind is not made safer by shipping it.\n */\n const imageSrc = (value: unknown): string | undefined => {\n const resolved = resolveEmailMediaSrc(subUrl(value), mediaHostId)\n if (!resolved || !hasSafeMediaScheme(resolved)) return undefined\n if (!resolved.startsWith('/') || resolved.startsWith('//')) return resolved\n return origin ? `${origin}${resolved}` : undefined\n }\n\n const renderChildren = (ids: string[] | undefined): string =>\n (ids ?? [])\n .map((id) => renderNode(id))\n .filter(Boolean)\n .join('')\n\n const renderNode = (id: string): string => {\n const node = nodes[id]\n if (!node) return ''\n const props = node.props ?? {}\n switch (node.componentId) {\n case 'emailSection': {\n const background = props.backgroundColor || '#ffffff'\n const padding = Number.isFinite(Number(props.padding))\n ? Number(props.padding)\n : 24\n const align = props.align || 'left'\n // A ROW, like every other block, with the band's table inside its\n // cell. Whatever encloses a section is a table — the 600px column, or\n // another section's inner table — and a `<table>` placed straight into\n // a table's rows is not a child an HTML parser keeps there: it closes\n // the enclosing table and lands after it. Emitted bare, a section would\n // draw outside the column, as wide as the viewport rather than the\n // 600px the editor shows, and take every row after it out too.\n return row(\n `<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" ` +\n `style=\"background-color:${background};\">` +\n row(\n `<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\">` +\n renderChildren(node.nodes) +\n `</table>`,\n `padding:${padding}px;text-align:${align};`,\n ) +\n `</table>`,\n )\n }\n case 'emailText': {\n const text = sub(props.children)\n textParts.push(text)\n const style = TEXT_STYLES[props.variant as string] ?? TEXT_STYLES['body']\n const color = props.color || '#1a1a1a'\n const align = props.align || 'left'\n return row(\n `<div style=\"font-family:${FONT};${style};color:${color};text-align:${align};\">` +\n escapeEmailHtml(text).replace(/\\n/g, '<br />') +\n `</div>`,\n 'padding:4px 0;',\n )\n }\n case 'emailRichtext':\n case 'emailHtml': {\n const html = sanitize(sub(props.html))\n if (!html.trim()) return ''\n textParts.push(html.replace(/<[^>]+>/g, ' ').trim())\n return row(\n `<div style=\"font-family:${FONT};font-size:15px;line-height:1.55;\">${html}</div>`,\n 'padding:4px 0;',\n )\n }\n case 'emailImage': {\n const src = imageSrc(props.src)\n if (!src) return ''\n const width = Number(props.width) > 0 ? Number(props.width) : 600\n const alt = escapeEmailHtml(String(props.alt ?? ''))\n const align = props.align || 'center'\n const img =\n `<img src=\"${escapeEmailHtml(src)}\" alt=\"${alt}\" width=\"${Math.min(width, 600)}\" ` +\n `style=\"display:inline-block;max-width:100%;height:auto;border:0;\" />`\n // A refused href drops the WRAPPER, not the picture: the image is the\n // content and the link is decoration on it.\n const href = linkHref(props.href)\n return row(\n href\n ? `<a href=\"${escapeEmailHtml(href)}\" target=\"_blank\">${img}</a>`\n : img,\n `padding:8px 0;text-align:${align};`,\n )\n }\n case 'emailButton': {\n const label = sub(props.children ?? 'Call to action')\n // A refused href falls back to the placeholder the empty case already\n // uses, so the button keeps its place in the layout and goes nowhere.\n // Dropping the row instead would take the author's copy with it, and\n // an inert button reads as an unfinished email rather than a broken\n // one. The plain-text alternative gets the same value the anchor\n // does — a text/plain body is inert, but it is still delivered mail\n // and a refused URL does not belong in it either.\n const href = linkHref(props.href ?? '#') ?? '#'\n const background = props.backgroundColor || '#1a73e8'\n const color = props.color || '#ffffff'\n const align = props.align || 'center'\n textParts.push(`${label}: ${href}`)\n // Bulletproof-ish button: padded anchor, table-aligned.\n return row(\n `<a href=\"${escapeEmailHtml(href)}\" target=\"_blank\" ` +\n `style=\"display:inline-block;padding:12px 28px;border-radius:6px;` +\n `background-color:${background};color:${color};font-family:${FONT};` +\n `font-size:15px;font-weight:600;text-decoration:none;\">` +\n escapeEmailHtml(label) +\n `</a>`,\n `padding:12px 0;text-align:${align};`,\n )\n }\n case 'emailDivider': {\n const color = props.color || '#e0e0e0'\n return row(\n `<div style=\"border-top:1px solid ${color};font-size:0;line-height:0;\"> </div>`,\n 'padding:12px 0;',\n )\n }\n case 'emailSpacer': {\n const height = Number(props.height) > 0 ? Number(props.height) : 24\n return row(\n `<div style=\"height:${height}px;font-size:0;line-height:0;\"> </div>`,\n )\n }\n case 'emailProduct': {\n const product = props.productId\n ? products?.[String(props.productId)]\n : undefined\n if (!product) return ''\n const label = sub(props.buttonLabel ?? 'Shop now')\n // Catalog data rather than author markup, and the caller absolutizes\n // it before it arrives — but \"it came from a trusted table\" is a\n // claim about every writer of that table, so the same rule applies\n // here as to an author-typed href. Refused means no button and no\n // line in the text part; the product card itself still renders.\n const productUrl =\n product.url && hasSafeLinkScheme(product.url)\n ? product.url\n : undefined\n textParts.push(\n `${product.name}${product.priceLabel ? ` — ${product.priceLabel}` : ''}` +\n (productUrl ? `: ${productUrl}` : ''),\n )\n // Same treatment as emailImage: a catalog image can be a picked\n // asset, so it carries the same reference/relative forms (AGL-1224).\n const productImage = imageSrc(product.imageUrl)\n const image = productImage\n ? `<img src=\"${escapeEmailHtml(productImage)}\" alt=\"${escapeEmailHtml(product.name)}\" width=\"280\" style=\"max-width:100%;height:auto;border:0;border-radius:6px;\" /><br />`\n : ''\n const button = productUrl\n ? `<a href=\"${escapeEmailHtml(productUrl)}\" target=\"_blank\" style=\"display:inline-block;margin-top:8px;padding:10px 24px;border-radius:6px;background-color:#1a73e8;color:#ffffff;font-family:${FONT};font-size:14px;font-weight:600;text-decoration:none;\">${escapeEmailHtml(label)}</a>`\n : ''\n return row(\n `<div style=\"border:1px solid #e0e0e0;border-radius:8px;padding:16px;text-align:center;font-family:${FONT};\">` +\n image +\n `<div style=\"font-size:16px;font-weight:600;margin-top:8px;\">${escapeEmailHtml(product.name)}</div>` +\n (product.priceLabel\n ? `<div style=\"font-size:14px;color:#555555;margin-top:2px;\">${escapeEmailHtml(product.priceLabel)}</div>`\n : '') +\n button +\n `</div>`,\n 'padding:8px 0;',\n )\n }\n default: {\n // Unknown/web components: render their children so mixed documents\n // degrade gracefully instead of dropping content.\n return renderChildren(node.nodes)\n }\n }\n }\n\n const body = renderNode(rootId) || renderChildren(nodes[rootId]?.nodes)\n const preheaderHtml = preheader\n ? `<div style=\"display:none;max-height:0;overflow:hidden;mso-hide:all;\">` +\n escapeEmailHtml(sub(preheader)) +\n `</div>`\n : ''\n\n // INSIDE the 600px table, above the designed body, so it inherits the\n // column's width on a phone instead of being centred against the viewport.\n // `alt` is the product name where one is known, because a logo blocked by\n // the client (which is the default in most inboxes) must still say who sent\n // the mail. Height is capped rather than set, so a wordmark and a square\n // mark both land at a sane size without the sender supplying dimensions.\n //\n // The src goes through `imageSrc` like every other image in the message\n // (AGL-2230), and NOT into the tag raw. `emailLogoUrl` is collected by\n // `MediaUrlField`, whose \"Browse\" button writes the picked asset's\n // site-relative `cdnPath` — and the org DAM's other generation stores a\n // `media:{scope}/{mediaId}` reference. Neither is a URL an inbox can\n // resolve: there is no page to resolve it against, so a raw pass-through\n // put `src=\"media:h1/med9\"` at the top of every transactional email a\n // white-label org sent. `imageSrc` absolutizes both forms against\n // `mediaOrigin`, passes a real https URL through untouched, and returns\n // undefined when it cannot produce something fetchable — in which case the\n // row is dropped, on the same reasoning as AGL-1224: a gap reads as a plain\n // email, a broken-image box reads as a broken one.\n const brandLogo = imageSrc(String(brandLogoUrl ?? '').trim()) ?? ''\n // The product name from the merge map, because most inboxes block images by\n // default — a logo with no `alt` is a blank box where the sender's identity\n // should be. Falls back to nothing rather than to a literal, which would\n // reintroduce the hard-coded brand this whole change removes.\n const brandLogoAlt = merge?.['brand.productName'] ?? ''\n const brandLogoHtml = brandLogo\n ? row(\n `<img src=\"${escapeEmailHtml(brandLogo)}\" alt=\"${escapeEmailHtml(brandLogoAlt)}\" ` +\n `style=\"max-height:48px;max-width:200px;display:block;margin:0 auto;border:0;\" />`,\n 'padding:24px 24px 0;text-align:center;',\n )\n : ''\n\n /**\n * The chrome header row, or '' when it has nothing to draw.\n *\n * Same placement and the same `imageSrc` as the logo row above, which it\n * replaces. Height is capped at 40px rather than set, so a wordmark and a\n * square mark both land at a sane size; the `height` attribute is for the\n * desktop clients that ignore CSS `max-height` and would otherwise draw the\n * file at its own size. A logo that cannot be fetched from an inbox falls\n * back to the name in bold, never to an `<img>` that draws a broken box.\n * White, so the header and a default body read as one card.\n */\n const renderChromeHeader = (header: EmailChromeHeader): string => {\n const logo = imageSrc(String(header.logoUrl ?? '').trim())\n const name = sub(header.logoAlt).trim()\n const href = linkHref(header.href)\n const link = (inner: string, style: string): string =>\n href\n ? `<a href=\"${escapeEmailHtml(href)}\" target=\"_blank\" style=\"${style}\">${inner}</a>`\n : inner\n let mark: string\n if (logo) {\n mark = link(\n `<img src=\"${escapeEmailHtml(logo)}\" alt=\"${escapeEmailHtml(name)}\" height=\"40\" ` +\n `style=\"display:block;margin:0 auto;border:0;height:auto;width:auto;max-height:40px;max-width:200px;\" />`,\n 'text-decoration:none;',\n )\n } else if (name) {\n mark =\n `<div style=\"font-family:${FONT};font-size:20px;font-weight:700;line-height:1.3;color:${CHROME_PALETTE.ink};text-align:center;\">` +\n link(escapeEmailHtml(name), `color:${CHROME_PALETTE.ink};text-decoration:none;`) +\n `</div>`\n } else {\n return ''\n }\n return row(\n mark,\n `padding:24px 24px 0;text-align:center;background-color:${CHROME_PALETTE.card};`,\n )\n }\n\n const headerHtml = chrome?.header\n ? renderChromeHeader(chrome.header)\n : brandLogoHtml\n\n /**\n * The chrome footer: rows under the body in the look of the email footer\n * block the platform's own mail wears (a `#F8F9FA` section, padded 24,\n * centered captions), and the same lines appended to the plain-text part.\n *\n * Rendered AFTER the body on purpose: the body's blocks push their text as\n * they draw, and the footer's lines belong after all of it. A line that\n * resolves to nothing is left out rather than drawn as an empty row.\n */\n const renderChromeFooter = (footer: EmailChromeFooter): string => {\n const htmlLines: string[] = []\n const textLines: string[] = []\n const reason = sub(footer.reason).trim()\n if (reason) {\n htmlLines.push(escapeEmailHtml(reason))\n textLines.push(reason)\n }\n const supportLabel = sub(footer.support?.label).trim()\n const supportHref = linkHref(footer.support?.href)\n if (supportLabel && supportHref) {\n htmlLines.push(\n `<a href=\"${escapeEmailHtml(supportHref)}\" target=\"_blank\" ` +\n `style=\"color:${CHROME_PALETTE.caption};text-decoration:underline;\">${escapeEmailHtml(supportLabel)}</a>`,\n )\n // A plain-text reader gets the address itself, bare: a mailto's\n // mailbox, or the URL. \"Get help: https://…\" names where it goes; a\n // label that already IS the mailbox is not repeated.\n const bare = /^mailto:/i.test(supportHref)\n ? supportHref.slice('mailto:'.length).split('?')[0]\n : supportHref\n textLines.push(supportLabel === bare ? bare : `${supportLabel}: ${bare}`)\n }\n const legal = sub(footer.legal).trim()\n if (legal) {\n htmlLines.push(escapeEmailHtml(legal))\n textLines.push(legal)\n }\n if (!htmlLines.length) return ''\n textParts.push(textLines.join('\\n'))\n return row(\n htmlLines\n .map(\n (line, index) =>\n `<div style=\"font-family:${FONT};font-size:12px;font-weight:400;line-height:1.4;color:${CHROME_PALETTE.caption};` +\n `${index ? 'margin-top:8px;' : ''}\">${line.replace(/\\n/g, '<br />')}</div>`,\n )\n .join(''),\n `padding:24px;text-align:center;background-color:${CHROME_PALETTE.band};`,\n )\n }\n\n const footerHtml = chrome?.footer ? renderChromeFooter(chrome.footer) : ''\n\n const html =\n `<!DOCTYPE html><html><head><meta charset=\"utf-8\" />` +\n `<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />` +\n `<title>${escapeEmailHtml(sub(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\" style=\"background-color:#f4f4f4;\">` +\n row(\n `<table role=\"presentation\" width=\"600\" cellpadding=\"0\" cellspacing=\"0\" align=\"center\" style=\"max-width:600px;width:100%;margin:0 auto;\">` +\n headerHtml +\n body +\n footerHtml +\n `</table>`,\n 'padding:24px 8px;',\n ) +\n `</table></body></html>`\n\n return { html, text: textParts.join('\\n\\n') }\n}\n"],"names":["hasSafeLinkScheme","hasSafeMediaScheme","resolveEmailMediaSrc","FONT","CHROME_PALETTE","ink","card","band","caption","escapeEmailHtml","value","replace","substituteMergeTokens","merge","match","token","replacement","undefined","TEXT_STYLES","heading","subheading","body","row","cellHtml","cellStyle","EMAIL_NODE_ROOT_ID","renderEmailHtml","options","imageSrc","nodes","rootId","subject","preheader","products","sanitize","mediaOrigin","mediaHostId","brandLogoUrl","chrome","textParts","sub","String","urlMerge","Object","fromEntries","entries","filter","startsWith","subUrl","linkHref","href","origin","resolved","renderChildren","ids","map","id","renderNode","Boolean","join","node","props","componentId","background","backgroundColor","padding","Number","isFinite","align","text","children","push","style","variant","color","html","trim","src","width","alt","img","Math","min","label","height","product","productId","buttonLabel","productUrl","url","name","priceLabel","productImage","imageUrl","image","button","preheaderHtml","brandLogo","brandLogoAlt","brandLogoHtml","renderChromeHeader","header","logo","logoUrl","logoAlt","link","inner","mark","headerHtml","renderChromeFooter","footer","htmlLines","textLines","reason","supportLabel","support","supportHref","bare","test","slice","length","split","legal","line","index","footerHtml"],"mappings":"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"}
|
|
@@ -84,6 +84,19 @@ export interface SystemEmailTemplateDefinition {
|
|
|
84
84
|
* so it needs none. Absent → the editor seeds a minimal placeholder.
|
|
85
85
|
*/
|
|
86
86
|
defaultBody?: readonly SystemEmailDefaultBlock[];
|
|
87
|
+
/**
|
|
88
|
+
* The footer's "why am I getting this" line (AGL-3322), drawn under the
|
|
89
|
+
* built-in copy, and under any design sent without the platform's own
|
|
90
|
+
* email blocks.
|
|
91
|
+
*
|
|
92
|
+
* Written against the merge tokens, never a product name: on a white-label
|
|
93
|
+
* send `{{brand.productName}}` is the agency's, and on a renamed deployment
|
|
94
|
+
* it is the operator's, so one sentence is right for every sender. Like the
|
|
95
|
+
* body, it may only use tokens the email declares — a token the send does
|
|
96
|
+
* not supply is blanked, and leaves a hole in the sentence. Every `resend`
|
|
97
|
+
* template carries one; the others are never rendered here.
|
|
98
|
+
*/
|
|
99
|
+
footerReason?: string;
|
|
87
100
|
/**
|
|
88
101
|
* Where the fallback copy lives, for staff wondering what recipients get
|
|
89
102
|
* today. Informational — nothing reads it at runtime.
|
|
@@ -32,7 +32,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
32
32
|
/**
|
|
33
33
|
* Mirrors `PLATFORM_SUPPORT_URL`'s precedence, including the step that makes
|
|
34
34
|
* the operator identity sufficient on its own: a configured support URL, else
|
|
35
|
-
* the operator's support mailbox as a `mailto:`, else
|
|
35
|
+
* the operator's support mailbox as a `mailto:`, else the console's own
|
|
36
|
+
* support page.
|
|
36
37
|
*
|
|
37
38
|
* The last step is built from `SAMPLE_CONSOLE_ORIGIN` rather than written out
|
|
38
39
|
* (AGL-3262). The support entry point is a CONSOLE route — `/support`
|
|
@@ -144,6 +145,7 @@ const BASE_SYSTEM_EMAIL_TEMPLATES = [
|
|
|
144
145
|
href: '{{signInUrl}}'
|
|
145
146
|
}
|
|
146
147
|
],
|
|
148
|
+
footerReason: 'You’re receiving this because someone invited you to join ' + '{{org.name}} on {{brand.productName}}.',
|
|
147
149
|
source: 'apps/console/app/api/orgs/invites/route.ts'
|
|
148
150
|
},
|
|
149
151
|
{
|
|
@@ -187,6 +189,7 @@ const BASE_SYSTEM_EMAIL_TEMPLATES = [
|
|
|
187
189
|
variant: 'body'
|
|
188
190
|
}
|
|
189
191
|
],
|
|
192
|
+
footerReason: 'You’re receiving this monthly summary because you manage ' + '{{org.name}} on {{brand.productName}}.',
|
|
190
193
|
source: 'apps/console/app/api/billing/usage-email/route.ts'
|
|
191
194
|
},
|
|
192
195
|
{
|
|
@@ -220,6 +223,7 @@ const BASE_SYSTEM_EMAIL_TEMPLATES = [
|
|
|
220
223
|
variant: 'body'
|
|
221
224
|
}
|
|
222
225
|
],
|
|
226
|
+
footerReason: 'You’re receiving this because this address gets ' + '{{brand.productName}}’s staff alerts.',
|
|
223
227
|
source: 'apps/console/app/api/admin/audit-archive/route.ts'
|
|
224
228
|
},
|
|
225
229
|
{
|
|
@@ -268,6 +272,7 @@ const BASE_SYSTEM_EMAIL_TEMPLATES = [
|
|
|
268
272
|
href: '{{consoleUrl}}'
|
|
269
273
|
}
|
|
270
274
|
],
|
|
275
|
+
footerReason: 'You’re receiving this because you created an organization on ' + '{{brand.productName}}.',
|
|
271
276
|
source: 'apps/console/app/api/orgs/create/route.ts'
|
|
272
277
|
},
|
|
273
278
|
{
|
|
@@ -311,6 +316,7 @@ const BASE_SYSTEM_EMAIL_TEMPLATES = [
|
|
|
311
316
|
href: '{{signInUrl}}'
|
|
312
317
|
}
|
|
313
318
|
],
|
|
319
|
+
footerReason: 'You’re receiving this because you were added to {{org.name}} ' + 'on {{brand.productName}}.',
|
|
314
320
|
source: 'apps/console/app/api/orgs/members/route.ts'
|
|
315
321
|
},
|
|
316
322
|
{
|
|
@@ -344,6 +350,7 @@ const BASE_SYSTEM_EMAIL_TEMPLATES = [
|
|
|
344
350
|
variant: 'body'
|
|
345
351
|
}
|
|
346
352
|
],
|
|
353
|
+
footerReason: 'You’re receiving this because you asked {{brand.productName}} ' + 'to erase {{org.name}}’s data.',
|
|
347
354
|
source: 'apps/console/app/api/admin/run-erasures/route.ts'
|
|
348
355
|
},
|
|
349
356
|
{
|
|
@@ -377,6 +384,7 @@ const BASE_SYSTEM_EMAIL_TEMPLATES = [
|
|
|
377
384
|
variant: 'body'
|
|
378
385
|
}
|
|
379
386
|
],
|
|
387
|
+
footerReason: 'You’re receiving this because you own {{org.name}} on ' + '{{brand.productName}}.',
|
|
380
388
|
source: 'apps/console/app/api/admin/erasure-request/route.ts'
|
|
381
389
|
},
|
|
382
390
|
// Admin-initiated password mail (AGL-910). Unlike the self-serve
|
|
@@ -424,6 +432,7 @@ const BASE_SYSTEM_EMAIL_TEMPLATES = [
|
|
|
424
432
|
variant: 'caption'
|
|
425
433
|
}
|
|
426
434
|
],
|
|
435
|
+
footerReason: 'You’re receiving this because an administrator started a ' + 'password reset for your {{brand.productName}} account.',
|
|
427
436
|
source: 'apps/console/app/api/_lib/password-admin.ts'
|
|
428
437
|
},
|
|
429
438
|
{
|
|
@@ -467,6 +476,7 @@ const BASE_SYSTEM_EMAIL_TEMPLATES = [
|
|
|
467
476
|
variant: 'caption'
|
|
468
477
|
}
|
|
469
478
|
],
|
|
479
|
+
footerReason: 'You’re receiving this because an administrator changed the ' + 'password on your {{brand.productName}} account.',
|
|
470
480
|
source: 'apps/console/app/api/_lib/password-admin.ts'
|
|
471
481
|
},
|
|
472
482
|
// Security alerts (AGL-665). Factual and actionable: what happened, from
|
|
@@ -556,6 +566,7 @@ const BASE_SYSTEM_EMAIL_TEMPLATES = [
|
|
|
556
566
|
variant: 'caption'
|
|
557
567
|
}
|
|
558
568
|
],
|
|
569
|
+
footerReason: 'You’re receiving this because your {{brand.productName}} ' + 'account signed in from a new device.',
|
|
559
570
|
source: 'apps/console/app/api/_lib/security-alerts.ts'
|
|
560
571
|
},
|
|
561
572
|
{
|
|
@@ -609,6 +620,7 @@ const BASE_SYSTEM_EMAIL_TEMPLATES = [
|
|
|
609
620
|
variant: 'caption'
|
|
610
621
|
}
|
|
611
622
|
],
|
|
623
|
+
footerReason: 'You’re receiving this because a passkey was added to your ' + '{{brand.productName}} account.',
|
|
612
624
|
source: 'apps/console/app/api/_lib/security-alerts.ts'
|
|
613
625
|
},
|
|
614
626
|
// Aglyn composes and sends both of these now (AGL-1112). They were
|
|
@@ -658,6 +670,7 @@ const BASE_SYSTEM_EMAIL_TEMPLATES = [
|
|
|
658
670
|
variant: 'caption'
|
|
659
671
|
}
|
|
660
672
|
],
|
|
673
|
+
footerReason: 'You’re receiving this because someone asked to reset the ' + 'password on your {{brand.productName}} account.',
|
|
661
674
|
source: 'apps/console/app/api/auth/send-password-reset/route.ts'
|
|
662
675
|
},
|
|
663
676
|
{
|
|
@@ -695,8 +708,54 @@ const BASE_SYSTEM_EMAIL_TEMPLATES = [
|
|
|
695
708
|
variant: 'caption'
|
|
696
709
|
}
|
|
697
710
|
],
|
|
711
|
+
footerReason: 'You’re receiving this because this address was used to sign ' + 'up for {{brand.productName}}.',
|
|
698
712
|
source: 'apps/console/app/api/auth/send-verification/route.ts'
|
|
699
713
|
},
|
|
714
|
+
// Its own entry rather than a second use of the one above, because every
|
|
715
|
+
// line of that one is about signing up: "finish setting up your account",
|
|
716
|
+
// "if you did not create an account", a footer saying the address was
|
|
717
|
+
// used to sign up. This mail goes to an address someone is ADDING to an
|
|
718
|
+
// account that already exists, and the one thing its reader needs to hear
|
|
719
|
+
// is that ignoring it changes nothing.
|
|
720
|
+
{
|
|
721
|
+
key: 'email-address-confirmation',
|
|
722
|
+
name: 'Confirm added email',
|
|
723
|
+
description: 'Confirmation link, sent when someone adds another email address to ' + 'their account under Manage Account → Email addresses.',
|
|
724
|
+
deliveredBy: 'resend',
|
|
725
|
+
defaultSubject: 'Confirm your email address',
|
|
726
|
+
mergeTokens: [
|
|
727
|
+
{
|
|
728
|
+
name: 'confirmUrl',
|
|
729
|
+
description: 'One-time link that adds the address to the account',
|
|
730
|
+
sample: `${SAMPLE_CONSOLE_ORIGIN}/manage/user?confirmEmail=…`
|
|
731
|
+
}
|
|
732
|
+
],
|
|
733
|
+
// Mirrors the fallbackText in the route.
|
|
734
|
+
defaultBody: [
|
|
735
|
+
{
|
|
736
|
+
block: 'text',
|
|
737
|
+
text: 'Confirm this email address',
|
|
738
|
+
variant: 'heading'
|
|
739
|
+
},
|
|
740
|
+
{
|
|
741
|
+
block: 'text',
|
|
742
|
+
text: 'Confirm this address so you can use it with your ' + '{{brand.productName}} account:',
|
|
743
|
+
variant: 'body'
|
|
744
|
+
},
|
|
745
|
+
{
|
|
746
|
+
block: 'button',
|
|
747
|
+
label: 'Confirm email address',
|
|
748
|
+
href: '{{confirmUrl}}'
|
|
749
|
+
},
|
|
750
|
+
{
|
|
751
|
+
block: 'text',
|
|
752
|
+
text: 'If you did not ask to add this address, you can ignore this ' + 'email — nothing has changed and the address has not been added ' + 'to any account.',
|
|
753
|
+
variant: 'caption'
|
|
754
|
+
}
|
|
755
|
+
],
|
|
756
|
+
footerReason: 'You’re receiving this because someone asked to add this address ' + 'to a {{brand.productName}} account.',
|
|
757
|
+
source: 'apps/console/app/api/account/emails/route.ts'
|
|
758
|
+
},
|
|
700
759
|
// Stripe-delivered billing email (AGL-767). Aglyn never composes these —
|
|
701
760
|
// Stripe sends them from the Dashboard's Customer-emails and Subscription
|
|
702
761
|
// settings, so they are listed for visibility only and are not designable.
|
|
@@ -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 ours.\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 * 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 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 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 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 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 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 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 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 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 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 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 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 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 source: 'apps/console/app/api/auth/send-verification/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","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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"}
|
|
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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"}
|