@aglyn/shared-util-email 1.0.0-beta.202 → 1.0.0-beta.204

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.
Files changed (44) hide show
  1. package/package.json +3 -3
  2. package/src/index.d.ts +3 -0
  3. package/src/index.js +9 -0
  4. package/src/index.js.map +1 -1
  5. package/src/lib/email-design-chrome.d.ts +61 -0
  6. package/src/lib/email-design-chrome.js +99 -0
  7. package/src/lib/email-design-chrome.js.map +1 -0
  8. package/src/lib/email-linkify.d.ts +3 -1
  9. package/src/lib/email-linkify.js +2 -2
  10. package/src/lib/email-linkify.js.map +1 -1
  11. package/src/lib/email-palette.d.ts +137 -0
  12. package/src/lib/email-palette.js +756 -0
  13. package/src/lib/email-palette.js.map +1 -0
  14. package/src/lib/email-render.d.ts +25 -0
  15. package/src/lib/email-render.js +27 -9
  16. package/src/lib/email-render.js.map +1 -1
  17. package/src/lib/held-page.d.ts +117 -0
  18. package/src/lib/held-page.js +172 -0
  19. package/src/lib/held-page.js.map +1 -0
  20. package/src/lib/host-email-render.d.ts +34 -7
  21. package/src/lib/host-email-render.js +69 -25
  22. package/src/lib/host-email-render.js.map +1 -1
  23. package/src/lib/hosted-page-screen.js +8 -6
  24. package/src/lib/hosted-page-screen.js.map +1 -1
  25. package/src/lib/outbound-phishing-screen.d.ts +24 -0
  26. package/src/lib/outbound-phishing-screen.js +81 -2
  27. package/src/lib/outbound-phishing-screen.js.map +1 -1
  28. package/src/lib/risk-notice-catalog.d.ts +204 -0
  29. package/src/lib/risk-notice-catalog.js +1702 -0
  30. package/src/lib/risk-notice-catalog.js.map +1 -0
  31. package/src/lib/risk-notice-emails.d.ts +25 -0
  32. package/src/lib/risk-notice-emails.js +182 -0
  33. package/src/lib/risk-notice-emails.js.map +1 -0
  34. package/src/lib/sender-readiness.d.ts +155 -0
  35. package/src/lib/sender-readiness.js +432 -0
  36. package/src/lib/sender-readiness.js.map +1 -0
  37. package/src/lib/system-email-catalog.js +7 -1
  38. package/src/lib/system-email-catalog.js.map +1 -1
  39. package/src/lib/tenant-email-catalog.d.ts +7 -0
  40. package/src/lib/tenant-email-catalog.js +183 -13
  41. package/src/lib/tenant-email-catalog.js.map +1 -1
  42. package/src/lib/text-email-html.d.ts +26 -0
  43. package/src/lib/text-email-html.js +56 -1
  44. package/src/lib/text-email-html.js.map +1 -1
@@ -0,0 +1,117 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /** The two notice kinds that are about a page (the risk notice catalog's). */
18
+ export declare const PAGE_HOLD_NOTICE_KINDS: readonly ["page-held", "page-flagged"];
19
+ export type PageHoldNoticeKind = (typeof PAGE_HOLD_NOTICE_KINDS)[number];
20
+ export declare function isPageHoldNoticeKind(value: unknown): value is PageHoldNoticeKind;
21
+ /**
22
+ * What kind of page was reviewed.
23
+ *
24
+ * - `screen`: a page at an address of its own.
25
+ * - `list-template`: a collection's listing template, served at `/{slug}`.
26
+ * - `entry-template`: a template that renders every entry of a collection
27
+ * (or every product) at `/{slug}/:slug`, with no address of its own.
28
+ * - `error-screen`: a screen bound to one of the site's error slots.
29
+ * - `variant`: an experiment variant's version of a page.
30
+ */
31
+ export type HeldPageKind = 'screen' | 'list-template' | 'entry-template' | 'error-screen' | 'variant';
32
+ /**
33
+ * What visitors receive while the page is under review.
34
+ *
35
+ * - `previous-version`: the last version the page served clean.
36
+ * - `not-found`: nothing — the page had never served clean.
37
+ * - `built-in-design`: a template with no clean version: the collection's
38
+ * routes fall back to the site's built-in design for them.
39
+ * - `published-version`: a held variant: its visitors get the published page.
40
+ * - `live`: a flagged page that was already live keeps serving.
41
+ */
42
+ export type HeldPageVisitorView = 'previous-version' | 'not-found' | 'built-in-design' | 'published-version' | 'live';
43
+ /** A layout or component the flagged content came from, when not the page's own. */
44
+ export interface HeldPageSource {
45
+ type: 'layout' | 'component';
46
+ id: string;
47
+ name: string | null;
48
+ /** How many live pages render it, when that could be counted. */
49
+ pagesUsing: number | null;
50
+ }
51
+ /** One held or flagged page, described once at review time. */
52
+ export interface HeldPageSubject {
53
+ kind: HeldPageKind;
54
+ screenId: string;
55
+ versionId: string;
56
+ /** The screen's (or template's) own name. */
57
+ name: string | null;
58
+ /** The route it serves: `/about`, `/videos`, `/videos/:slug`. */
59
+ route: string | null;
60
+ /** The page's public address, when it has one of its own. */
61
+ url: string | null;
62
+ /** An entry template: the address of the entry that was being composed. */
63
+ entryUrl: string | null;
64
+ /** A template: the collection (or `Products`) it renders. */
65
+ collectionName: string | null;
66
+ /** A variant: its name in the experiment. */
67
+ variantName: string | null;
68
+ source: HeldPageSource | null;
69
+ visitorView: HeldPageVisitorView;
70
+ }
71
+ /** A console document a hold can be shown on. */
72
+ export interface HeldPageTarget {
73
+ type: 'screen' | 'layout' | 'component';
74
+ id: string;
75
+ }
76
+ /**
77
+ * The page in the owner's words, as the notice's `item.label` and every
78
+ * console surface render it: `the "About" page (/about)`,
79
+ * `the "Video detail" template (/videos/:slug)`,
80
+ * `variant "B" of the "Pricing" page (/pricing)`.
81
+ */
82
+ export declare function heldPageLabel(subject: HeldPageSubject): string;
83
+ /** The layout or component, in words: `the layout "Main", used on 12 pages`. */
84
+ export declare function heldPageSourceLabel(source: HeldPageSource): string;
85
+ /**
86
+ * The facts beside the name, one sentence each: where the entry template was
87
+ * seen and which layout or component the flagged content lives in. The owner
88
+ * surfaces and the staff row both render these.
89
+ */
90
+ export declare function heldPageDetails(subject: HeldPageSubject): string[];
91
+ /** What visitors receive meanwhile, as one sentence. */
92
+ export declare function heldPageVisitorSentence(subject: Pick<HeldPageSubject, 'visitorView'>): string;
93
+ /**
94
+ * Where the owner fixes it, in the stored-notification shape
95
+ * (`/{hostId}/…`): the layout or component the flagged content lives in, or
96
+ * the held version of the page itself.
97
+ */
98
+ export declare function heldPageConsolePath(subject: HeldPageSubject, hostId: string): string;
99
+ /** Every console document the hold is shown on: the page, and its source. */
100
+ export declare function heldPageTargets(subject: HeldPageSubject): HeldPageTarget[];
101
+ /**
102
+ * The screen and version a page notice's stored `itemPath` names, for a
103
+ * notice written before it carried a subject. Null for any other path.
104
+ */
105
+ export declare function parseHeldPageItemPath(path: string | null | undefined): {
106
+ screenId: string;
107
+ versionId: string;
108
+ } | null;
109
+ /** A hold's review state, as the owner surfaces receive it. */
110
+ export type PageHoldStatus = 'held' | 'in-review' | 'released' | 'rejected' | 'closed' | null;
111
+ /** Whether a hold is still something the owner has to see. */
112
+ export declare function isOpenPageHoldStatus(status: PageHoldStatus): boolean;
113
+ /** The status chip on a held or flagged page. There is never a release control beside it. */
114
+ export declare function pageHoldChip(kind: PageHoldNoticeKind, status: PageHoldStatus): {
115
+ label: string;
116
+ color: 'warning' | 'error' | 'info';
117
+ };
@@ -0,0 +1,172 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /*==========================================
17
+ * A HELD OR FLAGGED PAGE, NAMED (AGL-3374).
18
+ *
19
+ * The page phishing screen (`hosted-page-review.ts`) reviews a page where it
20
+ * is composed, and knows only a screen id. A screen id is not what anybody
21
+ * calls a page, and the routing map that turns one into an address does not
22
+ * hold every kind of page: a collection's entry template renders
23
+ * `/videos/{entry}` for every entry and has no address of its own, so a row
24
+ * built from the map alone named the site's home page for it.
25
+ *
26
+ * So the review describes the page ONCE, as a {@link HeldPageSubject} —
27
+ * what kind of page, its name, its route, the entry that triggered it, the
28
+ * layout or component the flagged content lives in, and what visitors see
29
+ * meanwhile — and stores that on the review row and on the owners' notice.
30
+ * Every reader names the page from it through the functions here: the owner
31
+ * notice's `item.label`, the staff row, the console's chips and banners, and
32
+ * Settings → Holds & reviews. One description, so the owner's email and the
33
+ * banner in the editor cannot name two different pages.
34
+ *
35
+ * Pure and client-safe: the console imports it.
36
+ *=========================================*/ /** The two notice kinds that are about a page (the risk notice catalog's). */ export const PAGE_HOLD_NOTICE_KINDS = [
37
+ 'page-held',
38
+ 'page-flagged'
39
+ ];
40
+ export function isPageHoldNoticeKind(value) {
41
+ return PAGE_HOLD_NOTICE_KINDS.includes(value);
42
+ }
43
+ const NAME_MAX = 80;
44
+ function quoted(value) {
45
+ const text = String(value != null ? value : '').replace(/\s+/g, ' ').trim().slice(0, NAME_MAX);
46
+ return text ? `"${text.replace(/"/g, '”')}"` : null;
47
+ }
48
+ function withRoute(text, route) {
49
+ return route ? `${text} (${route})` : text;
50
+ }
51
+ /**
52
+ * The page in the owner's words, as the notice's `item.label` and every
53
+ * console surface render it: `the "About" page (/about)`,
54
+ * `the "Video detail" template (/videos/:slug)`,
55
+ * `variant "B" of the "Pricing" page (/pricing)`.
56
+ */ export function heldPageLabel(subject) {
57
+ const name = quoted(subject.name);
58
+ switch(subject.kind){
59
+ case 'list-template':
60
+ return withRoute(name ? `the ${name} list template` : subject.collectionName ? `the ${quoted(subject.collectionName)} list template` : 'a collection list template', subject.route);
61
+ case 'entry-template':
62
+ return withRoute(name ? `the ${name} template` : subject.collectionName ? `the ${quoted(subject.collectionName)} entry template` : 'a collection entry template', subject.route);
63
+ case 'error-screen':
64
+ return name ? `the ${name} error page` : 'an error page';
65
+ case 'variant':
66
+ {
67
+ const page = withRoute(name ? `the ${name} page` : 'a page', subject.route);
68
+ const variant = quoted(subject.variantName);
69
+ return variant ? `variant ${variant} of ${page}` : `an experiment variant of ${page}`;
70
+ }
71
+ default:
72
+ return name ? withRoute(`the ${name} page`, subject.route) : subject.route ? `the page ${subject.route}` : 'a page on your site';
73
+ }
74
+ }
75
+ /** The layout or component, in words: `the layout "Main", used on 12 pages`. */ export function heldPageSourceLabel(source) {
76
+ const name = quoted(source.name);
77
+ const noun = source.type === 'layout' ? 'layout' : 'component';
78
+ const base = name ? `the ${noun} ${name}` : `a ${noun}`;
79
+ if (source.pagesUsing === null) return base;
80
+ return `${base}, used on ${source.pagesUsing} ${source.pagesUsing === 1 ? 'page' : 'pages'}`;
81
+ }
82
+ /**
83
+ * The facts beside the name, one sentence each: where the entry template was
84
+ * seen and which layout or component the flagged content lives in. The owner
85
+ * surfaces and the staff row both render these.
86
+ */ export function heldPageDetails(subject) {
87
+ const lines = [];
88
+ if (subject.kind === 'entry-template' && subject.entryUrl) {
89
+ lines.push(`Found while showing ${subject.entryUrl}.`);
90
+ } else if (subject.url && subject.kind !== 'entry-template') {
91
+ lines.push(`Address: ${subject.url}.`);
92
+ }
93
+ if (subject.source) {
94
+ lines.push(`The flagged content is in ${heldPageSourceLabel(subject.source)}, not on the page itself.`);
95
+ }
96
+ return lines;
97
+ }
98
+ /** What visitors receive meanwhile, as one sentence. */ export function heldPageVisitorSentence(subject) {
99
+ switch(subject.visitorView){
100
+ case 'live':
101
+ return 'Visitors still see this page while it is reviewed.';
102
+ case 'previous-version':
103
+ return 'Visitors see the previous version of this page until the review is done.';
104
+ case 'built-in-design':
105
+ return 'Visitors see the site’s built-in design for these pages until the review is done.';
106
+ case 'published-version':
107
+ return 'Visitors in this variant see the page’s published version until the review is done.';
108
+ default:
109
+ return 'Visitors see a not-found page until the review is done.';
110
+ }
111
+ }
112
+ /**
113
+ * Where the owner fixes it, in the stored-notification shape
114
+ * (`/{hostId}/…`): the layout or component the flagged content lives in, or
115
+ * the held version of the page itself.
116
+ */ export function heldPageConsolePath(subject, hostId) {
117
+ var _subject_source, _subject_source1;
118
+ if (((_subject_source = subject.source) == null ? void 0 : _subject_source.type) === 'layout') {
119
+ return `/${hostId}/layouts/${encodeURIComponent(subject.source.id)}`;
120
+ }
121
+ if (((_subject_source1 = subject.source) == null ? void 0 : _subject_source1.type) === 'component') {
122
+ return `/${hostId}/components/${encodeURIComponent(subject.source.id)}`;
123
+ }
124
+ return `/${hostId}/screens/${encodeURIComponent(subject.screenId)}` + `/versions/${encodeURIComponent(subject.versionId)}/view`;
125
+ }
126
+ /** Every console document the hold is shown on: the page, and its source. */ export function heldPageTargets(subject) {
127
+ const targets = [
128
+ {
129
+ type: 'screen',
130
+ id: subject.screenId
131
+ }
132
+ ];
133
+ if (subject.source) targets.push({
134
+ type: subject.source.type,
135
+ id: subject.source.id
136
+ });
137
+ return targets;
138
+ }
139
+ /**
140
+ * The screen and version a page notice's stored `itemPath` names, for a
141
+ * notice written before it carried a subject. Null for any other path.
142
+ */ export function parseHeldPageItemPath(path) {
143
+ const match = /^\/[^/]+\/screens\/([^/]+)\/versions\/([^/]+)\/view$/.exec(String(path != null ? path : ''));
144
+ if (!match) return null;
145
+ try {
146
+ return {
147
+ screenId: decodeURIComponent(match[1]),
148
+ versionId: decodeURIComponent(match[2])
149
+ };
150
+ } catch (unused) {
151
+ return null;
152
+ }
153
+ }
154
+ /** Whether a hold is still something the owner has to see. */ export function isOpenPageHoldStatus(status) {
155
+ return status === 'held' || status === 'in-review' || status === 'rejected';
156
+ }
157
+ /** The status chip on a held or flagged page. There is never a release control beside it. */ export function pageHoldChip(kind, status) {
158
+ if (status === 'rejected') return {
159
+ label: 'Not approved',
160
+ color: 'error'
161
+ };
162
+ if (kind === 'page-flagged') return {
163
+ label: 'Flagged — live, under review',
164
+ color: 'info'
165
+ };
166
+ return {
167
+ label: 'Held for review',
168
+ color: 'warning'
169
+ };
170
+ }
171
+
172
+ //# sourceMappingURL=held-page.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/held-page.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 * A HELD OR FLAGGED PAGE, NAMED (AGL-3374).\n *\n * The page phishing screen (`hosted-page-review.ts`) reviews a page where it\n * is composed, and knows only a screen id. A screen id is not what anybody\n * calls a page, and the routing map that turns one into an address does not\n * hold every kind of page: a collection's entry template renders\n * `/videos/{entry}` for every entry and has no address of its own, so a row\n * built from the map alone named the site's home page for it.\n *\n * So the review describes the page ONCE, as a {@link HeldPageSubject} —\n * what kind of page, its name, its route, the entry that triggered it, the\n * layout or component the flagged content lives in, and what visitors see\n * meanwhile — and stores that on the review row and on the owners' notice.\n * Every reader names the page from it through the functions here: the owner\n * notice's `item.label`, the staff row, the console's chips and banners, and\n * Settings → Holds & reviews. One description, so the owner's email and the\n * banner in the editor cannot name two different pages.\n *\n * Pure and client-safe: the console imports it.\n *=========================================*/\n\n/** The two notice kinds that are about a page (the risk notice catalog's). */\nexport const PAGE_HOLD_NOTICE_KINDS = ['page-held', 'page-flagged'] as const\nexport type PageHoldNoticeKind = (typeof PAGE_HOLD_NOTICE_KINDS)[number]\n\nexport function isPageHoldNoticeKind(value: unknown): value is PageHoldNoticeKind {\n return (PAGE_HOLD_NOTICE_KINDS as readonly unknown[]).includes(value)\n}\n\n/**\n * What kind of page was reviewed.\n *\n * - `screen`: a page at an address of its own.\n * - `list-template`: a collection's listing template, served at `/{slug}`.\n * - `entry-template`: a template that renders every entry of a collection\n * (or every product) at `/{slug}/:slug`, with no address of its own.\n * - `error-screen`: a screen bound to one of the site's error slots.\n * - `variant`: an experiment variant's version of a page.\n */\nexport type HeldPageKind =\n | 'screen'\n | 'list-template'\n | 'entry-template'\n | 'error-screen'\n | 'variant'\n\n/**\n * What visitors receive while the page is under review.\n *\n * - `previous-version`: the last version the page served clean.\n * - `not-found`: nothing — the page had never served clean.\n * - `built-in-design`: a template with no clean version: the collection's\n * routes fall back to the site's built-in design for them.\n * - `published-version`: a held variant: its visitors get the published page.\n * - `live`: a flagged page that was already live keeps serving.\n */\nexport type HeldPageVisitorView =\n | 'previous-version'\n | 'not-found'\n | 'built-in-design'\n | 'published-version'\n | 'live'\n\n/** A layout or component the flagged content came from, when not the page's own. */\nexport interface HeldPageSource {\n type: 'layout' | 'component'\n id: string\n name: string | null\n /** How many live pages render it, when that could be counted. */\n pagesUsing: number | null\n}\n\n/** One held or flagged page, described once at review time. */\nexport interface HeldPageSubject {\n kind: HeldPageKind\n screenId: string\n versionId: string\n /** The screen's (or template's) own name. */\n name: string | null\n /** The route it serves: `/about`, `/videos`, `/videos/:slug`. */\n route: string | null\n /** The page's public address, when it has one of its own. */\n url: string | null\n /** An entry template: the address of the entry that was being composed. */\n entryUrl: string | null\n /** A template: the collection (or `Products`) it renders. */\n collectionName: string | null\n /** A variant: its name in the experiment. */\n variantName: string | null\n source: HeldPageSource | null\n visitorView: HeldPageVisitorView\n}\n\n/** A console document a hold can be shown on. */\nexport interface HeldPageTarget {\n type: 'screen' | 'layout' | 'component'\n id: string\n}\n\nconst NAME_MAX = 80\n\nfunction quoted(value: string | null | undefined): string | null {\n const text = String(value ?? '')\n .replace(/\\s+/g, ' ')\n .trim()\n .slice(0, NAME_MAX)\n return text ? `\"${text.replace(/\"/g, '”')}\"` : null\n}\n\nfunction withRoute(text: string, route: string | null): string {\n return route ? `${text} (${route})` : text\n}\n\n/**\n * The page in the owner's words, as the notice's `item.label` and every\n * console surface render it: `the \"About\" page (/about)`,\n * `the \"Video detail\" template (/videos/:slug)`,\n * `variant \"B\" of the \"Pricing\" page (/pricing)`.\n */\nexport function heldPageLabel(subject: HeldPageSubject): string {\n const name = quoted(subject.name)\n switch (subject.kind) {\n case 'list-template':\n return withRoute(\n name\n ? `the ${name} list template`\n : subject.collectionName\n ? `the ${quoted(subject.collectionName)} list template`\n : 'a collection list template',\n subject.route,\n )\n case 'entry-template':\n return withRoute(\n name\n ? `the ${name} template`\n : subject.collectionName\n ? `the ${quoted(subject.collectionName)} entry template`\n : 'a collection entry template',\n subject.route,\n )\n case 'error-screen':\n return name ? `the ${name} error page` : 'an error page'\n case 'variant': {\n const page = withRoute(name ? `the ${name} page` : 'a page', subject.route)\n const variant = quoted(subject.variantName)\n return variant ? `variant ${variant} of ${page}` : `an experiment variant of ${page}`\n }\n default:\n return name\n ? withRoute(`the ${name} page`, subject.route)\n : subject.route\n ? `the page ${subject.route}`\n : 'a page on your site'\n }\n}\n\n/** The layout or component, in words: `the layout \"Main\", used on 12 pages`. */\nexport function heldPageSourceLabel(source: HeldPageSource): string {\n const name = quoted(source.name)\n const noun = source.type === 'layout' ? 'layout' : 'component'\n const base = name ? `the ${noun} ${name}` : `a ${noun}`\n if (source.pagesUsing === null) return base\n return `${base}, used on ${source.pagesUsing} ${source.pagesUsing === 1 ? 'page' : 'pages'}`\n}\n\n/**\n * The facts beside the name, one sentence each: where the entry template was\n * seen and which layout or component the flagged content lives in. The owner\n * surfaces and the staff row both render these.\n */\nexport function heldPageDetails(subject: HeldPageSubject): string[] {\n const lines: string[] = []\n if (subject.kind === 'entry-template' && subject.entryUrl) {\n lines.push(`Found while showing ${subject.entryUrl}.`)\n } else if (subject.url && subject.kind !== 'entry-template') {\n lines.push(`Address: ${subject.url}.`)\n }\n if (subject.source) {\n lines.push(\n `The flagged content is in ${heldPageSourceLabel(subject.source)}, not on the page itself.`,\n )\n }\n return lines\n}\n\n/** What visitors receive meanwhile, as one sentence. */\nexport function heldPageVisitorSentence(subject: Pick<HeldPageSubject, 'visitorView'>): string {\n switch (subject.visitorView) {\n case 'live':\n return 'Visitors still see this page while it is reviewed.'\n case 'previous-version':\n return 'Visitors see the previous version of this page until the review is done.'\n case 'built-in-design':\n return 'Visitors see the site’s built-in design for these pages until the review is done.'\n case 'published-version':\n return 'Visitors in this variant see the page’s published version until the review is done.'\n default:\n return 'Visitors see a not-found page until the review is done.'\n }\n}\n\n/**\n * Where the owner fixes it, in the stored-notification shape\n * (`/{hostId}/…`): the layout or component the flagged content lives in, or\n * the held version of the page itself.\n */\nexport function heldPageConsolePath(subject: HeldPageSubject, hostId: string): string {\n if (subject.source?.type === 'layout') {\n return `/${hostId}/layouts/${encodeURIComponent(subject.source.id)}`\n }\n if (subject.source?.type === 'component') {\n return `/${hostId}/components/${encodeURIComponent(subject.source.id)}`\n }\n return (\n `/${hostId}/screens/${encodeURIComponent(subject.screenId)}` +\n `/versions/${encodeURIComponent(subject.versionId)}/view`\n )\n}\n\n/** Every console document the hold is shown on: the page, and its source. */\nexport function heldPageTargets(subject: HeldPageSubject): HeldPageTarget[] {\n const targets: HeldPageTarget[] = [{ type: 'screen', id: subject.screenId }]\n if (subject.source) targets.push({ type: subject.source.type, id: subject.source.id })\n return targets\n}\n\n/**\n * The screen and version a page notice's stored `itemPath` names, for a\n * notice written before it carried a subject. Null for any other path.\n */\nexport function parseHeldPageItemPath(\n path: string | null | undefined,\n): { screenId: string; versionId: string } | null {\n const match = /^\\/[^/]+\\/screens\\/([^/]+)\\/versions\\/([^/]+)\\/view$/.exec(String(path ?? ''))\n if (!match) return null\n try {\n return { screenId: decodeURIComponent(match[1]), versionId: decodeURIComponent(match[2]) }\n } catch {\n return null\n }\n}\n\n/** A hold's review state, as the owner surfaces receive it. */\nexport type PageHoldStatus = 'held' | 'in-review' | 'released' | 'rejected' | 'closed' | null\n\n/** Whether a hold is still something the owner has to see. */\nexport function isOpenPageHoldStatus(status: PageHoldStatus): boolean {\n return status === 'held' || status === 'in-review' || status === 'rejected'\n}\n\n/** The status chip on a held or flagged page. There is never a release control beside it. */\nexport function pageHoldChip(\n kind: PageHoldNoticeKind,\n status: PageHoldStatus,\n): { label: string; color: 'warning' | 'error' | 'info' } {\n if (status === 'rejected') return { label: 'Not approved', color: 'error' }\n if (kind === 'page-flagged') return { label: 'Flagged — live, under review', color: 'info' }\n return { label: 'Held for review', color: 'warning' }\n}\n"],"names":["PAGE_HOLD_NOTICE_KINDS","isPageHoldNoticeKind","value","includes","NAME_MAX","quoted","text","String","replace","trim","slice","withRoute","route","heldPageLabel","subject","name","kind","collectionName","page","variant","variantName","heldPageSourceLabel","source","noun","type","base","pagesUsing","heldPageDetails","lines","entryUrl","push","url","heldPageVisitorSentence","visitorView","heldPageConsolePath","hostId","encodeURIComponent","id","screenId","versionId","heldPageTargets","targets","parseHeldPageItemPath","path","match","exec","decodeURIComponent","isOpenPageHoldStatus","status","pageHoldChip","label","color"],"mappings":"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"}
@@ -14,7 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
- import { type EmailRenderOptions } from './email-render';
17
+ import { type EmailChrome, type EmailRenderOptions, type EmailTheme } from './email-render';
18
18
  import { type TenantEmailEntry } from './tenant-email-catalog';
19
19
  /**
20
20
  * The slice of the Admin Firestore chain this resolver uses. Structural on
@@ -70,6 +70,20 @@ export interface LoadHostEmailOptions {
70
70
  origin?: string;
71
71
  /** See {@link HostEmailComposer} — required, deliberately. */
72
72
  compose: HostEmailComposer;
73
+ /**
74
+ * The site's header and footer (AGL-3370), drawn around the built-in copy
75
+ * and around a design the owner published, less any band that design draws
76
+ * itself (AGL-3372, {@link chromeForDesign}). Built by the caller because
77
+ * it is read off the site document, which this `scope:shared` module has
78
+ * no brand vocabulary for.
79
+ */
80
+ chrome?: EmailChrome;
81
+ /**
82
+ * The site's theme (AGL-3370), on EVERY copy, the owner's design included:
83
+ * a color they picked is a palette token only the theme can resolve, and a
84
+ * button they left alone wears the site's accent.
85
+ */
86
+ theme?: EmailTheme;
73
87
  }
74
88
  /**
75
89
  * A site-owner-designed template loaded once, ready to render for any number
@@ -89,12 +103,24 @@ export interface LoadedHostEmail {
89
103
  * in which case those images are dropped rather than sent broken.
90
104
  */
91
105
  origin?: string;
106
+ /**
107
+ * `designed` when the site owner published one; `default` for the
108
+ * catalog's built-in copy, which a site with nothing published sends
109
+ * (AGL-3370).
110
+ */
111
+ source: 'designed' | 'default';
112
+ /** The site's header and footer, less the bands a design draws itself. */
113
+ chrome?: EmailChrome;
114
+ /** The site's theme, on every copy. */
115
+ theme?: EmailTheme;
92
116
  }
93
117
  /**
94
- * Loads a site's published email template, or `null` when there is nothing
95
- * usable (no document, no published version, an empty node map, an unknown or
96
- * non-designable key). The host-scoped mirror of the platform
97
- * `loadSystemEmail`: `hosts/{hostId}/emailTemplates/{key}`.
118
+ * Loads what a site sends for a key: its published template, or the
119
+ * catalog's built-in copy when there is none to use (no document, no
120
+ * published version, an empty node map, a failed read) (AGL-3370). `null`
121
+ * only for an unknown or non-designable key, or one with no built-in copy.
122
+ * The host-scoped mirror of the platform `loadSystemEmail`:
123
+ * `hosts/{hostId}/emailTemplates/{key}`.
98
124
  *
99
125
  * The caller passes its own Admin Firestore, so this reads through the Admin
100
126
  * SDK — the send path runs as the server, not a signed-in user.
@@ -119,8 +145,9 @@ export declare function loadHostEmail(firestore: AdminFirestoreLike, hostId: str
119
145
  */
120
146
  export declare function renderLoadedHostEmail(loaded: LoadedHostEmail, merge: Record<string, string>, sanitize: EmailRenderOptions['sanitize']): RenderedHostEmail | null;
121
147
  /**
122
- * Renders a site's designed email, or `null` when nothing usable is published
123
- * so the send site keeps its built-in copy (AGL-770). Single-recipient
148
+ * Renders what a site sends for a key: its designed email, or the catalog's
149
+ * built-in copy in the site's header and footer (AGL-770, AGL-3370). `null`
150
+ * when the key has neither, so the send site keeps its own copy. Single-recipient
124
151
  * convenience over {@link loadHostEmail} + {@link renderLoadedHostEmail}; a
125
152
  * batch should call those two so it reads the template once.
126
153
  */
@@ -1,3 +1,4 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
1
2
  /**
2
3
  * @license
3
4
  * Copyright 2026 Aglyn LLC
@@ -16,16 +17,23 @@
16
17
  */ import { hostEmailOrigin } from "./email-media-src.js";
17
18
  import { decodeEmailNodes } from "./stored-email-nodes.js";
18
19
  import { EMAIL_NODE_ROOT_ID, renderEmailHtml, substituteMergeTokens } from "./email-render.js";
20
+ import { chromeForDesign } from "./email-design-chrome.js";
21
+ import { buildDefaultEmailNodeMap } from "./system-email-catalog.js";
22
+ /** A chrome as an optional field, absent when there is none. */ const withChrome = (chrome)=>chrome ? {
23
+ chrome
24
+ } : {};
19
25
  import { getTenantEmail, isTenantEmailEditable, TENANT_EMAIL_COLLECTION } from "./tenant-email-catalog.js";
20
26
  /** Blanks any `{{token}}` the caller did not supply, so a customer never
21
27
  * sees a raw tag. Mirrors the platform system-email resolver. */ function blankUnresolvedTokens(value) {
22
28
  return value.replace(/\{\{[^}]*\}\}/g, '');
23
29
  }
24
30
  /**
25
- * Loads a site's published email template, or `null` when there is nothing
26
- * usable (no document, no published version, an empty node map, an unknown or
27
- * non-designable key). The host-scoped mirror of the platform
28
- * `loadSystemEmail`: `hosts/{hostId}/emailTemplates/{key}`.
31
+ * Loads what a site sends for a key: its published template, or the
32
+ * catalog's built-in copy when there is none to use (no document, no
33
+ * published version, an empty node map, a failed read) (AGL-3370). `null`
34
+ * only for an unknown or non-designable key, or one with no built-in copy.
35
+ * The host-scoped mirror of the platform `loadSystemEmail`:
36
+ * `hosts/{hostId}/emailTemplates/{key}`.
29
37
  *
30
38
  * The caller passes its own Admin Firestore, so this reads through the Admin
31
39
  * SDK — the send path runs as the server, not a signed-in user.
@@ -45,14 +53,46 @@ import { getTenantEmail, isTenantEmailEditable, TENANT_EMAIL_COLLECTION } from "
45
53
  const entry = getTenantEmail(templateKey);
46
54
  if (!entry || !isTenantEmailEditable(entry)) return null;
47
55
  if (!hostId) return null;
56
+ const hostRef = firestore.collection('hosts').doc(hostId);
57
+ /** The site's origin: the caller's, else one read of the host document. */ const siteOrigin = async ()=>{
58
+ if (options.origin) return options.origin;
59
+ const hostSnapshot = await hostRef.get();
60
+ return hostEmailOrigin({
61
+ cname: hostSnapshot.get('cname'),
62
+ subdomain: hostSnapshot.get('subdomain')
63
+ });
64
+ };
65
+ /**
66
+ * The built-in copy (AGL-3370), in the site's header and footer: what a
67
+ * site with nothing published sends, and what a template that cannot be
68
+ * read falls back to, so a customer never gets the bare text card.
69
+ * `null` only for an entry with no built-in copy to render.
70
+ */ const builtIn = async ()=>{
71
+ var _entry_defaultSubject;
72
+ var _entry_defaultBody;
73
+ if (!((_entry_defaultBody = entry.defaultBody) == null ? void 0 : _entry_defaultBody.length)) return null;
74
+ const origin = await siteOrigin().catch(()=>options.origin);
75
+ return _extends({
76
+ entry,
77
+ nodes: buildDefaultEmailNodeMap(entry),
78
+ hostId,
79
+ origin,
80
+ subjectTemplate: (_entry_defaultSubject = entry.defaultSubject) != null ? _entry_defaultSubject : '',
81
+ preheaderTemplate: '',
82
+ source: 'default'
83
+ }, options.chrome ? {
84
+ chrome: options.chrome
85
+ } : {}, options.theme ? {
86
+ theme: options.theme
87
+ } : {});
88
+ };
48
89
  try {
49
90
  var _templateSnapshot_get, _templateSnapshot_get1;
50
- const hostRef = firestore.collection('hosts').doc(hostId);
51
91
  const templateRef = hostRef.collection(TENANT_EMAIL_COLLECTION).doc(templateKey);
52
92
  const templateSnapshot = await templateRef.get();
53
- if (!templateSnapshot.exists) return null;
93
+ if (!templateSnapshot.exists) return builtIn();
54
94
  const versionId = templateSnapshot.get('versionId');
55
- if (!versionId) return null;
95
+ if (!versionId) return builtIn();
56
96
  const versionSnapshot = await templateRef.collection('versions').doc(String(versionId)).get();
57
97
  // BOTH stored forms. The email besigner compresses like every other
58
98
  // besigner document (AGL-1151), and every version written before it did
@@ -64,7 +104,7 @@ import { getTenantEmail, isTenantEmailEditable, TENANT_EMAIL_COLLECTION } from "
64
104
  // passes and the caller renders an empty email instead of falling back to
65
105
  // its built-in copy (AGL-1223).
66
106
  const stored = decodeEmailNodes(versionSnapshot.get('nodes'));
67
- if (!stored || !Object.keys(stored).length) return null;
107
+ if (!stored || !Object.keys(stored).length) return builtIn();
68
108
  // Reusable blocks (AGL-3287), BEFORE anything reads the tree: a header or
69
109
  // footer the site owner placed is a component reference until this runs.
70
110
  // A failed component read throws into the catch below and falls back to
@@ -73,27 +113,28 @@ import { getTenantEmail, isTenantEmailEditable, TENANT_EMAIL_COLLECTION } from "
73
113
  firestore,
74
114
  hostId
75
115
  });
76
- let origin = options.origin;
77
- if (!origin) {
78
- const hostSnapshot = await hostRef.get();
79
- origin = hostEmailOrigin({
80
- cname: hostSnapshot.get('cname'),
81
- subdomain: hostSnapshot.get('subdomain')
82
- });
83
- }
84
- return {
116
+ const origin = await siteOrigin();
117
+ return _extends({
85
118
  entry,
86
119
  nodes,
87
120
  hostId,
88
121
  origin,
122
+ source: 'designed'
123
+ }, options.theme ? {
124
+ theme: options.theme
125
+ } : {}, withChrome(chromeForDesign({
126
+ stored,
127
+ composed: nodes,
128
+ chrome: options.chrome
129
+ })), {
89
130
  subjectTemplate: String((_templateSnapshot_get = templateSnapshot.get('subject')) != null ? _templateSnapshot_get : '') || entry.defaultSubject || '',
90
131
  preheaderTemplate: String((_templateSnapshot_get1 = templateSnapshot.get('preheader')) != null ? _templateSnapshot_get1 : '')
91
- };
132
+ });
92
133
  } catch (error) {
93
- // Never let a template problem block the send — the caller falls back to
94
- // its built-in copy and the customer still gets their email.
134
+ // Never let a template problem block the send, or strip it bare: the
135
+ // customer gets the built-in copy, in the site's header and footer.
95
136
  console.error(`host email template ${templateKey} failed to load`, error);
96
- return null;
137
+ return builtIn().catch(()=>null);
97
138
  }
98
139
  }
99
140
  /**
@@ -102,7 +143,7 @@ import { getTenantEmail, isTenantEmailEditable, TENANT_EMAIL_COLLECTION } from "
102
143
  * caller still falls back to its built-in copy.
103
144
  */ export function renderLoadedHostEmail(loaded, merge = {}, sanitize) {
104
145
  var _rendered_text;
105
- const rendered = renderEmailHtml({
146
+ const rendered = renderEmailHtml(_extends({
106
147
  nodes: loaded.nodes,
107
148
  // A site owner designs these in the besigner, so a template's richtext is
108
149
  // author markup exactly as a campaign's is. The policy has to arrive from
@@ -118,7 +159,9 @@ import { getTenantEmail, isTenantEmailEditable, TENANT_EMAIL_COLLECTION } from "
118
159
  // makes it fetchable from a recipient's inbox (AGL-1224).
119
160
  mediaOrigin: loaded.origin,
120
161
  mediaHostId: loaded.hostId
121
- });
162
+ }, loaded.chrome ? {
163
+ chrome: loaded.chrome
164
+ } : {}, loaded.theme));
122
165
  if (!(rendered == null ? void 0 : rendered.html)) return null;
123
166
  return {
124
167
  subject: blankUnresolvedTokens(substituteMergeTokens(loaded.subjectTemplate, merge)),
@@ -127,8 +170,9 @@ import { getTenantEmail, isTenantEmailEditable, TENANT_EMAIL_COLLECTION } from "
127
170
  };
128
171
  }
129
172
  /**
130
- * Renders a site's designed email, or `null` when nothing usable is published
131
- * so the send site keeps its built-in copy (AGL-770). Single-recipient
173
+ * Renders what a site sends for a key: its designed email, or the catalog's
174
+ * built-in copy in the site's header and footer (AGL-770, AGL-3370). `null`
175
+ * when the key has neither, so the send site keeps its own copy. Single-recipient
132
176
  * convenience over {@link loadHostEmail} + {@link renderLoadedHostEmail}; a
133
177
  * batch should call those two so it reads the template once.
134
178
  */ export async function renderHostEmail(firestore, hostId, templateKey, merge = {}, options) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/host-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\nimport { hostEmailOrigin } from './email-media-src'\nimport { decodeEmailNodes } from './stored-email-nodes'\nimport {\n EMAIL_NODE_ROOT_ID,\n renderEmailHtml,\n substituteMergeTokens,\n type EmailRenderOptions,\n} from './email-render'\nimport {\n getTenantEmail,\n isTenantEmailEditable,\n TENANT_EMAIL_COLLECTION,\n type TenantEmailEntry,\n} from './tenant-email-catalog'\n\n/**\n * The slice of the Admin Firestore chain this resolver uses. Structural on\n * purpose (AGL-770): the plugins that call this already hold an Admin\n * Firestore instance, so passing it in keeps `@aglyn/shared-util-email` free\n * of a firebase-admin dependency and importable from every send site.\n */\ninterface AdminDocSnapshotLike {\n exists: boolean\n get(field: string): unknown\n}\ninterface AdminDocRefLike {\n get(): Promise<AdminDocSnapshotLike>\n collection(path: string): AdminCollectionRefLike\n}\ninterface AdminCollectionRefLike {\n doc(id: string): AdminDocRefLike\n}\nexport interface AdminFirestoreLike {\n collection(path: string): AdminCollectionRefLike\n}\n\nexport interface RenderedHostEmail {\n subject: string\n html: string\n text: string\n}\n\n/**\n * Expands the reusable blocks a site's email places into the blocks they stand\n * for (AGL-3287): a shared header or footer is a `reusableInstance` node naming\n * one of the site's components, and `renderEmailHtml` draws an unknown node as\n * nothing at all. Given the decoded node map and the handle and site it was\n * read with; resolves to the map to render.\n *\n * REQUIRED, for the reason `sanitize` is (see\n * {@link EmailRenderOptions.sanitize}): this module is `scope:shared` and the\n * composition — the component graft, the document reads — is the core's, which\n * nx forbids it to import. A composer a caller may omit is one a new send path\n * omits, and its customers then receive every designed email with the header\n * and footer missing while the canvas shows them in place. So the type makes\n * each caller hand one in; every sender passes the core's\n * `composeHostComponentNodes`.\n *\n * Only style overrides are lost on the way through, and not by the composer:\n * a placement's `styleOverrides` land in `sx`, which the mail renderer never\n * reads. Property values and attribute overrides reach the blocks.\n */\nexport type HostEmailComposer = (\n nodes: Record<string, unknown>,\n context: { firestore: AdminFirestoreLike; hostId: string },\n) => Promise<Record<string, unknown>>\n\nexport interface LoadHostEmailOptions {\n /** The site's origin when the caller already knows it; saves the host read. */\n origin?: string\n /** See {@link HostEmailComposer} — required, deliberately. */\n compose: HostEmailComposer\n}\n\n/**\n * A site-owner-designed template loaded once, ready to render for any number\n * of recipients without another Firestore read (AGL-770) — the booking\n * reminder job renders one per booking.\n */\nexport interface LoadedHostEmail {\n entry: TenantEmailEntry\n nodes: Record<string, unknown>\n subjectTemplate: string\n preheaderTemplate: string\n /** Host doc id, so an org-scoped media reference can be host-qualified. */\n hostId: string\n /**\n * The site's absolute origin, for absolutizing picked images (AGL-1224).\n * Undefined only for a host with neither a custom domain nor a subdomain,\n * in which case those images are dropped rather than sent broken.\n */\n origin?: string\n}\n\n/** Blanks any `{{token}}` the caller did not supply, so a customer never\n * sees a raw tag. Mirrors the platform system-email resolver. */\nfunction blankUnresolvedTokens(value: string): string {\n return value.replace(/\\{\\{[^}]*\\}\\}/g, '')\n}\n\n/**\n * Loads a site's published email template, or `null` when there is nothing\n * usable (no document, no published version, an empty node map, an unknown or\n * non-designable key). The host-scoped mirror of the platform\n * `loadSystemEmail`: `hosts/{hostId}/emailTemplates/{key}`.\n *\n * The caller passes its own Admin Firestore, so this reads through the Admin\n * SDK — the send path runs as the server, not a signed-in user.\n *\n * TWO reads, not one (AGL-1224): the host document is read for the site's\n * origin, which picked images need to be fetchable from an inbox. It is read\n * LAST, only once there is something to render, so a site with nothing\n * published still costs a single read — and it is skipped entirely when the\n * caller already knows the origin and passes it. Still once per batch, not\n * once per recipient, which is the property AGL-770 cared about.\n *\n * The components the template places are read and grafted HERE, through\n * `options.compose`, for the same reason: once per load, so a batch renders\n * every recipient's copy from one composed map. A template that places none\n * costs no extra read.\n */\nexport async function loadHostEmail(\n firestore: AdminFirestoreLike,\n hostId: string,\n templateKey: string,\n options: LoadHostEmailOptions,\n): Promise<LoadedHostEmail | null> {\n const entry = getTenantEmail(templateKey)\n if (!entry || !isTenantEmailEditable(entry)) return null\n if (!hostId) return null\n\n try {\n const hostRef = firestore.collection('hosts').doc(hostId)\n const templateRef = hostRef\n .collection(TENANT_EMAIL_COLLECTION)\n .doc(templateKey)\n const templateSnapshot = await templateRef.get()\n if (!templateSnapshot.exists) return null\n\n const versionId = templateSnapshot.get('versionId')\n if (!versionId) return null\n\n const versionSnapshot = await templateRef\n .collection('versions')\n .doc(String(versionId))\n .get()\n // BOTH stored forms. The email besigner compresses like every other\n // besigner document (AGL-1151), and every version written before it did\n // is still a plain map — a plain map comes back unchanged, so a single\n // call serves both and keeps serving both.\n //\n // Reading the field raw is the failure the guard below cannot catch: over\n // a `Buffer`, `Object.keys` counts BYTE INDICES, so the emptiness test\n // passes and the caller renders an empty email instead of falling back to\n // its built-in copy (AGL-1223).\n const stored = decodeEmailNodes<Record<string, unknown>>(\n versionSnapshot.get('nodes'),\n )\n if (!stored || !Object.keys(stored).length) return null\n // Reusable blocks (AGL-3287), BEFORE anything reads the tree: a header or\n // footer the site owner placed is a component reference until this runs.\n // A failed component read throws into the catch below and falls back to\n // the built-in copy — never a send with half its design missing.\n const nodes = await options.compose(stored, { firestore, hostId })\n\n let origin = options.origin\n if (!origin) {\n const hostSnapshot = await hostRef.get()\n origin = hostEmailOrigin({\n cname: hostSnapshot.get('cname') as string | undefined,\n subdomain: hostSnapshot.get('subdomain') as string | undefined,\n })\n }\n\n return {\n entry,\n nodes,\n hostId,\n origin,\n subjectTemplate:\n String(templateSnapshot.get('subject') ?? '') ||\n entry.defaultSubject ||\n '',\n preheaderTemplate: String(templateSnapshot.get('preheader') ?? ''),\n }\n } catch (error) {\n // Never let a template problem block the send — the caller falls back to\n // its built-in copy and the customer still gets their email.\n console.error(`host email template ${templateKey} failed to load`, error)\n return null\n }\n}\n\n/**\n * Renders a pre-loaded template for one recipient's merge values (AGL-770).\n * No Firestore access; returns `null` if the node map renders empty so the\n * caller still falls back to its built-in copy.\n */\nexport function renderLoadedHostEmail(\n loaded: LoadedHostEmail,\n merge: Record<string, string> = {},\n sanitize: EmailRenderOptions['sanitize'],\n): RenderedHostEmail | null {\n const rendered = renderEmailHtml({\n nodes: loaded.nodes as never,\n // A site owner designs these in the besigner, so a template's richtext is\n // author markup exactly as a campaign's is. The policy has to arrive from\n // above for the reason this lib documents everywhere: `scope:shared`\n // cannot import the aglyn-scoped sanitizer.\n sanitize,\n // Besigner maps are rooted at '_@_' (AGL-765).\n rootId: EMAIL_NODE_ROOT_ID,\n subject: substituteMergeTokens(loaded.subjectTemplate, merge),\n preheader: substituteMergeTokens(loaded.preheaderTemplate, merge),\n merge,\n // A picked image is stored as a reference; the site's own origin is what\n // makes it fetchable from a recipient's inbox (AGL-1224).\n mediaOrigin: loaded.origin,\n mediaHostId: loaded.hostId,\n })\n if (!rendered?.html) return null\n return {\n subject: blankUnresolvedTokens(\n substituteMergeTokens(loaded.subjectTemplate, merge),\n ),\n html: blankUnresolvedTokens(rendered.html),\n text: blankUnresolvedTokens(rendered.text ?? ''),\n }\n}\n\n/**\n * Renders a site's designed email, or `null` when nothing usable is published\n * so the send site keeps its built-in copy (AGL-770). Single-recipient\n * convenience over {@link loadHostEmail} + {@link renderLoadedHostEmail}; a\n * batch should call those two so it reads the template once.\n */\nexport async function renderHostEmail(\n firestore: AdminFirestoreLike,\n hostId: string,\n templateKey: string,\n merge: Record<string, string> = {},\n options: LoadHostEmailOptions & {\n /** See {@link EmailRenderOptions.sanitize} — required, deliberately. */\n sanitize: EmailRenderOptions['sanitize']\n },\n): Promise<RenderedHostEmail | null> {\n const loaded = await loadHostEmail(firestore, hostId, templateKey, options)\n return loaded ? renderLoadedHostEmail(loaded, merge, options.sanitize) : null\n}\n"],"names":["hostEmailOrigin","decodeEmailNodes","EMAIL_NODE_ROOT_ID","renderEmailHtml","substituteMergeTokens","getTenantEmail","isTenantEmailEditable","TENANT_EMAIL_COLLECTION","blankUnresolvedTokens","value","replace","loadHostEmail","firestore","hostId","templateKey","options","entry","templateSnapshot","hostRef","collection","doc","templateRef","get","exists","versionId","versionSnapshot","String","stored","Object","keys","length","nodes","compose","origin","hostSnapshot","cname","subdomain","subjectTemplate","defaultSubject","preheaderTemplate","error","console","renderLoadedHostEmail","loaded","merge","sanitize","rendered","rootId","subject","preheader","mediaOrigin","mediaHostId","html","text","renderHostEmail"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/host-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\nimport { hostEmailOrigin } from './email-media-src'\nimport { decodeEmailNodes } from './stored-email-nodes'\nimport {\n EMAIL_NODE_ROOT_ID,\n renderEmailHtml,\n substituteMergeTokens,\n type EmailChrome,\n type EmailRenderOptions,\n type EmailTheme,\n} from './email-render'\nimport { chromeForDesign } from './email-design-chrome'\nimport { buildDefaultEmailNodeMap } from './system-email-catalog'\n\n/** A chrome as an optional field, absent when there is none. */\nconst withChrome = (chrome: EmailChrome | undefined) => (chrome ? { chrome } : {})\nimport {\n getTenantEmail,\n isTenantEmailEditable,\n TENANT_EMAIL_COLLECTION,\n type TenantEmailEntry,\n} from './tenant-email-catalog'\n\n/**\n * The slice of the Admin Firestore chain this resolver uses. Structural on\n * purpose (AGL-770): the plugins that call this already hold an Admin\n * Firestore instance, so passing it in keeps `@aglyn/shared-util-email` free\n * of a firebase-admin dependency and importable from every send site.\n */\ninterface AdminDocSnapshotLike {\n exists: boolean\n get(field: string): unknown\n}\ninterface AdminDocRefLike {\n get(): Promise<AdminDocSnapshotLike>\n collection(path: string): AdminCollectionRefLike\n}\ninterface AdminCollectionRefLike {\n doc(id: string): AdminDocRefLike\n}\nexport interface AdminFirestoreLike {\n collection(path: string): AdminCollectionRefLike\n}\n\nexport interface RenderedHostEmail {\n subject: string\n html: string\n text: string\n}\n\n/**\n * Expands the reusable blocks a site's email places into the blocks they stand\n * for (AGL-3287): a shared header or footer is a `reusableInstance` node naming\n * one of the site's components, and `renderEmailHtml` draws an unknown node as\n * nothing at all. Given the decoded node map and the handle and site it was\n * read with; resolves to the map to render.\n *\n * REQUIRED, for the reason `sanitize` is (see\n * {@link EmailRenderOptions.sanitize}): this module is `scope:shared` and the\n * composition — the component graft, the document reads — is the core's, which\n * nx forbids it to import. A composer a caller may omit is one a new send path\n * omits, and its customers then receive every designed email with the header\n * and footer missing while the canvas shows them in place. So the type makes\n * each caller hand one in; every sender passes the core's\n * `composeHostComponentNodes`.\n *\n * Only style overrides are lost on the way through, and not by the composer:\n * a placement's `styleOverrides` land in `sx`, which the mail renderer never\n * reads. Property values and attribute overrides reach the blocks.\n */\nexport type HostEmailComposer = (\n nodes: Record<string, unknown>,\n context: { firestore: AdminFirestoreLike; hostId: string },\n) => Promise<Record<string, unknown>>\n\nexport interface LoadHostEmailOptions {\n /** The site's origin when the caller already knows it; saves the host read. */\n origin?: string\n /** See {@link HostEmailComposer} — required, deliberately. */\n compose: HostEmailComposer\n /**\n * The site's header and footer (AGL-3370), drawn around the built-in copy\n * and around a design the owner published, less any band that design draws\n * itself (AGL-3372, {@link chromeForDesign}). Built by the caller because\n * it is read off the site document, which this `scope:shared` module has\n * no brand vocabulary for.\n */\n chrome?: EmailChrome\n /**\n * The site's theme (AGL-3370), on EVERY copy, the owner's design included:\n * a color they picked is a palette token only the theme can resolve, and a\n * button they left alone wears the site's accent.\n */\n theme?: EmailTheme\n}\n\n/**\n * A site-owner-designed template loaded once, ready to render for any number\n * of recipients without another Firestore read (AGL-770) — the booking\n * reminder job renders one per booking.\n */\nexport interface LoadedHostEmail {\n entry: TenantEmailEntry\n nodes: Record<string, unknown>\n subjectTemplate: string\n preheaderTemplate: string\n /** Host doc id, so an org-scoped media reference can be host-qualified. */\n hostId: string\n /**\n * The site's absolute origin, for absolutizing picked images (AGL-1224).\n * Undefined only for a host with neither a custom domain nor a subdomain,\n * in which case those images are dropped rather than sent broken.\n */\n origin?: string\n /**\n * `designed` when the site owner published one; `default` for the\n * catalog's built-in copy, which a site with nothing published sends\n * (AGL-3370).\n */\n source: 'designed' | 'default'\n /** The site's header and footer, less the bands a design draws itself. */\n chrome?: EmailChrome\n /** The site's theme, on every copy. */\n theme?: EmailTheme\n}\n\n/** Blanks any `{{token}}` the caller did not supply, so a customer never\n * sees a raw tag. Mirrors the platform system-email resolver. */\nfunction blankUnresolvedTokens(value: string): string {\n return value.replace(/\\{\\{[^}]*\\}\\}/g, '')\n}\n\n/**\n * Loads what a site sends for a key: its published template, or the\n * catalog's built-in copy when there is none to use (no document, no\n * published version, an empty node map, a failed read) (AGL-3370). `null`\n * only for an unknown or non-designable key, or one with no built-in copy.\n * The host-scoped mirror of the platform `loadSystemEmail`:\n * `hosts/{hostId}/emailTemplates/{key}`.\n *\n * The caller passes its own Admin Firestore, so this reads through the Admin\n * SDK — the send path runs as the server, not a signed-in user.\n *\n * TWO reads, not one (AGL-1224): the host document is read for the site's\n * origin, which picked images need to be fetchable from an inbox. It is read\n * LAST, only once there is something to render, so a site with nothing\n * published still costs a single read — and it is skipped entirely when the\n * caller already knows the origin and passes it. Still once per batch, not\n * once per recipient, which is the property AGL-770 cared about.\n *\n * The components the template places are read and grafted HERE, through\n * `options.compose`, for the same reason: once per load, so a batch renders\n * every recipient's copy from one composed map. A template that places none\n * costs no extra read.\n */\nexport async function loadHostEmail(\n firestore: AdminFirestoreLike,\n hostId: string,\n templateKey: string,\n options: LoadHostEmailOptions,\n): Promise<LoadedHostEmail | null> {\n const entry = getTenantEmail(templateKey)\n if (!entry || !isTenantEmailEditable(entry)) return null\n if (!hostId) return null\n\n const hostRef = firestore.collection('hosts').doc(hostId)\n /** The site's origin: the caller's, else one read of the host document. */\n const siteOrigin = async (): Promise<string | undefined> => {\n if (options.origin) return options.origin\n const hostSnapshot = await hostRef.get()\n return hostEmailOrigin({\n cname: hostSnapshot.get('cname') as string | undefined,\n subdomain: hostSnapshot.get('subdomain') as string | undefined,\n })\n }\n /**\n * The built-in copy (AGL-3370), in the site's header and footer: what a\n * site with nothing published sends, and what a template that cannot be\n * read falls back to, so a customer never gets the bare text card.\n * `null` only for an entry with no built-in copy to render.\n */\n const builtIn = async (): Promise<LoadedHostEmail | null> => {\n if (!entry.defaultBody?.length) return null\n const origin = await siteOrigin().catch(() => options.origin)\n return {\n entry,\n nodes: buildDefaultEmailNodeMap(entry),\n hostId,\n origin,\n subjectTemplate: entry.defaultSubject ?? '',\n preheaderTemplate: '',\n source: 'default',\n ...(options.chrome ? { chrome: options.chrome } : {}),\n ...(options.theme ? { theme: options.theme } : {}),\n }\n }\n\n try {\n const templateRef = hostRef\n .collection(TENANT_EMAIL_COLLECTION)\n .doc(templateKey)\n const templateSnapshot = await templateRef.get()\n if (!templateSnapshot.exists) return builtIn()\n\n const versionId = templateSnapshot.get('versionId')\n if (!versionId) return builtIn()\n\n const versionSnapshot = await templateRef\n .collection('versions')\n .doc(String(versionId))\n .get()\n // BOTH stored forms. The email besigner compresses like every other\n // besigner document (AGL-1151), and every version written before it did\n // is still a plain map — a plain map comes back unchanged, so a single\n // call serves both and keeps serving both.\n //\n // Reading the field raw is the failure the guard below cannot catch: over\n // a `Buffer`, `Object.keys` counts BYTE INDICES, so the emptiness test\n // passes and the caller renders an empty email instead of falling back to\n // its built-in copy (AGL-1223).\n const stored = decodeEmailNodes<Record<string, unknown>>(\n versionSnapshot.get('nodes'),\n )\n if (!stored || !Object.keys(stored).length) return builtIn()\n // Reusable blocks (AGL-3287), BEFORE anything reads the tree: a header or\n // footer the site owner placed is a component reference until this runs.\n // A failed component read throws into the catch below and falls back to\n // the built-in copy — never a send with half its design missing.\n const nodes = await options.compose(stored, { firestore, hostId })\n\n const origin = await siteOrigin()\n\n return {\n entry,\n nodes,\n hostId,\n origin,\n source: 'designed',\n ...(options.theme ? { theme: options.theme } : {}),\n // The site's chrome too (AGL-3372), less whatever band the owner's\n // design draws itself.\n ...withChrome(\n chromeForDesign({ stored, composed: nodes, chrome: options.chrome }),\n ),\n subjectTemplate:\n String(templateSnapshot.get('subject') ?? '') ||\n entry.defaultSubject ||\n '',\n preheaderTemplate: String(templateSnapshot.get('preheader') ?? ''),\n }\n } catch (error) {\n // Never let a template problem block the send, or strip it bare: the\n // customer gets the built-in copy, in the site's header and footer.\n console.error(`host email template ${templateKey} failed to load`, error)\n return builtIn().catch(() => null)\n }\n}\n\n/**\n * Renders a pre-loaded template for one recipient's merge values (AGL-770).\n * No Firestore access; returns `null` if the node map renders empty so the\n * caller still falls back to its built-in copy.\n */\nexport function renderLoadedHostEmail(\n loaded: LoadedHostEmail,\n merge: Record<string, string> = {},\n sanitize: EmailRenderOptions['sanitize'],\n): RenderedHostEmail | null {\n const rendered = renderEmailHtml({\n nodes: loaded.nodes as never,\n // A site owner designs these in the besigner, so a template's richtext is\n // author markup exactly as a campaign's is. The policy has to arrive from\n // above for the reason this lib documents everywhere: `scope:shared`\n // cannot import the aglyn-scoped sanitizer.\n sanitize,\n // Besigner maps are rooted at '_@_' (AGL-765).\n rootId: EMAIL_NODE_ROOT_ID,\n subject: substituteMergeTokens(loaded.subjectTemplate, merge),\n preheader: substituteMergeTokens(loaded.preheaderTemplate, merge),\n merge,\n // A picked image is stored as a reference; the site's own origin is what\n // makes it fetchable from a recipient's inbox (AGL-1224).\n mediaOrigin: loaded.origin,\n mediaHostId: loaded.hostId,\n ...(loaded.chrome ? { chrome: loaded.chrome } : {}),\n ...loaded.theme,\n })\n if (!rendered?.html) return null\n return {\n subject: blankUnresolvedTokens(\n substituteMergeTokens(loaded.subjectTemplate, merge),\n ),\n html: blankUnresolvedTokens(rendered.html),\n text: blankUnresolvedTokens(rendered.text ?? ''),\n }\n}\n\n/**\n * Renders what a site sends for a key: its designed email, or the catalog's\n * built-in copy in the site's header and footer (AGL-770, AGL-3370). `null`\n * when the key has neither, so the send site keeps its own copy. Single-recipient\n * convenience over {@link loadHostEmail} + {@link renderLoadedHostEmail}; a\n * batch should call those two so it reads the template once.\n */\nexport async function renderHostEmail(\n firestore: AdminFirestoreLike,\n hostId: string,\n templateKey: string,\n merge: Record<string, string> = {},\n options: LoadHostEmailOptions & {\n /** See {@link EmailRenderOptions.sanitize} — required, deliberately. */\n sanitize: EmailRenderOptions['sanitize']\n },\n): Promise<RenderedHostEmail | null> {\n const loaded = await loadHostEmail(firestore, hostId, templateKey, options)\n return loaded ? renderLoadedHostEmail(loaded, merge, options.sanitize) : null\n}\n"],"names":["hostEmailOrigin","decodeEmailNodes","EMAIL_NODE_ROOT_ID","renderEmailHtml","substituteMergeTokens","chromeForDesign","buildDefaultEmailNodeMap","withChrome","chrome","getTenantEmail","isTenantEmailEditable","TENANT_EMAIL_COLLECTION","blankUnresolvedTokens","value","replace","loadHostEmail","firestore","hostId","templateKey","options","entry","hostRef","collection","doc","siteOrigin","origin","hostSnapshot","get","cname","subdomain","builtIn","defaultBody","length","catch","nodes","subjectTemplate","defaultSubject","preheaderTemplate","source","theme","templateSnapshot","templateRef","exists","versionId","versionSnapshot","String","stored","Object","keys","compose","composed","error","console","renderLoadedHostEmail","loaded","merge","sanitize","rendered","rootId","subject","preheader","mediaOrigin","mediaHostId","html","text","renderHostEmail"],"mappings":"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"}