@aglyn/shared-util-email 1.0.0-beta.181 → 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/README.md CHANGED
@@ -76,6 +76,35 @@ this point, so ordinary senders never meet the guard — it is there because
76
76
  `RESEND_SEND_ENDPOINT` is exported and a module that fetches it directly
77
77
  bypasses every check `sendEmail()` owns.
78
78
 
79
+ ### The List-Unsubscribe setting
80
+
81
+ `list-unsubscribe.ts` is the one place the `List-Unsubscribe` /
82
+ `List-Unsubscribe-Post` (RFC 8058) pair is modeled, for every sender that makes
83
+ it a setting — an outreach sequence (default off) and a marketing campaign
84
+ (default on):
85
+
86
+ ```typescript
87
+ import {
88
+ decideListUnsubscribe,
89
+ listUnsubscribeHeaders,
90
+ readListUnsubscribeSetting,
91
+ } from '@aglyn/shared-util-email/list-unsubscribe'
92
+
93
+ const requested = readListUnsubscribeSetting(stored.listUnsubscribe, true)
94
+ const decision = decideListUnsubscribe({
95
+ requested,
96
+ recentVolume, // the organization's campaign messages in the last day
97
+ sendVolume, // everyone this email will address
98
+ })
99
+ const headers = decision.on ? listUnsubscribeHeaders({ url: oneClickUrl }) : {}
100
+ ```
101
+
102
+ `decideListUnsubscribe` is the bulk-sender guard: with the setting off, a send
103
+ that takes the organization to `EMAIL_LIST_UNSUBSCRIBE_BULK_THRESHOLD` messages
104
+ (default 5,000, Gmail's and Yahoo's line) carries the pair anyway, and the
105
+ decision says it was forced and why. The headers are plain headers, so any
106
+ provider behind `sendEmail` delivers them.
107
+
79
108
  ## Checking configuration
80
109
 
81
110
  ```typescript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/shared-util-email",
3
- "version": "1.0.0-beta.181",
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.181",
29
- "@aglyn/shared-util-http": "1.0.0-beta.181",
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
  },
package/src/index.d.ts CHANGED
@@ -19,6 +19,7 @@ export * from './lib/send-rate';
19
19
  export * from './lib/sender-reputation';
20
20
  export * from './lib/send-ceilings';
21
21
  export * from './lib/sending-domain';
22
+ export * from './lib/tracking-host';
22
23
  export * from './lib/sending-mailbox';
23
24
  export * from './lib/host-sender';
24
25
  export * from './lib/platform-sending-domain';
@@ -29,6 +30,7 @@ export * from './lib/host-email-render';
29
30
  export * from './lib/email-render';
30
31
  export * from './lib/text-email-html';
31
32
  export * from './lib/marketing-send';
33
+ export * from './lib/list-unsubscribe';
32
34
  export * from './lib/email-delivery-events';
33
35
  export * from './lib/received-email';
34
36
  export * from './lib/email-revenue-window';
package/src/index.js CHANGED
@@ -18,6 +18,7 @@ export * from "./lib/send-rate.js";
18
18
  export * from "./lib/sender-reputation.js";
19
19
  export * from "./lib/send-ceilings.js";
20
20
  export * from "./lib/sending-domain.js";
21
+ export * from "./lib/tracking-host.js";
21
22
  export * from "./lib/sending-mailbox.js";
22
23
  export * from "./lib/host-sender.js";
23
24
  export * from "./lib/platform-sending-domain.js";
@@ -28,6 +29,8 @@ export * from "./lib/host-email-render.js";
28
29
  export * from "./lib/email-render.js";
29
30
  export * from "./lib/text-email-html.js";
30
31
  export * from "./lib/marketing-send.js";
32
+ // The List-Unsubscribe setting sequences and campaigns share (AGL-3307).
33
+ export * from "./lib/list-unsubscribe.js";
31
34
  export * from "./lib/email-delivery-events.js";
32
35
  // The received-mail reader the CRM's capture route files from (AGL-2657).
33
36
  // Its neighbor `./lib/svix-signature` is deliberately NOT re-exported here:
package/src/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/shared/util/email/src/index.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\nexport * from './lib/send-email'\nexport * from './lib/send-rate'\nexport * from './lib/sender-reputation'\nexport * from './lib/send-ceilings'\nexport * from './lib/sending-domain'\nexport * from './lib/sending-mailbox'\nexport * from './lib/host-sender'\nexport * from './lib/platform-sending-domain'\nexport * from './lib/email-health'\nexport * from './lib/system-email-catalog'\nexport * from './lib/tenant-email-catalog'\nexport * from './lib/host-email-render'\nexport * from './lib/email-render'\nexport * from './lib/text-email-html'\nexport * from './lib/marketing-send'\nexport * from './lib/email-delivery-events'\n// The received-mail reader the CRM's capture route files from (AGL-2657).\n// Its neighbor `./lib/svix-signature` is deliberately NOT re-exported here:\n// it holds a `crypto` HMAC, and this barrel is reached from the browser\n// through `@aglyn/shared-ui-email-campaigns`, so a webhook imports it by its\n// own path — `@aglyn/shared-util-email/svix-signature`.\nexport * from './lib/received-email'\nexport * from './lib/email-revenue-window'\n// Exported so the console drift guard can pin it against @aglyn/aglyn.\nexport * from './lib/email-media-src'\n// Same arrangement, same reason (AGL-1223): a copy of `decodeStoredNodes`\n// this scope:shared lib may not import, pinned by `email-nodes-drift.spec.ts`.\nexport * from './lib/stored-email-nodes'\nexport * from './lib/email-merge'\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,cAAc,sBAAkB;AAChC,cAAc,qBAAiB;AAC/B,cAAc,6BAAyB;AACvC,cAAc,yBAAqB;AACnC,cAAc,0BAAsB;AACpC,cAAc,2BAAuB;AACrC,cAAc,uBAAmB;AACjC,cAAc,mCAA+B;AAC7C,cAAc,wBAAoB;AAClC,cAAc,gCAA4B;AAC1C,cAAc,gCAA4B;AAC1C,cAAc,6BAAyB;AACvC,cAAc,wBAAoB;AAClC,cAAc,2BAAuB;AACrC,cAAc,0BAAsB;AACpC,cAAc,iCAA6B;AAC3C,0EAA0E;AAC1E,4EAA4E;AAC5E,wEAAwE;AACxE,6EAA6E;AAC7E,wDAAwD;AACxD,cAAc,0BAAsB;AACpC,cAAc,gCAA4B;AAC1C,uEAAuE;AACvE,cAAc,2BAAuB;AACrC,0EAA0E;AAC1E,+EAA+E;AAC/E,cAAc,8BAA0B;AACxC,cAAc,uBAAmB"}
1
+ {"version":3,"sources":["../../../../../../libs/shared/util/email/src/index.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\nexport * from './lib/send-email'\nexport * from './lib/send-rate'\nexport * from './lib/sender-reputation'\nexport * from './lib/send-ceilings'\nexport * from './lib/sending-domain'\nexport * from './lib/tracking-host'\nexport * from './lib/sending-mailbox'\nexport * from './lib/host-sender'\nexport * from './lib/platform-sending-domain'\nexport * from './lib/email-health'\nexport * from './lib/system-email-catalog'\nexport * from './lib/tenant-email-catalog'\nexport * from './lib/host-email-render'\nexport * from './lib/email-render'\nexport * from './lib/text-email-html'\nexport * from './lib/marketing-send'\n// The List-Unsubscribe setting sequences and campaigns share (AGL-3307).\nexport * from './lib/list-unsubscribe'\nexport * from './lib/email-delivery-events'\n// The received-mail reader the CRM's capture route files from (AGL-2657).\n// Its neighbor `./lib/svix-signature` is deliberately NOT re-exported here:\n// it holds a `crypto` HMAC, and this barrel is reached from the browser\n// through `@aglyn/shared-ui-email-campaigns`, so a webhook imports it by its\n// own path — `@aglyn/shared-util-email/svix-signature`.\nexport * from './lib/received-email'\nexport * from './lib/email-revenue-window'\n// Exported so the console drift guard can pin it against @aglyn/aglyn.\nexport * from './lib/email-media-src'\n// Same arrangement, same reason (AGL-1223): a copy of `decodeStoredNodes`\n// this scope:shared lib may not import, pinned by `email-nodes-drift.spec.ts`.\nexport * from './lib/stored-email-nodes'\nexport * from './lib/email-merge'\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,cAAc,sBAAkB;AAChC,cAAc,qBAAiB;AAC/B,cAAc,6BAAyB;AACvC,cAAc,yBAAqB;AACnC,cAAc,0BAAsB;AACpC,cAAc,yBAAqB;AACnC,cAAc,2BAAuB;AACrC,cAAc,uBAAmB;AACjC,cAAc,mCAA+B;AAC7C,cAAc,wBAAoB;AAClC,cAAc,gCAA4B;AAC1C,cAAc,gCAA4B;AAC1C,cAAc,6BAAyB;AACvC,cAAc,wBAAoB;AAClC,cAAc,2BAAuB;AACrC,cAAc,0BAAsB;AACpC,yEAAyE;AACzE,cAAc,4BAAwB;AACtC,cAAc,iCAA6B;AAC3C,0EAA0E;AAC1E,4EAA4E;AAC5E,wEAAwE;AACxE,6EAA6E;AAC7E,wDAAwD;AACxD,cAAc,0BAAsB;AACpC,cAAc,gCAA4B;AAC1C,uEAAuE;AACvE,cAAc,2BAAuB;AACrC,0EAA0E;AAC1E,+EAA+E;AAC/E,cAAc,8BAA0B;AACxC,cAAc,uBAAmB"}
@@ -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":"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"}
@@ -0,0 +1,193 @@
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
+ /**
18
+ * THE MAIL-CLIENT UNSUBSCRIBE BUTTON, AS A SETTING (AGL-3296, AGL-3307).
19
+ *
20
+ * `List-Unsubscribe` plus `List-Unsubscribe-Post: List-Unsubscribe=One-Click`
21
+ * (RFC 8058) is what makes Gmail, Yahoo, Apple Mail and Outlook draw their own
22
+ * Unsubscribe button above a message. It is a per-sender SETTING in two
23
+ * places, with opposite defaults:
24
+ *
25
+ * | Sender | Default | Why |
26
+ * | --- | --- | --- |
27
+ * | An outreach sequence | off | one-to-one mail; the header makes it read as a mailing list |
28
+ * | A marketing campaign | on | bulk mail; Gmail and Yahoo require one-click from bulk senders |
29
+ *
30
+ * Everything the two share lives here, so neither keeps a copy: how a stored
31
+ * value is read ({@link readListUnsubscribeSetting}), how the header pair is
32
+ * written ({@link listUnsubscribeHeaders}), how a send decides whether it can
33
+ * carry the pair it promised ({@link resolveListUnsubscribe}), and — for bulk
34
+ * mail — the guard that turns the header back on when a send makes the
35
+ * organization a bulk sender ({@link decideListUnsubscribe}).
36
+ *
37
+ * ## What the setting never touches
38
+ *
39
+ * The visible way out. A campaign's footer link and a sequence's "reply 'no'"
40
+ * line are in the body whatever this says, because they are what CAN-SPAM
41
+ * and the recipient rely on; the header is a convenience a mail client may or
42
+ * may not draw. The routes that answer a one-click POST keep answering every
43
+ * link already sent, whatever the setting says now.
44
+ *
45
+ * ## Provider-neutral
46
+ *
47
+ * Nothing here knows who delivers the mail. The pair is two ordinary headers,
48
+ * handed to whatever transport the deployment runs — `sendEmail`'s provider
49
+ * seam for campaigns, the mailbox's own API for a sequence — so a self-hosted
50
+ * deployment on any provider gets the same behavior.
51
+ */
52
+ /** The one value RFC 8058 defines for `List-Unsubscribe-Post`. */
53
+ export declare const LIST_UNSUBSCRIBE_ONE_CLICK = "List-Unsubscribe=One-Click";
54
+ /**
55
+ * A stored `listUnsubscribe` setting, read with the sender's own default.
56
+ *
57
+ * Only a real boolean is a choice. Anything else — absent on every record
58
+ * written before the setting existed, `null`, a string — reads as the
59
+ * default, which is `false` for a sequence and `true` for a campaign. So a
60
+ * stored sequence never grows a header it did not ask for, and a stored
61
+ * campaign never loses one.
62
+ */
63
+ export declare function readListUnsubscribeSetting(raw: unknown, defaultOn: boolean): boolean;
64
+ /**
65
+ * The `List-Unsubscribe` header pair for one message, or `{}`.
66
+ *
67
+ * `url` is the signed HTTPS link a mailbox provider POSTs with nobody present,
68
+ * and `mailto` an address that unsubscribes whoever writes to it. Either can
69
+ * stand alone in `List-Unsubscribe`; `List-Unsubscribe-Post` is written only
70
+ * beside a URL, because advertising one-click with nothing to post to
71
+ * promises a verb nothing serves.
72
+ */
73
+ export declare function listUnsubscribeHeaders(input: {
74
+ url?: string | null;
75
+ mailto?: string | null;
76
+ }): Record<string, string>;
77
+ /** The pair one send carries, or why it carries none. */
78
+ export type ListUnsubscribeResolution = {
79
+ status: 'off';
80
+ } | {
81
+ status: 'unavailable';
82
+ } | {
83
+ status: 'ready';
84
+ url: string;
85
+ mailto: string | null;
86
+ };
87
+ /**
88
+ * Decides what one send's `List-Unsubscribe` header says.
89
+ *
90
+ * `off` unless `enabled` is exactly `true`. On, the URL is minted — and the
91
+ * mailto too, when the sender has one (`mintMailto` present) — and the result
92
+ * is `unavailable` when any part cannot be: no signing secret, no HTTPS
93
+ * origin, no mailbox address. What a caller does with `unavailable` is its
94
+ * own policy; a sequence holds the send rather than leave without the way out
95
+ * it promised.
96
+ */
97
+ export declare function resolveListUnsubscribe(input: {
98
+ enabled: boolean | null | undefined;
99
+ mintUrl: () => string | null | undefined;
100
+ mintMailto?: () => string | null | undefined;
101
+ }): ListUnsubscribeResolution;
102
+ /**
103
+ * Messages in {@link LIST_UNSUBSCRIBE_BULK_WINDOW_MS} at which an organization
104
+ * is a bulk sender and a campaign's header is turned back on.
105
+ *
106
+ * 5,000 is the figure Gmail and Yahoo publish for their bulk-sender rules, and
107
+ * one-click unsubscribe is one of those rules: a bulk sender without it is
108
+ * rejected or foldered as spam. Their count is per mailbox provider, which a
109
+ * send cannot see (a Google Workspace address is on a company's own domain),
110
+ * and a count to any one provider can never exceed the organization's total —
111
+ * so the total is the count, and it is never smaller than the provider's.
112
+ */
113
+ export declare const LIST_UNSUBSCRIBE_BULK_THRESHOLD_DEFAULT = 5000;
114
+ /** The environment variable a deployment overrides the threshold with. */
115
+ export declare const LIST_UNSUBSCRIBE_BULK_THRESHOLD_ENV = "EMAIL_LIST_UNSUBSCRIBE_BULK_THRESHOLD";
116
+ /** The window the threshold counts over. */
117
+ export declare const LIST_UNSUBSCRIBE_BULK_WINDOW_MS = 86400000;
118
+ /**
119
+ * The deployment's bulk threshold.
120
+ *
121
+ * Unset, blank or unreadable resolves to the published default, never to
122
+ * something smaller or larger by accident: a typo must not silently move a
123
+ * deliverability control. A positive whole number is taken as written — a
124
+ * self-hoster whose provider or audience draws the line elsewhere sets it
125
+ * lower or higher. There is no value that disables the guard.
126
+ *
127
+ * Read per call rather than captured at module load, because these run in
128
+ * serverless handlers whose module may be evaluated during a build, before
129
+ * the runtime env exists.
130
+ */
131
+ export declare function listUnsubscribeBulkThreshold(raw?: unknown): number;
132
+ /** Why a send carries the header its setting turned off. */
133
+ export type ListUnsubscribeForcedReason =
134
+ /** The send takes the organization to the bulk threshold. */
135
+ 'bulk-volume'
136
+ /**
137
+ * The send leaves on the shared pooled identity, whose reputation is every
138
+ * site's on it, and which refuses marketing mail without a way out.
139
+ */
140
+ | 'pooled-identity'
141
+ /**
142
+ * The organization's recent volume could not be read, so whether the send
143
+ * reaches the threshold is unknown — and the header is the safe answer.
144
+ */
145
+ | 'volume-unknown';
146
+ /** What one send does with the header, and why. */
147
+ export interface ListUnsubscribeDecision {
148
+ /** What the sender's setting asked for. */
149
+ requested: boolean;
150
+ /** Whether the send carries the header. */
151
+ on: boolean;
152
+ /** True when the setting said off and the send carries it anyway. */
153
+ forced: boolean;
154
+ /** Why it was forced; null when it was not. */
155
+ reason: ListUnsubscribeForcedReason | null;
156
+ /**
157
+ * The organization's volume the decision was made on: what it had sent in
158
+ * the window plus what this send will address.
159
+ */
160
+ volume: number;
161
+ /** The threshold it was compared with. */
162
+ threshold: number;
163
+ }
164
+ /**
165
+ * Whether one bulk send carries the header, honoring the sender's setting
166
+ * unless the send would make the organization a bulk sender.
167
+ *
168
+ * `recentVolume` is what the organization has already sent in the window and
169
+ * `sendVolume` what this send will address in total, so the question asked is
170
+ * the one the rule asks — does the organization reach the threshold — and a
171
+ * send that starts under it and would finish over it is caught at its start
172
+ * rather than halfway through. Reaching the threshold counts, because the
173
+ * rule's line is "5,000 or more".
174
+ *
175
+ * `forcedBefore` keeps a decision made for an earlier part of the same send:
176
+ * a campaign delivered over several batches does not drop the header halfway
177
+ * through because a day rolled over.
178
+ */
179
+ export declare function decideListUnsubscribe(input: {
180
+ requested: boolean;
181
+ recentVolume: number;
182
+ sendVolume: number;
183
+ threshold?: number;
184
+ pooled?: boolean;
185
+ forcedBefore?: ListUnsubscribeForcedReason | null;
186
+ }): ListUnsubscribeDecision;
187
+ /**
188
+ * One sentence for a forced decision, for the page that shows it.
189
+ *
190
+ * Written here rather than in a component so the send record, the log line
191
+ * and the campaign page say the same thing.
192
+ */
193
+ export declare function listUnsubscribeForcedDetail(decision: Pick<ListUnsubscribeDecision, 'reason' | 'volume' | 'threshold'>): string;