@aglyn/shared-util-email 1.0.0-beta.182 → 1.0.0-beta.183

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.182",
3
+ "version": "1.0.0-beta.183",
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.182",
29
- "@aglyn/shared-util-http": "1.0.0-beta.182",
28
+ "@aglyn/shared-data-enums": "1.0.0-beta.183",
29
+ "@aglyn/shared-util-http": "1.0.0-beta.183",
30
30
  "@msgpack/msgpack": "^3.1.3",
31
31
  "@swc/helpers": "0.5.23"
32
32
  },
@@ -41,6 +41,36 @@ export interface RenderedHostEmail {
41
41
  html: string;
42
42
  text: string;
43
43
  }
44
+ /**
45
+ * Expands the reusable blocks a site's email places into the blocks they stand
46
+ * for (AGL-3287): a shared header or footer is a `reusableInstance` node naming
47
+ * one of the site's components, and `renderEmailHtml` draws an unknown node as
48
+ * nothing at all. Given the decoded node map and the handle and site it was
49
+ * read with; resolves to the map to render.
50
+ *
51
+ * REQUIRED, for the reason `sanitize` is (see
52
+ * {@link EmailRenderOptions.sanitize}): this module is `scope:shared` and the
53
+ * composition — the component graft, the document reads — is the core's, which
54
+ * nx forbids it to import. A composer a caller may omit is one a new send path
55
+ * omits, and its customers then receive every designed email with the header
56
+ * and footer missing while the canvas shows them in place. So the type makes
57
+ * each caller hand one in; every sender passes the core's
58
+ * `composeHostComponentNodes`.
59
+ *
60
+ * Only style overrides are lost on the way through, and not by the composer:
61
+ * a placement's `styleOverrides` land in `sx`, which the mail renderer never
62
+ * reads. Property values and attribute overrides reach the blocks.
63
+ */
64
+ export type HostEmailComposer = (nodes: Record<string, unknown>, context: {
65
+ firestore: AdminFirestoreLike;
66
+ hostId: string;
67
+ }) => Promise<Record<string, unknown>>;
68
+ export interface LoadHostEmailOptions {
69
+ /** The site's origin when the caller already knows it; saves the host read. */
70
+ origin?: string;
71
+ /** See {@link HostEmailComposer} — required, deliberately. */
72
+ compose: HostEmailComposer;
73
+ }
44
74
  /**
45
75
  * A site-owner-designed template loaded once, ready to render for any number
46
76
  * of recipients without another Firestore read (AGL-770) — the booking
@@ -75,10 +105,13 @@ export interface LoadedHostEmail {
75
105
  * published still costs a single read — and it is skipped entirely when the
76
106
  * caller already knows the origin and passes it. Still once per batch, not
77
107
  * once per recipient, which is the property AGL-770 cared about.
108
+ *
109
+ * The components the template places are read and grafted HERE, through
110
+ * `options.compose`, for the same reason: once per load, so a batch renders
111
+ * every recipient's copy from one composed map. A template that places none
112
+ * costs no extra read.
78
113
  */
79
- export declare function loadHostEmail(firestore: AdminFirestoreLike, hostId: string, templateKey: string, options?: {
80
- origin?: string;
81
- }): Promise<LoadedHostEmail | null>;
114
+ export declare function loadHostEmail(firestore: AdminFirestoreLike, hostId: string, templateKey: string, options: LoadHostEmailOptions): Promise<LoadedHostEmail | null>;
82
115
  /**
83
116
  * Renders a pre-loaded template for one recipient's merge values (AGL-770).
84
117
  * No Firestore access; returns `null` if the node map renders empty so the
@@ -91,8 +124,7 @@ export declare function renderLoadedHostEmail(loaded: LoadedHostEmail, merge: Re
91
124
  * convenience over {@link loadHostEmail} + {@link renderLoadedHostEmail}; a
92
125
  * batch should call those two so it reads the template once.
93
126
  */
94
- export declare function renderHostEmail(firestore: AdminFirestoreLike, hostId: string, templateKey: string, merge: Record<string, string>, options: {
95
- origin?: string;
127
+ export declare function renderHostEmail(firestore: AdminFirestoreLike, hostId: string, templateKey: string, merge: Record<string, string>, options: LoadHostEmailOptions & {
96
128
  /** See {@link EmailRenderOptions.sanitize} — required, deliberately. */
97
129
  sanitize: EmailRenderOptions['sanitize'];
98
130
  }): Promise<RenderedHostEmail | null>;
@@ -36,7 +36,12 @@ import { getTenantEmail, isTenantEmailEditable, TENANT_EMAIL_COLLECTION } from "
36
36
  * published still costs a single read — and it is skipped entirely when the
37
37
  * caller already knows the origin and passes it. Still once per batch, not
38
38
  * once per recipient, which is the property AGL-770 cared about.
39
- */ export async function loadHostEmail(firestore, hostId, templateKey, options = {}) {
39
+ *
40
+ * The components the template places are read and grafted HERE, through
41
+ * `options.compose`, for the same reason: once per load, so a batch renders
42
+ * every recipient's copy from one composed map. A template that places none
43
+ * costs no extra read.
44
+ */ export async function loadHostEmail(firestore, hostId, templateKey, options) {
40
45
  const entry = getTenantEmail(templateKey);
41
46
  if (!entry || !isTenantEmailEditable(entry)) return null;
42
47
  if (!hostId) return null;
@@ -58,8 +63,16 @@ import { getTenantEmail, isTenantEmailEditable, TENANT_EMAIL_COLLECTION } from "
58
63
  // a `Buffer`, `Object.keys` counts BYTE INDICES, so the emptiness test
59
64
  // passes and the caller renders an empty email instead of falling back to
60
65
  // its built-in copy (AGL-1223).
61
- const nodes = decodeEmailNodes(versionSnapshot.get('nodes'));
62
- if (!nodes || !Object.keys(nodes).length) return null;
66
+ const stored = decodeEmailNodes(versionSnapshot.get('nodes'));
67
+ if (!stored || !Object.keys(stored).length) return null;
68
+ // Reusable blocks (AGL-3287), BEFORE anything reads the tree: a header or
69
+ // footer the site owner placed is a component reference until this runs.
70
+ // A failed component read throws into the catch below and falls back to
71
+ // the built-in copy — never a send with half its design missing.
72
+ const nodes = await options.compose(stored, {
73
+ firestore,
74
+ hostId
75
+ });
63
76
  let origin = options.origin;
64
77
  if (!origin) {
65
78
  const hostSnapshot = await hostRef.get();
@@ -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 * 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 */\nexport async function loadHostEmail(\n firestore: AdminFirestoreLike,\n hostId: string,\n templateKey: string,\n options: { origin?: string } = {},\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 nodes = decodeEmailNodes<Record<string, unknown>>(\n versionSnapshot.get('nodes'),\n )\n if (!nodes || !Object.keys(nodes).length) return null\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: {\n origin?: string\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","nodes","Object","keys","length","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 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":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,eAAe,QAAQ,uBAAmB;AACnD,SAASC,gBAAgB,QAAQ,0BAAsB;AACvD,SACEC,kBAAkB,EAClBC,eAAe,EACfC,qBAAqB,QAEhB,oBAAgB;AACvB,SACEC,cAAc,EACdC,qBAAqB,EACrBC,uBAAuB,QAElB,4BAAwB;AAiF/B;+DAC+D,GAC/D,SAASC,sBAAsBC,KAAa;IAC1C,OAAOA,MAAMC,OAAO,CAAC,kBAAkB;AACzC;AAEA;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,eAAeC,cACpBC,SAA6B,EAC7BC,MAAc,EACdC,WAAmB,EACnBC,OAA6B;IAE7B,MAAMC,QAAQX,eAAeS;IAC7B,IAAI,CAACE,SAAS,CAACV,sBAAsBU,QAAQ,OAAO;IACpD,IAAI,CAACH,QAAQ,OAAO;IAEpB,IAAI;YAiDSI,uBAGiBA;QAnD5B,MAAMC,UAAUN,UAAUO,UAAU,CAAC,SAASC,GAAG,CAACP;QAClD,MAAMQ,cAAcH,QACjBC,UAAU,CAACZ,yBACXa,GAAG,CAACN;QACP,MAAMG,mBAAmB,MAAMI,YAAYC,GAAG;QAC9C,IAAI,CAACL,iBAAiBM,MAAM,EAAE,OAAO;QAErC,MAAMC,YAAYP,iBAAiBK,GAAG,CAAC;QACvC,IAAI,CAACE,WAAW,OAAO;QAEvB,MAAMC,kBAAkB,MAAMJ,YAC3BF,UAAU,CAAC,YACXC,GAAG,CAACM,OAAOF,YACXF,GAAG;QACN,oEAAoE;QACpE,wEAAwE;QACxE,uEAAuE;QACvE,2CAA2C;QAC3C,EAAE;QACF,0EAA0E;QAC1E,uEAAuE;QACvE,0EAA0E;QAC1E,gCAAgC;QAChC,MAAMK,SAAS1B,iBACbwB,gBAAgBH,GAAG,CAAC;QAEtB,IAAI,CAACK,UAAU,CAACC,OAAOC,IAAI,CAACF,QAAQG,MAAM,EAAE,OAAO;QACnD,0EAA0E;QAC1E,yEAAyE;QACzE,wEAAwE;QACxE,iEAAiE;QACjE,MAAMC,QAAQ,MAAMhB,QAAQiB,OAAO,CAACL,QAAQ;YAAEf;YAAWC;QAAO;QAEhE,IAAIoB,SAASlB,QAAQkB,MAAM;QAC3B,IAAI,CAACA,QAAQ;YACX,MAAMC,eAAe,MAAMhB,QAAQI,GAAG;YACtCW,SAASjC,gBAAgB;gBACvBmC,OAAOD,aAAaZ,GAAG,CAAC;gBACxBc,WAAWF,aAAaZ,GAAG,CAAC;YAC9B;QACF;QAEA,OAAO;YACLN;YACAe;YACAlB;YACAoB;YACAI,iBACEX,QAAOT,wBAAAA,iBAAiBK,GAAG,CAAC,sBAArBL,wBAAmC,OAC1CD,MAAMsB,cAAc,IACpB;YACFC,mBAAmBb,QAAOT,yBAAAA,iBAAiBK,GAAG,CAAC,wBAArBL,yBAAqC;QACjE;IACF,EAAE,OAAOuB,OAAO;QACd,yEAAyE;QACzE,6DAA6D;QAC7DC,QAAQD,KAAK,CAAC,CAAC,oBAAoB,EAAE1B,YAAY,eAAe,CAAC,EAAE0B;QACnE,OAAO;IACT;AACF;AAEA;;;;CAIC,GACD,OAAO,SAASE,sBACdC,MAAuB,EACvBC,QAAgC,CAAC,CAAC,EAClCC,QAAwC;QAyBVC;IAvB9B,MAAMA,WAAW3C,gBAAgB;QAC/B4B,OAAOY,OAAOZ,KAAK;QACnB,0EAA0E;QAC1E,0EAA0E;QAC1E,qEAAqE;QACrE,4CAA4C;QAC5Cc;QACA,+CAA+C;QAC/CE,QAAQ7C;QACR8C,SAAS5C,sBAAsBuC,OAAON,eAAe,EAAEO;QACvDK,WAAW7C,sBAAsBuC,OAAOJ,iBAAiB,EAAEK;QAC3DA;QACA,yEAAyE;QACzE,0DAA0D;QAC1DM,aAAaP,OAAOV,MAAM;QAC1BkB,aAAaR,OAAO9B,MAAM;IAC5B;IACA,IAAI,EAACiC,4BAAAA,SAAUM,IAAI,GAAE,OAAO;IAC5B,OAAO;QACLJ,SAASxC,sBACPJ,sBAAsBuC,OAAON,eAAe,EAAEO;QAEhDQ,MAAM5C,sBAAsBsC,SAASM,IAAI;QACzCC,MAAM7C,uBAAsBsC,iBAAAA,SAASO,IAAI,YAAbP,iBAAiB;IAC/C;AACF;AAEA;;;;;CAKC,GACD,OAAO,eAAeQ,gBACpB1C,SAA6B,EAC7BC,MAAc,EACdC,WAAmB,EACnB8B,QAAgC,CAAC,CAAC,EAClC7B,OAGC;IAED,MAAM4B,SAAS,MAAMhC,cAAcC,WAAWC,QAAQC,aAAaC;IACnE,OAAO4B,SAASD,sBAAsBC,QAAQC,OAAO7B,QAAQ8B,QAAQ,IAAI;AAC3E"}