@aglyn/shared-util-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 (72) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +104 -0
  3. package/package.json +37 -0
  4. package/src/index.d.ts +37 -0
  5. package/src/index.js +46 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/email-delivery-events.d.ts +198 -0
  8. package/src/lib/email-delivery-events.js +310 -0
  9. package/src/lib/email-delivery-events.js.map +1 -0
  10. package/src/lib/email-health.d.ts +154 -0
  11. package/src/lib/email-health.js +264 -0
  12. package/src/lib/email-health.js.map +1 -0
  13. package/src/lib/email-media-src.d.ts +33 -0
  14. package/src/lib/email-media-src.js +113 -0
  15. package/src/lib/email-media-src.js.map +1 -0
  16. package/src/lib/email-merge.d.ts +31 -0
  17. package/src/lib/email-merge.js +36 -0
  18. package/src/lib/email-merge.js.map +1 -0
  19. package/src/lib/email-render.d.ts +114 -0
  20. package/src/lib/email-render.js +293 -0
  21. package/src/lib/email-render.js.map +1 -0
  22. package/src/lib/email-revenue-window.d.ts +55 -0
  23. package/src/lib/email-revenue-window.js +58 -0
  24. package/src/lib/email-revenue-window.js.map +1 -0
  25. package/src/lib/host-email-render.d.ts +99 -0
  26. package/src/lib/host-email-render.js +126 -0
  27. package/src/lib/host-email-render.js.map +1 -0
  28. package/src/lib/host-sender.d.ts +86 -0
  29. package/src/lib/host-sender.js +131 -0
  30. package/src/lib/host-sender.js.map +1 -0
  31. package/src/lib/marketing-send.d.ts +466 -0
  32. package/src/lib/marketing-send.js +459 -0
  33. package/src/lib/marketing-send.js.map +1 -0
  34. package/src/lib/platform-sending-domain.d.ts +362 -0
  35. package/src/lib/platform-sending-domain.js +697 -0
  36. package/src/lib/platform-sending-domain.js.map +1 -0
  37. package/src/lib/received-email.d.ts +86 -0
  38. package/src/lib/received-email.js +124 -0
  39. package/src/lib/received-email.js.map +1 -0
  40. package/src/lib/send-ceilings.d.ts +394 -0
  41. package/src/lib/send-ceilings.js +341 -0
  42. package/src/lib/send-ceilings.js.map +1 -0
  43. package/src/lib/send-email.d.ts +385 -0
  44. package/src/lib/send-email.js +586 -0
  45. package/src/lib/send-email.js.map +1 -0
  46. package/src/lib/send-rate.d.ts +298 -0
  47. package/src/lib/send-rate.js +310 -0
  48. package/src/lib/send-rate.js.map +1 -0
  49. package/src/lib/sender-reputation.d.ts +357 -0
  50. package/src/lib/sender-reputation.js +392 -0
  51. package/src/lib/sender-reputation.js.map +1 -0
  52. package/src/lib/sending-domain.d.ts +744 -0
  53. package/src/lib/sending-domain.js +798 -0
  54. package/src/lib/sending-domain.js.map +1 -0
  55. package/src/lib/sending-mailbox.d.ts +132 -0
  56. package/src/lib/sending-mailbox.js +192 -0
  57. package/src/lib/sending-mailbox.js.map +1 -0
  58. package/src/lib/stored-email-nodes.d.ts +38 -0
  59. package/src/lib/stored-email-nodes.js +78 -0
  60. package/src/lib/stored-email-nodes.js.map +1 -0
  61. package/src/lib/svix-signature.d.ts +38 -0
  62. package/src/lib/svix-signature.js +55 -0
  63. package/src/lib/svix-signature.js.map +1 -0
  64. package/src/lib/system-email-catalog.d.ts +170 -0
  65. package/src/lib/system-email-catalog.js +828 -0
  66. package/src/lib/system-email-catalog.js.map +1 -0
  67. package/src/lib/tenant-email-catalog.d.ts +78 -0
  68. package/src/lib/tenant-email-catalog.js +452 -0
  69. package/src/lib/tenant-email-catalog.js.map +1 -0
  70. package/src/lib/text-email-html.d.ts +32 -0
  71. package/src/lib/text-email-html.js +119 -0
  72. package/src/lib/text-email-html.js.map +1 -0
@@ -0,0 +1,114 @@
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
+ export interface EmailRenderProduct {
18
+ name: string;
19
+ priceLabel?: string;
20
+ imageUrl?: string;
21
+ url?: string;
22
+ }
23
+ export interface EmailRenderNode {
24
+ componentId?: string;
25
+ props?: Record<string, any>;
26
+ nodes?: string[];
27
+ }
28
+ export interface EmailRenderOptions {
29
+ /** Flat node map (screen version `nodes`). */
30
+ nodes: Record<string, EmailRenderNode | undefined>;
31
+ /** Root node id (defaults to 'root'). */
32
+ rootId?: string;
33
+ subject?: string;
34
+ /** Hidden preview line shown next to the subject in inboxes. */
35
+ preheader?: string;
36
+ /** Merge values keyed by token body, e.g. 'contact.firstName'. */
37
+ merge?: Record<string, string>;
38
+ /** Product data by id for emailProduct blocks. */
39
+ products?: Record<string, EmailRenderProduct | undefined>;
40
+ /**
41
+ * The HTML policy applied to every `emailRichtext` / `emailHtml` block.
42
+ *
43
+ * REQUIRED, and required is the point. A policy a caller may omit is a
44
+ * policy that gets omitted, and an omitted one here is not a rendering
45
+ * bug: the console renders these same nodes through `sanitizeCustomHtml`
46
+ * before an author ever sees them, so an unpoliced mail render means two
47
+ * renderers of one document disagreeing about safety, with the unsafe one
48
+ * being the one that sends.
49
+ *
50
+ * This module cannot pick the policy for itself — it is `scope:shared` and
51
+ * the sanitizer is aglyn-scoped, which nx forbids it to import — so the
52
+ * choice belongs to the caller, and the type is what makes the caller make
53
+ * it. Every send path passes `sanitizeAuthorHtml`, which is exactly what
54
+ * `sanitizeCustomHtml` delegates to, so the mailed copy and the previewed
55
+ * copy are the same function of the same string.
56
+ */
57
+ sanitize: (html: string) => string;
58
+ /**
59
+ * Absolute origin serving this email's media, e.g. `https://acme.com`
60
+ * (AGL-1224).
61
+ *
62
+ * An image an author picked with "Browse media" is stored as a
63
+ * `media:{scope}/{mediaId}` reference, which resolves to the SITE-RELATIVE
64
+ * CDN path `/api/media/cdn/…`. A browser has a page to resolve that
65
+ * against; an inbox has nothing. Without this, every picked image is a
66
+ * broken-image box in the delivered mail.
67
+ *
68
+ * It is an input rather than a lookup because this module is pure, and
69
+ * because the CDN route is mounted in BOTH the console and the tenant app
70
+ * — which origin is correct depends on whose email this is.
71
+ */
72
+ mediaOrigin?: string;
73
+ /**
74
+ * Host doc id of the site sending, so an `org:`-scoped reference is
75
+ * host-qualified. The CDN is unauthenticated and decides from the URL
76
+ * alone, so an org asset restricted to particular sites is only served
77
+ * through the qualified form.
78
+ */
79
+ mediaHostId?: string;
80
+ /**
81
+ * Absolute URL of the sender's EMAIL logo, prepended as a centred row at
82
+ * the top of the 600px table (AGL-2139).
83
+ *
84
+ * `emailLogoUrl` was a first-class field of `OrgBrandingProfile`, resolved,
85
+ * collected in the branding editor, https-validated and persisted — and read
86
+ * at ZERO render sites. An agency admin on the tier that costs the most
87
+ * filled it in, the form saved, the value round-tripped, and it appeared in
88
+ * no email ever. This is the consumer that makes it a live capability
89
+ * rather than a stored string.
90
+ *
91
+ * Absent or blank emits NOTHING — not an empty row, not a spacer. An email
92
+ * with a gap where a logo should be reads as broken; one without a logo
93
+ * reads as plain, which is the correct appearance for an org that has not
94
+ * set one.
95
+ */
96
+ brandLogoUrl?: string;
97
+ }
98
+ export interface RenderedEmail {
99
+ html: string;
100
+ text: string;
101
+ }
102
+ export declare function escapeEmailHtml(value: string): string;
103
+ /** Substitutes {{token}} occurrences; unknown tokens stay visible. */
104
+ export declare function substituteMergeTokens(value: string, merge: Record<string, string> | undefined): string;
105
+ /**
106
+ * The id the besigner roots every stored node map at — `CANVAS_ROOT_ELEMENT_ID`
107
+ * in `@aglyn/aglyn`. `renderEmailHtml` still defaults `rootId` to `'root'` for
108
+ * ad-hoc callers, but anything rendering a real besigner document MUST pass
109
+ * this: rendering a besigner map as `'root'` finds no root and emits nothing
110
+ * (AGL-765). Kept here so server code need not import the heavy `@aglyn/aglyn`
111
+ * barrel; a drift guard in the console specs asserts the two stay equal.
112
+ */
113
+ export declare const EMAIL_NODE_ROOT_ID = "_@_";
114
+ export declare function renderEmailHtml(options: EmailRenderOptions): RenderedEmail;
@@ -0,0 +1,293 @@
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
+ * Email render pipeline (AGL-348): converts a designed email screen's
18
+ * node map into email-client-compatible HTML (600px table layout,
19
+ * inlined styles, bulletproof buttons) plus a plain-text alternative.
20
+ * Pure — no I/O; callers resolve products/merge data first.
21
+ *
22
+ * Merge tokens ({{contact.firstName}}, {{unsubscribeUrl}}, …) substitute
23
+ * from the provided map; unknown tokens are left in place so a missing
24
+ * field is visible in test sends instead of silently blank.
25
+ *
26
+ * ## The safety contract, in one place
27
+ *
28
+ * Everything this emits is author-controlled, and it is emitted into mail
29
+ * that has left the building. Three rules, all applied here rather than
30
+ * left to a caller:
31
+ *
32
+ * 1. Block HTML goes through {@link EmailRenderOptions.sanitize}, which is
33
+ * REQUIRED. There is no identity default to fall back into.
34
+ * 2. Every URL is scheme-checked against `@aglyn/shared-util-http` before it
35
+ * is escaped into an attribute — escaping stops an attribute breakout and
36
+ * says nothing at all about `javascript:`.
37
+ * 3. Merge substitution into a URL cannot draw from the `contact.` namespace,
38
+ * so no template can route a recipient's own address into a query string.
39
+ *
40
+ * Mail clients strip most of this themselves, so none of the three is the
41
+ * last line of defense in an inbox. They are the only line the moment one of
42
+ * these documents renders outside one — a "view this email in your browser"
43
+ * page is the ordinary way that happens.
44
+ */ import { hasSafeLinkScheme, hasSafeMediaScheme } from "@aglyn/shared-util-http/safe-url-scheme";
45
+ import { resolveEmailMediaSrc } from "./email-media-src.js";
46
+ const FONT = 'Helvetica, Arial, sans-serif';
47
+ export function escapeEmailHtml(value) {
48
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
49
+ }
50
+ /** Substitutes {{token}} occurrences; unknown tokens stay visible. */ export function substituteMergeTokens(value, merge) {
51
+ return value.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (match, token)=>{
52
+ const replacement = merge == null ? void 0 : merge[token];
53
+ return replacement !== undefined ? replacement : match;
54
+ });
55
+ }
56
+ const TEXT_STYLES = {
57
+ heading: `font-size:28px;font-weight:700;line-height:1.25`,
58
+ subheading: `font-size:20px;font-weight:600;line-height:1.3`,
59
+ body: `font-size:15px;font-weight:400;line-height:1.55`,
60
+ caption: `font-size:12px;font-weight:400;line-height:1.4`
61
+ };
62
+ /** One full-width table row wrapping arbitrary cell HTML. */ const row = (cellHtml, cellStyle = '')=>`<tr><td style="${cellStyle}">${cellHtml}</td></tr>`;
63
+ /**
64
+ * The id the besigner roots every stored node map at — `CANVAS_ROOT_ELEMENT_ID`
65
+ * in `@aglyn/aglyn`. `renderEmailHtml` still defaults `rootId` to `'root'` for
66
+ * ad-hoc callers, but anything rendering a real besigner document MUST pass
67
+ * this: rendering a besigner map as `'root'` finds no root and emits nothing
68
+ * (AGL-765). Kept here so server code need not import the heavy `@aglyn/aglyn`
69
+ * barrel; a drift guard in the console specs asserts the two stay equal.
70
+ */ export const EMAIL_NODE_ROOT_ID = '_@_';
71
+ export function renderEmailHtml(options) {
72
+ var _imageSrc, _ref;
73
+ var _nodes_rootId;
74
+ const { nodes, rootId = 'root', subject = '', preheader = '', merge, products, sanitize, mediaOrigin, mediaHostId, brandLogoUrl } = options;
75
+ const textParts = [];
76
+ const sub = (value)=>substituteMergeTokens(String(value != null ? value : ''), merge);
77
+ /**
78
+ * The merge map as it applies inside a URL: everything except the
79
+ * `contact.` namespace.
80
+ *
81
+ * That namespace is the recipient — `contact.email`, `contact.name`,
82
+ * `contact.firstName` are what campaign-send puts in the map. Substituting
83
+ * them into an `href` or a `src` is how a template like
84
+ * `https://example.test/?e={{contact.email}}` turns every send into the
85
+ * recipient's own address on somebody's query string, and putting personal
86
+ * data in a URL is a line this codebase does not cross. The refusal has to
87
+ * live here because it is the SUBSTITUTION that creates the value: no
88
+ * scheme check and no sanitizer can see the address, since the template
89
+ * the author wrote and the editor reviewed contains only the token.
90
+ *
91
+ * Nothing else narrows: `{{unsubscribeUrl}}` and `{{site.url}}` ARE URLs
92
+ * and are the reason a token appears in this position at all.
93
+ *
94
+ * A refused token is left standing rather than blanked, which is this
95
+ * module's existing convention for a token it will not fill — a test send
96
+ * shows `?e={{contact.email}}` in the link, which names the problem to the
97
+ * author instead of silently shipping a subtly different URL.
98
+ */ const urlMerge = merge ? Object.fromEntries(Object.entries(merge).filter(([token])=>!token.startsWith('contact.'))) : undefined;
99
+ /** {@link sub} for a value that lands in a URL — see {@link urlMerge}. */ const subUrl = (value)=>substituteMergeTokens(String(value != null ? value : ''), urlMerge);
100
+ /**
101
+ * A link target safe to emit, or undefined.
102
+ *
103
+ * Escaping the value into the attribute stops a breakout and says nothing
104
+ * about the scheme, so `escapeEmailHtml` on its own passes a stored
105
+ * `javascript:` or `data:` href through verbatim. The check runs on the
106
+ * SUBSTITUTED string, because a merge value is part of the final URL and a
107
+ * template that reads clean can resolve to one that does not.
108
+ */ const linkHref = (value)=>{
109
+ const href = subUrl(value);
110
+ if (!href) return undefined;
111
+ return hasSafeLinkScheme(href) ? href : undefined;
112
+ };
113
+ const origin = mediaOrigin == null ? void 0 : mediaOrigin.replace(/\/+$/, '');
114
+ /**
115
+ * A stored image value turned into something an inbox can actually fetch,
116
+ * or undefined (AGL-1224).
117
+ *
118
+ * `resolveEmailMediaSrc` handles the three stored generations — a `media:`
119
+ * reference, the AGL-175 relative CDN path, and a plain absolute URL an
120
+ * author typed — but the first two come back site-relative, which is the
121
+ * whole bug: only a browser has a page to resolve them against.
122
+ *
123
+ * With no origin to absolutize against, the image is DROPPED rather than
124
+ * emitted relative. Both are a missing picture; a dropped one leaves a gap,
125
+ * while `src="/api/media/cdn/…"` renders as a broken-image box, which reads
126
+ * to a recipient as a broken email rather than a plain one.
127
+ *
128
+ * ⚠️ This comment used to claim "neither send path relies on this — both
129
+ * supply an origin", and treated reaching the drop as hypothetical. It was
130
+ * wrong, and it was wrong in the direction that hides the bug: campaign-send
131
+ * passed no `mediaOrigin`/`mediaHostId` at all, so every author-picked image
132
+ * was silently dropped from every marketing campaign until AGL-1394. The
133
+ * quiet failure IS still the right one for a caller that genuinely has no
134
+ * origin — but "no caller reaches this" is a claim about the whole repo, and
135
+ * it decays the moment someone adds a third send path. Verify it before
136
+ * relying on it; do not restore a count here.
137
+ *
138
+ * A protocol-relative `//host/x.png` is passed through untouched: it is
139
+ * already absolute enough to name a host, and prefixing an origin would
140
+ * corrupt it.
141
+ *
142
+ * The scheme is checked on the way out. `resolveEmailMediaSrc` recognizes
143
+ * the two stored reference forms and passes anything else through as an
144
+ * author-typed URL, so it is this check and nothing else that stands
145
+ * between what an author types and the `src` attribute. A refusal drops
146
+ * the image on the same reasoning as everything else here that cannot be
147
+ * made fetchable: a gap reads as a plain email, and a `src` we would not
148
+ * stand behind is not made safer by shipping it.
149
+ */ const imageSrc = (value)=>{
150
+ const resolved = resolveEmailMediaSrc(subUrl(value), mediaHostId);
151
+ if (!resolved || !hasSafeMediaScheme(resolved)) return undefined;
152
+ if (!resolved.startsWith('/') || resolved.startsWith('//')) return resolved;
153
+ return origin ? `${origin}${resolved}` : undefined;
154
+ };
155
+ const renderChildren = (ids)=>(ids != null ? ids : []).map((id)=>renderNode(id)).filter(Boolean).join('');
156
+ const renderNode = (id)=>{
157
+ var _node_props;
158
+ const node = nodes[id];
159
+ if (!node) return '';
160
+ const props = (_node_props = node.props) != null ? _node_props : {};
161
+ switch(node.componentId){
162
+ case 'emailSection':
163
+ {
164
+ const background = props.backgroundColor || '#ffffff';
165
+ const padding = Number.isFinite(Number(props.padding)) ? Number(props.padding) : 24;
166
+ const align = props.align || 'left';
167
+ return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" ` + `style="background-color:${background};">` + row(`<table role="presentation" width="100%" cellpadding="0" cellspacing="0">` + renderChildren(node.nodes) + `</table>`, `padding:${padding}px;text-align:${align};`) + `</table>`;
168
+ }
169
+ case 'emailText':
170
+ {
171
+ var _TEXT_STYLES_props_variant;
172
+ const text = sub(props.children);
173
+ textParts.push(text);
174
+ const style = (_TEXT_STYLES_props_variant = TEXT_STYLES[props.variant]) != null ? _TEXT_STYLES_props_variant : TEXT_STYLES['body'];
175
+ const color = props.color || '#1a1a1a';
176
+ const align = props.align || 'left';
177
+ return row(`<div style="font-family:${FONT};${style};color:${color};text-align:${align};">` + escapeEmailHtml(text).replace(/\n/g, '<br />') + `</div>`, 'padding:4px 0;');
178
+ }
179
+ case 'emailRichtext':
180
+ case 'emailHtml':
181
+ {
182
+ const html = sanitize(sub(props.html));
183
+ if (!html.trim()) return '';
184
+ textParts.push(html.replace(/<[^>]+>/g, ' ').trim());
185
+ return row(`<div style="font-family:${FONT};font-size:15px;line-height:1.55;">${html}</div>`, 'padding:4px 0;');
186
+ }
187
+ case 'emailImage':
188
+ {
189
+ var _props_alt;
190
+ const src = imageSrc(props.src);
191
+ if (!src) return '';
192
+ const width = Number(props.width) > 0 ? Number(props.width) : 600;
193
+ const alt = escapeEmailHtml(String((_props_alt = props.alt) != null ? _props_alt : ''));
194
+ const align = props.align || 'center';
195
+ const img = `<img src="${escapeEmailHtml(src)}" alt="${alt}" width="${Math.min(width, 600)}" ` + `style="display:inline-block;max-width:100%;height:auto;border:0;" />`;
196
+ // A refused href drops the WRAPPER, not the picture: the image is the
197
+ // content and the link is decoration on it.
198
+ const href = linkHref(props.href);
199
+ return row(href ? `<a href="${escapeEmailHtml(href)}" target="_blank">${img}</a>` : img, `padding:8px 0;text-align:${align};`);
200
+ }
201
+ case 'emailButton':
202
+ {
203
+ var _props_children, _linkHref, _props_href;
204
+ const label = sub((_props_children = props.children) != null ? _props_children : 'Call to action');
205
+ // A refused href falls back to the placeholder the empty case already
206
+ // uses, so the button keeps its place in the layout and goes nowhere.
207
+ // Dropping the row instead would take the author's copy with it, and
208
+ // an inert button reads as an unfinished email rather than a broken
209
+ // one. The plain-text alternative gets the same value the anchor
210
+ // does — a text/plain body is inert, but it is still delivered mail
211
+ // and a refused URL does not belong in it either.
212
+ const href = (_linkHref = linkHref((_props_href = props.href) != null ? _props_href : '#')) != null ? _linkHref : '#';
213
+ const background = props.backgroundColor || '#1a73e8';
214
+ const color = props.color || '#ffffff';
215
+ const align = props.align || 'center';
216
+ textParts.push(`${label}: ${href}`);
217
+ // Bulletproof-ish button: padded anchor, table-aligned.
218
+ return row(`<a href="${escapeEmailHtml(href)}" target="_blank" ` + `style="display:inline-block;padding:12px 28px;border-radius:6px;` + `background-color:${background};color:${color};font-family:${FONT};` + `font-size:15px;font-weight:600;text-decoration:none;">` + escapeEmailHtml(label) + `</a>`, `padding:12px 0;text-align:${align};`);
219
+ }
220
+ case 'emailDivider':
221
+ {
222
+ const color = props.color || '#e0e0e0';
223
+ return row(`<div style="border-top:1px solid ${color};font-size:0;line-height:0;">&nbsp;</div>`, 'padding:12px 0;');
224
+ }
225
+ case 'emailSpacer':
226
+ {
227
+ const height = Number(props.height) > 0 ? Number(props.height) : 24;
228
+ return row(`<div style="height:${height}px;font-size:0;line-height:0;">&nbsp;</div>`);
229
+ }
230
+ case 'emailProduct':
231
+ {
232
+ var _props_buttonLabel;
233
+ const product = props.productId ? products == null ? void 0 : products[String(props.productId)] : undefined;
234
+ if (!product) return '';
235
+ const label = sub((_props_buttonLabel = props.buttonLabel) != null ? _props_buttonLabel : 'Shop now');
236
+ // Catalog data rather than author markup, and the caller absolutizes
237
+ // it before it arrives — but "it came from a trusted table" is a
238
+ // claim about every writer of that table, so the same rule applies
239
+ // here as to an author-typed href. Refused means no button and no
240
+ // line in the text part; the product card itself still renders.
241
+ const productUrl = product.url && hasSafeLinkScheme(product.url) ? product.url : undefined;
242
+ textParts.push(`${product.name}${product.priceLabel ? ` — ${product.priceLabel}` : ''}` + (productUrl ? `: ${productUrl}` : ''));
243
+ // Same treatment as emailImage: a catalog image can be a picked
244
+ // asset, so it carries the same reference/relative forms (AGL-1224).
245
+ const productImage = imageSrc(product.imageUrl);
246
+ const image = productImage ? `<img src="${escapeEmailHtml(productImage)}" alt="${escapeEmailHtml(product.name)}" width="280" style="max-width:100%;height:auto;border:0;border-radius:6px;" /><br />` : '';
247
+ const button = productUrl ? `<a href="${escapeEmailHtml(productUrl)}" target="_blank" style="display:inline-block;margin-top:8px;padding:10px 24px;border-radius:6px;background-color:#1a73e8;color:#ffffff;font-family:${FONT};font-size:14px;font-weight:600;text-decoration:none;">${escapeEmailHtml(label)}</a>` : '';
248
+ return row(`<div style="border:1px solid #e0e0e0;border-radius:8px;padding:16px;text-align:center;font-family:${FONT};">` + image + `<div style="font-size:16px;font-weight:600;margin-top:8px;">${escapeEmailHtml(product.name)}</div>` + (product.priceLabel ? `<div style="font-size:14px;color:#555555;margin-top:2px;">${escapeEmailHtml(product.priceLabel)}</div>` : '') + button + `</div>`, 'padding:8px 0;');
249
+ }
250
+ default:
251
+ {
252
+ // Unknown/web components: render their children so mixed documents
253
+ // degrade gracefully instead of dropping content.
254
+ return renderChildren(node.nodes);
255
+ }
256
+ }
257
+ };
258
+ const body = renderNode(rootId) || renderChildren((_nodes_rootId = nodes[rootId]) == null ? void 0 : _nodes_rootId.nodes);
259
+ const preheaderHtml = preheader ? `<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">` + escapeEmailHtml(sub(preheader)) + `</div>` : '';
260
+ // INSIDE the 600px table, above the designed body, so it inherits the
261
+ // column's width on a phone instead of being centred against the viewport.
262
+ // `alt` is the product name where one is known, because a logo blocked by
263
+ // the client (which is the default in most inboxes) must still say who sent
264
+ // the mail. Height is capped rather than set, so a wordmark and a square
265
+ // mark both land at a sane size without the sender supplying dimensions.
266
+ //
267
+ // The src goes through `imageSrc` like every other image in the message
268
+ // (AGL-2230), and NOT into the tag raw. `emailLogoUrl` is collected by
269
+ // `MediaUrlField`, whose "Browse" button writes the picked asset's
270
+ // site-relative `cdnPath` — and the org DAM's other generation stores a
271
+ // `media:{scope}/{mediaId}` reference. Neither is a URL an inbox can
272
+ // resolve: there is no page to resolve it against, so a raw pass-through
273
+ // put `src="media:h1/med9"` at the top of every transactional email a
274
+ // white-label org sent. `imageSrc` absolutizes both forms against
275
+ // `mediaOrigin`, passes a real https URL through untouched, and returns
276
+ // undefined when it cannot produce something fetchable — in which case the
277
+ // row is dropped, on the same reasoning as AGL-1224: a gap reads as a plain
278
+ // email, a broken-image box reads as a broken one.
279
+ const brandLogo = (_imageSrc = imageSrc(String(brandLogoUrl != null ? brandLogoUrl : '').trim())) != null ? _imageSrc : '';
280
+ // The product name from the merge map, because most inboxes block images by
281
+ // default — a logo with no `alt` is a blank box where the sender's identity
282
+ // should be. Falls back to nothing rather than to a literal, which would
283
+ // reintroduce the hard-coded brand this whole change removes.
284
+ const brandLogoAlt = (_ref = merge == null ? void 0 : merge['brand.productName']) != null ? _ref : '';
285
+ const brandLogoHtml = brandLogo ? row(`<img src="${escapeEmailHtml(brandLogo)}" alt="${escapeEmailHtml(brandLogoAlt)}" ` + `style="max-height:48px;max-width:200px;display:block;margin:0 auto;border:0;" />`, 'padding:24px 24px 0;text-align:center;') : '';
286
+ const html = `<!DOCTYPE html><html><head><meta charset="utf-8" />` + `<meta name="viewport" content="width=device-width, initial-scale=1" />` + `<title>${escapeEmailHtml(sub(subject))}</title></head>` + `<body style="margin:0;padding:0;background-color:#f4f4f4;">` + preheaderHtml + `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f4;">` + row(`<table role="presentation" width="600" cellpadding="0" cellspacing="0" align="center" style="max-width:600px;width:100%;margin:0 auto;">` + brandLogoHtml + body + `</table>`, 'padding:24px 8px;') + `</table></body></html>`;
287
+ return {
288
+ html,
289
+ text: textParts.join('\n\n')
290
+ };
291
+ }
292
+
293
+ //# sourceMappingURL=email-render.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/email-render.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Email render pipeline (AGL-348): converts a designed email screen's\n * node map into email-client-compatible HTML (600px table layout,\n * inlined styles, bulletproof buttons) plus a plain-text alternative.\n * Pure — no I/O; callers resolve products/merge data first.\n *\n * Merge tokens ({{contact.firstName}}, {{unsubscribeUrl}}, …) substitute\n * from the provided map; unknown tokens are left in place so a missing\n * field is visible in test sends instead of silently blank.\n *\n * ## The safety contract, in one place\n *\n * Everything this emits is author-controlled, and it is emitted into mail\n * that has left the building. Three rules, all applied here rather than\n * left to a caller:\n *\n * 1. Block HTML goes through {@link EmailRenderOptions.sanitize}, which is\n * REQUIRED. There is no identity default to fall back into.\n * 2. Every URL is scheme-checked against `@aglyn/shared-util-http` before it\n * is escaped into an attribute — escaping stops an attribute breakout and\n * says nothing at all about `javascript:`.\n * 3. Merge substitution into a URL cannot draw from the `contact.` namespace,\n * so no template can route a recipient's own address into a query string.\n *\n * Mail clients strip most of this themselves, so none of the three is the\n * last line of defense in an inbox. They are the only line the moment one of\n * these documents renders outside one — a \"view this email in your browser\"\n * page is the ordinary way that happens.\n */\n\nimport {\n hasSafeLinkScheme,\n hasSafeMediaScheme,\n} from '@aglyn/shared-util-http/safe-url-scheme'\n\nimport { resolveEmailMediaSrc } from './email-media-src'\n\nexport interface EmailRenderProduct {\n name: string\n priceLabel?: string\n imageUrl?: string\n url?: string\n}\n\nexport interface EmailRenderNode {\n componentId?: string\n props?: Record<string, any>\n nodes?: string[]\n}\n\nexport interface EmailRenderOptions {\n /** Flat node map (screen version `nodes`). */\n nodes: Record<string, EmailRenderNode | undefined>\n /** Root node id (defaults to 'root'). */\n rootId?: string\n subject?: string\n /** Hidden preview line shown next to the subject in inboxes. */\n preheader?: string\n /** Merge values keyed by token body, e.g. 'contact.firstName'. */\n merge?: Record<string, string>\n /** Product data by id for emailProduct blocks. */\n products?: Record<string, EmailRenderProduct | undefined>\n /**\n * The HTML policy applied to every `emailRichtext` / `emailHtml` block.\n *\n * REQUIRED, and required is the point. A policy a caller may omit is a\n * policy that gets omitted, and an omitted one here is not a rendering\n * bug: the console renders these same nodes through `sanitizeCustomHtml`\n * before an author ever sees them, so an unpoliced mail render means two\n * renderers of one document disagreeing about safety, with the unsafe one\n * being the one that sends.\n *\n * This module cannot pick the policy for itself — it is `scope:shared` and\n * the sanitizer is aglyn-scoped, which nx forbids it to import — so the\n * choice belongs to the caller, and the type is what makes the caller make\n * it. Every send path passes `sanitizeAuthorHtml`, which is exactly what\n * `sanitizeCustomHtml` delegates to, so the mailed copy and the previewed\n * copy are the same function of the same string.\n */\n sanitize: (html: string) => string\n /**\n * Absolute origin serving this email's media, e.g. `https://acme.com`\n * (AGL-1224).\n *\n * An image an author picked with \"Browse media\" is stored as a\n * `media:{scope}/{mediaId}` reference, which resolves to the SITE-RELATIVE\n * CDN path `/api/media/cdn/…`. A browser has a page to resolve that\n * against; an inbox has nothing. Without this, every picked image is a\n * broken-image box in the delivered mail.\n *\n * It is an input rather than a lookup because this module is pure, and\n * because the CDN route is mounted in BOTH the console and the tenant app\n * — which origin is correct depends on whose email this is.\n */\n mediaOrigin?: string\n /**\n * Host doc id of the site sending, so an `org:`-scoped reference is\n * host-qualified. The CDN is unauthenticated and decides from the URL\n * alone, so an org asset restricted to particular sites is only served\n * through the qualified form.\n */\n mediaHostId?: string\n /**\n * Absolute URL of the sender's EMAIL logo, prepended as a centred row at\n * the top of the 600px table (AGL-2139).\n *\n * `emailLogoUrl` was a first-class field of `OrgBrandingProfile`, resolved,\n * collected in the branding editor, https-validated and persisted — and read\n * at ZERO render sites. An agency admin on the tier that costs the most\n * filled it in, the form saved, the value round-tripped, and it appeared in\n * no email ever. This is the consumer that makes it a live capability\n * rather than a stored string.\n *\n * Absent or blank emits NOTHING — not an empty row, not a spacer. An email\n * with a gap where a logo should be reads as broken; one without a logo\n * reads as plain, which is the correct appearance for an org that has not\n * set one.\n */\n brandLogoUrl?: string\n}\n\nexport interface RenderedEmail {\n html: string\n text: string\n}\n\nconst FONT = 'Helvetica, Arial, sans-serif'\n\nexport function escapeEmailHtml(value: string): string {\n return value\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n}\n\n/** Substitutes {{token}} occurrences; unknown tokens stay visible. */\nexport function substituteMergeTokens(\n value: string,\n merge: Record<string, string> | undefined,\n): string {\n return value.replace(/\\{\\{\\s*([\\w.]+)\\s*\\}\\}/g, (match, token: string) => {\n const replacement = merge?.[token]\n return replacement !== undefined ? replacement : match\n })\n}\n\nconst TEXT_STYLES: Record<string, string> = {\n heading: `font-size:28px;font-weight:700;line-height:1.25`,\n subheading: `font-size:20px;font-weight:600;line-height:1.3`,\n body: `font-size:15px;font-weight:400;line-height:1.55`,\n caption: `font-size:12px;font-weight:400;line-height:1.4`,\n}\n\n/** One full-width table row wrapping arbitrary cell HTML. */\nconst row = (cellHtml: string, cellStyle = ''): string =>\n `<tr><td style=\"${cellStyle}\">${cellHtml}</td></tr>`\n\n/**\n * The id the besigner roots every stored node map at — `CANVAS_ROOT_ELEMENT_ID`\n * in `@aglyn/aglyn`. `renderEmailHtml` still defaults `rootId` to `'root'` for\n * ad-hoc callers, but anything rendering a real besigner document MUST pass\n * this: rendering a besigner map as `'root'` finds no root and emits nothing\n * (AGL-765). Kept here so server code need not import the heavy `@aglyn/aglyn`\n * barrel; a drift guard in the console specs asserts the two stay equal.\n */\nexport const EMAIL_NODE_ROOT_ID = '_@_'\n\nexport function renderEmailHtml(options: EmailRenderOptions): RenderedEmail {\n const {\n nodes,\n rootId = 'root',\n subject = '',\n preheader = '',\n merge,\n products,\n sanitize,\n mediaOrigin,\n mediaHostId,\n brandLogoUrl,\n } = options\n\n const textParts: string[] = []\n const sub = (value: unknown): string =>\n substituteMergeTokens(String(value ?? ''), merge)\n\n /**\n * The merge map as it applies inside a URL: everything except the\n * `contact.` namespace.\n *\n * That namespace is the recipient — `contact.email`, `contact.name`,\n * `contact.firstName` are what campaign-send puts in the map. Substituting\n * them into an `href` or a `src` is how a template like\n * `https://example.test/?e={{contact.email}}` turns every send into the\n * recipient's own address on somebody's query string, and putting personal\n * data in a URL is a line this codebase does not cross. The refusal has to\n * live here because it is the SUBSTITUTION that creates the value: no\n * scheme check and no sanitizer can see the address, since the template\n * the author wrote and the editor reviewed contains only the token.\n *\n * Nothing else narrows: `{{unsubscribeUrl}}` and `{{site.url}}` ARE URLs\n * and are the reason a token appears in this position at all.\n *\n * A refused token is left standing rather than blanked, which is this\n * module's existing convention for a token it will not fill — a test send\n * shows `?e={{contact.email}}` in the link, which names the problem to the\n * author instead of silently shipping a subtly different URL.\n */\n const urlMerge = merge\n ? Object.fromEntries(\n Object.entries(merge).filter(\n ([token]) => !token.startsWith('contact.'),\n ),\n )\n : undefined\n\n /** {@link sub} for a value that lands in a URL — see {@link urlMerge}. */\n const subUrl = (value: unknown): string =>\n substituteMergeTokens(String(value ?? ''), urlMerge)\n\n /**\n * A link target safe to emit, or undefined.\n *\n * Escaping the value into the attribute stops a breakout and says nothing\n * about the scheme, so `escapeEmailHtml` on its own passes a stored\n * `javascript:` or `data:` href through verbatim. The check runs on the\n * SUBSTITUTED string, because a merge value is part of the final URL and a\n * template that reads clean can resolve to one that does not.\n */\n const linkHref = (value: unknown): string | undefined => {\n const href = subUrl(value)\n if (!href) return undefined\n return hasSafeLinkScheme(href) ? href : undefined\n }\n\n const origin = mediaOrigin?.replace(/\\/+$/, '')\n\n /**\n * A stored image value turned into something an inbox can actually fetch,\n * or undefined (AGL-1224).\n *\n * `resolveEmailMediaSrc` handles the three stored generations — a `media:`\n * reference, the AGL-175 relative CDN path, and a plain absolute URL an\n * author typed — but the first two come back site-relative, which is the\n * whole bug: only a browser has a page to resolve them against.\n *\n * With no origin to absolutize against, the image is DROPPED rather than\n * emitted relative. Both are a missing picture; a dropped one leaves a gap,\n * while `src=\"/api/media/cdn/…\"` renders as a broken-image box, which reads\n * to a recipient as a broken email rather than a plain one.\n *\n * ⚠️ This comment used to claim \"neither send path relies on this — both\n * supply an origin\", and treated reaching the drop as hypothetical. It was\n * wrong, and it was wrong in the direction that hides the bug: campaign-send\n * passed no `mediaOrigin`/`mediaHostId` at all, so every author-picked image\n * was silently dropped from every marketing campaign until AGL-1394. The\n * quiet failure IS still the right one for a caller that genuinely has no\n * origin — but \"no caller reaches this\" is a claim about the whole repo, and\n * it decays the moment someone adds a third send path. Verify it before\n * relying on it; do not restore a count here.\n *\n * A protocol-relative `//host/x.png` is passed through untouched: it is\n * already absolute enough to name a host, and prefixing an origin would\n * corrupt it.\n *\n * The scheme is checked on the way out. `resolveEmailMediaSrc` recognizes\n * the two stored reference forms and passes anything else through as an\n * author-typed URL, so it is this check and nothing else that stands\n * between what an author types and the `src` attribute. A refusal drops\n * the image on the same reasoning as everything else here that cannot be\n * made fetchable: a gap reads as a plain email, and a `src` we would not\n * stand behind is not made safer by shipping it.\n */\n const imageSrc = (value: unknown): string | undefined => {\n const resolved = resolveEmailMediaSrc(subUrl(value), mediaHostId)\n if (!resolved || !hasSafeMediaScheme(resolved)) return undefined\n if (!resolved.startsWith('/') || resolved.startsWith('//')) return resolved\n return origin ? `${origin}${resolved}` : undefined\n }\n\n const renderChildren = (ids: string[] | undefined): string =>\n (ids ?? [])\n .map((id) => renderNode(id))\n .filter(Boolean)\n .join('')\n\n const renderNode = (id: string): string => {\n const node = nodes[id]\n if (!node) return ''\n const props = node.props ?? {}\n switch (node.componentId) {\n case 'emailSection': {\n const background = props.backgroundColor || '#ffffff'\n const padding = Number.isFinite(Number(props.padding))\n ? Number(props.padding)\n : 24\n const align = props.align || 'left'\n return (\n `<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" ` +\n `style=\"background-color:${background};\">` +\n row(\n `<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\">` +\n renderChildren(node.nodes) +\n `</table>`,\n `padding:${padding}px;text-align:${align};`,\n ) +\n `</table>`\n )\n }\n case 'emailText': {\n const text = sub(props.children)\n textParts.push(text)\n const style = TEXT_STYLES[props.variant as string] ?? TEXT_STYLES['body']\n const color = props.color || '#1a1a1a'\n const align = props.align || 'left'\n return row(\n `<div style=\"font-family:${FONT};${style};color:${color};text-align:${align};\">` +\n escapeEmailHtml(text).replace(/\\n/g, '<br />') +\n `</div>`,\n 'padding:4px 0;',\n )\n }\n case 'emailRichtext':\n case 'emailHtml': {\n const html = sanitize(sub(props.html))\n if (!html.trim()) return ''\n textParts.push(html.replace(/<[^>]+>/g, ' ').trim())\n return row(\n `<div style=\"font-family:${FONT};font-size:15px;line-height:1.55;\">${html}</div>`,\n 'padding:4px 0;',\n )\n }\n case 'emailImage': {\n const src = imageSrc(props.src)\n if (!src) return ''\n const width = Number(props.width) > 0 ? Number(props.width) : 600\n const alt = escapeEmailHtml(String(props.alt ?? ''))\n const align = props.align || 'center'\n const img =\n `<img src=\"${escapeEmailHtml(src)}\" alt=\"${alt}\" width=\"${Math.min(width, 600)}\" ` +\n `style=\"display:inline-block;max-width:100%;height:auto;border:0;\" />`\n // A refused href drops the WRAPPER, not the picture: the image is the\n // content and the link is decoration on it.\n const href = linkHref(props.href)\n return row(\n href\n ? `<a href=\"${escapeEmailHtml(href)}\" target=\"_blank\">${img}</a>`\n : img,\n `padding:8px 0;text-align:${align};`,\n )\n }\n case 'emailButton': {\n const label = sub(props.children ?? 'Call to action')\n // A refused href falls back to the placeholder the empty case already\n // uses, so the button keeps its place in the layout and goes nowhere.\n // Dropping the row instead would take the author's copy with it, and\n // an inert button reads as an unfinished email rather than a broken\n // one. The plain-text alternative gets the same value the anchor\n // does — a text/plain body is inert, but it is still delivered mail\n // and a refused URL does not belong in it either.\n const href = linkHref(props.href ?? '#') ?? '#'\n const background = props.backgroundColor || '#1a73e8'\n const color = props.color || '#ffffff'\n const align = props.align || 'center'\n textParts.push(`${label}: ${href}`)\n // Bulletproof-ish button: padded anchor, table-aligned.\n return row(\n `<a href=\"${escapeEmailHtml(href)}\" target=\"_blank\" ` +\n `style=\"display:inline-block;padding:12px 28px;border-radius:6px;` +\n `background-color:${background};color:${color};font-family:${FONT};` +\n `font-size:15px;font-weight:600;text-decoration:none;\">` +\n escapeEmailHtml(label) +\n `</a>`,\n `padding:12px 0;text-align:${align};`,\n )\n }\n case 'emailDivider': {\n const color = props.color || '#e0e0e0'\n return row(\n `<div style=\"border-top:1px solid ${color};font-size:0;line-height:0;\">&nbsp;</div>`,\n 'padding:12px 0;',\n )\n }\n case 'emailSpacer': {\n const height = Number(props.height) > 0 ? Number(props.height) : 24\n return row(\n `<div style=\"height:${height}px;font-size:0;line-height:0;\">&nbsp;</div>`,\n )\n }\n case 'emailProduct': {\n const product = props.productId\n ? products?.[String(props.productId)]\n : undefined\n if (!product) return ''\n const label = sub(props.buttonLabel ?? 'Shop now')\n // Catalog data rather than author markup, and the caller absolutizes\n // it before it arrives — but \"it came from a trusted table\" is a\n // claim about every writer of that table, so the same rule applies\n // here as to an author-typed href. Refused means no button and no\n // line in the text part; the product card itself still renders.\n const productUrl =\n product.url && hasSafeLinkScheme(product.url)\n ? product.url\n : undefined\n textParts.push(\n `${product.name}${product.priceLabel ? ` — ${product.priceLabel}` : ''}` +\n (productUrl ? `: ${productUrl}` : ''),\n )\n // Same treatment as emailImage: a catalog image can be a picked\n // asset, so it carries the same reference/relative forms (AGL-1224).\n const productImage = imageSrc(product.imageUrl)\n const image = productImage\n ? `<img src=\"${escapeEmailHtml(productImage)}\" alt=\"${escapeEmailHtml(product.name)}\" width=\"280\" style=\"max-width:100%;height:auto;border:0;border-radius:6px;\" /><br />`\n : ''\n const button = productUrl\n ? `<a href=\"${escapeEmailHtml(productUrl)}\" target=\"_blank\" style=\"display:inline-block;margin-top:8px;padding:10px 24px;border-radius:6px;background-color:#1a73e8;color:#ffffff;font-family:${FONT};font-size:14px;font-weight:600;text-decoration:none;\">${escapeEmailHtml(label)}</a>`\n : ''\n return row(\n `<div style=\"border:1px solid #e0e0e0;border-radius:8px;padding:16px;text-align:center;font-family:${FONT};\">` +\n image +\n `<div style=\"font-size:16px;font-weight:600;margin-top:8px;\">${escapeEmailHtml(product.name)}</div>` +\n (product.priceLabel\n ? `<div style=\"font-size:14px;color:#555555;margin-top:2px;\">${escapeEmailHtml(product.priceLabel)}</div>`\n : '') +\n button +\n `</div>`,\n 'padding:8px 0;',\n )\n }\n default: {\n // Unknown/web components: render their children so mixed documents\n // degrade gracefully instead of dropping content.\n return renderChildren(node.nodes)\n }\n }\n }\n\n const body = renderNode(rootId) || renderChildren(nodes[rootId]?.nodes)\n const preheaderHtml = preheader\n ? `<div style=\"display:none;max-height:0;overflow:hidden;mso-hide:all;\">` +\n escapeEmailHtml(sub(preheader)) +\n `</div>`\n : ''\n\n // INSIDE the 600px table, above the designed body, so it inherits the\n // column's width on a phone instead of being centred against the viewport.\n // `alt` is the product name where one is known, because a logo blocked by\n // the client (which is the default in most inboxes) must still say who sent\n // the mail. Height is capped rather than set, so a wordmark and a square\n // mark both land at a sane size without the sender supplying dimensions.\n //\n // The src goes through `imageSrc` like every other image in the message\n // (AGL-2230), and NOT into the tag raw. `emailLogoUrl` is collected by\n // `MediaUrlField`, whose \"Browse\" button writes the picked asset's\n // site-relative `cdnPath` — and the org DAM's other generation stores a\n // `media:{scope}/{mediaId}` reference. Neither is a URL an inbox can\n // resolve: there is no page to resolve it against, so a raw pass-through\n // put `src=\"media:h1/med9\"` at the top of every transactional email a\n // white-label org sent. `imageSrc` absolutizes both forms against\n // `mediaOrigin`, passes a real https URL through untouched, and returns\n // undefined when it cannot produce something fetchable — in which case the\n // row is dropped, on the same reasoning as AGL-1224: a gap reads as a plain\n // email, a broken-image box reads as a broken one.\n const brandLogo = imageSrc(String(brandLogoUrl ?? '').trim()) ?? ''\n // The product name from the merge map, because most inboxes block images by\n // default — a logo with no `alt` is a blank box where the sender's identity\n // should be. Falls back to nothing rather than to a literal, which would\n // reintroduce the hard-coded brand this whole change removes.\n const brandLogoAlt = merge?.['brand.productName'] ?? ''\n const brandLogoHtml = brandLogo\n ? row(\n `<img src=\"${escapeEmailHtml(brandLogo)}\" alt=\"${escapeEmailHtml(brandLogoAlt)}\" ` +\n `style=\"max-height:48px;max-width:200px;display:block;margin:0 auto;border:0;\" />`,\n 'padding:24px 24px 0;text-align:center;',\n )\n : ''\n\n const html =\n `<!DOCTYPE html><html><head><meta charset=\"utf-8\" />` +\n `<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />` +\n `<title>${escapeEmailHtml(sub(subject))}</title></head>` +\n `<body style=\"margin:0;padding:0;background-color:#f4f4f4;\">` +\n preheaderHtml +\n `<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"background-color:#f4f4f4;\">` +\n row(\n `<table role=\"presentation\" width=\"600\" cellpadding=\"0\" cellspacing=\"0\" align=\"center\" style=\"max-width:600px;width:100%;margin:0 auto;\">` +\n brandLogoHtml +\n body +\n `</table>`,\n 'padding:24px 8px;',\n ) +\n `</table></body></html>`\n\n return { html, text: textParts.join('\\n\\n') }\n}\n"],"names":["hasSafeLinkScheme","hasSafeMediaScheme","resolveEmailMediaSrc","FONT","escapeEmailHtml","value","replace","substituteMergeTokens","merge","match","token","replacement","undefined","TEXT_STYLES","heading","subheading","body","caption","row","cellHtml","cellStyle","EMAIL_NODE_ROOT_ID","renderEmailHtml","options","imageSrc","nodes","rootId","subject","preheader","products","sanitize","mediaOrigin","mediaHostId","brandLogoUrl","textParts","sub","String","urlMerge","Object","fromEntries","entries","filter","startsWith","subUrl","linkHref","href","origin","resolved","renderChildren","ids","map","id","renderNode","Boolean","join","node","props","componentId","background","backgroundColor","padding","Number","isFinite","align","text","children","push","style","variant","color","html","trim","src","width","alt","img","Math","min","label","height","product","productId","buttonLabel","productUrl","url","name","priceLabel","productImage","imageUrl","image","button","preheaderHtml","brandLogo","brandLogoAlt","brandLogoHtml"],"mappings":"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"}
@@ -0,0 +1,55 @@
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 ATTRIBUTION WINDOW, in the one place both halves of the join can reach.
19
+ *
20
+ * The writer lives in `tenant-data-admin` (it holds the Firestore paths) and
21
+ * the reader lives in the email plugin (it holds the report math), and a
22
+ * foundation library may not import a feature plugin. A window defined twice
23
+ * is a window that drifts, and the two copies would drift in the worst
24
+ * possible way: the number credited and the number printed beside it would
25
+ * describe different rules.
26
+ *
27
+ * The reasoning behind the model itself is recorded once, with the report, in
28
+ * `campaign-revenue.ts`.
29
+ */
30
+ /** Days between a click and an order, inside which the click gets the credit. */
31
+ export declare const EMAIL_ATTRIBUTION_WINDOW_DAYS = 7;
32
+ /** The same window in milliseconds. */
33
+ export declare const EMAIL_ATTRIBUTION_WINDOW_MS: number;
34
+ /**
35
+ * The model orders are credited under, stamped onto every record written.
36
+ *
37
+ * A string on the record rather than an implied convention, so a second model
38
+ * can be added without making the records already written unreadable. It is
39
+ * not an enum anything switches on.
40
+ */
41
+ export declare const EMAIL_ATTRIBUTION_MODEL = "last-click";
42
+ /**
43
+ * Whether a click may be credited with an order placed at `orderedAtMs`.
44
+ *
45
+ * Both bounds matter and they fail differently. A click AFTER the order is
46
+ * not a touch that led to it — it is the receipt, or a campaign that happened
47
+ * to land between the sale and the webhook — and crediting it would let a
48
+ * LATER campaign steal an earlier one's order. A click older than the window
49
+ * is a touch nobody can argue caused the purchase.
50
+ *
51
+ * Inclusive at both ends: an order placed in the same millisecond as the
52
+ * click is a plausible checkout from the landing page, and one placed exactly
53
+ * seven days later is inside a window described as seven days.
54
+ */
55
+ export declare function emailTouchIsInWindow(clickedAtMs: number, orderedAtMs: number, windowMs?: number): boolean;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * THE ATTRIBUTION WINDOW, in the one place both halves of the join can reach.
18
+ *
19
+ * The writer lives in `tenant-data-admin` (it holds the Firestore paths) and
20
+ * the reader lives in the email plugin (it holds the report math), and a
21
+ * foundation library may not import a feature plugin. A window defined twice
22
+ * is a window that drifts, and the two copies would drift in the worst
23
+ * possible way: the number credited and the number printed beside it would
24
+ * describe different rules.
25
+ *
26
+ * The reasoning behind the model itself is recorded once, with the report, in
27
+ * `campaign-revenue.ts`.
28
+ */ /** Days between a click and an order, inside which the click gets the credit. */ export const EMAIL_ATTRIBUTION_WINDOW_DAYS = 7;
29
+ /** The same window in milliseconds. */ export const EMAIL_ATTRIBUTION_WINDOW_MS = EMAIL_ATTRIBUTION_WINDOW_DAYS * 24 * 60 * 60 * 1000;
30
+ /**
31
+ * The model orders are credited under, stamped onto every record written.
32
+ *
33
+ * A string on the record rather than an implied convention, so a second model
34
+ * can be added without making the records already written unreadable. It is
35
+ * not an enum anything switches on.
36
+ */ export const EMAIL_ATTRIBUTION_MODEL = 'last-click';
37
+ /**
38
+ * Whether a click may be credited with an order placed at `orderedAtMs`.
39
+ *
40
+ * Both bounds matter and they fail differently. A click AFTER the order is
41
+ * not a touch that led to it — it is the receipt, or a campaign that happened
42
+ * to land between the sale and the webhook — and crediting it would let a
43
+ * LATER campaign steal an earlier one's order. A click older than the window
44
+ * is a touch nobody can argue caused the purchase.
45
+ *
46
+ * Inclusive at both ends: an order placed in the same millisecond as the
47
+ * click is a plausible checkout from the landing page, and one placed exactly
48
+ * seven days later is inside a window described as seven days.
49
+ */ export function emailTouchIsInWindow(clickedAtMs, orderedAtMs, windowMs = EMAIL_ATTRIBUTION_WINDOW_MS) {
50
+ if (!Number.isFinite(clickedAtMs) || !Number.isFinite(orderedAtMs)) {
51
+ return false;
52
+ }
53
+ if (clickedAtMs <= 0 || orderedAtMs <= 0) return false;
54
+ const age = orderedAtMs - clickedAtMs;
55
+ return age >= 0 && age <= windowMs;
56
+ }
57
+
58
+ //# sourceMappingURL=email-revenue-window.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/email-revenue-window.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 ATTRIBUTION WINDOW, in the one place both halves of the join can reach.\n *\n * The writer lives in `tenant-data-admin` (it holds the Firestore paths) and\n * the reader lives in the email plugin (it holds the report math), and a\n * foundation library may not import a feature plugin. A window defined twice\n * is a window that drifts, and the two copies would drift in the worst\n * possible way: the number credited and the number printed beside it would\n * describe different rules.\n *\n * The reasoning behind the model itself is recorded once, with the report, in\n * `campaign-revenue.ts`.\n */\n\n/** Days between a click and an order, inside which the click gets the credit. */\nexport const EMAIL_ATTRIBUTION_WINDOW_DAYS = 7\n\n/** The same window in milliseconds. */\nexport const EMAIL_ATTRIBUTION_WINDOW_MS =\n EMAIL_ATTRIBUTION_WINDOW_DAYS * 24 * 60 * 60 * 1000\n\n/**\n * The model orders are credited under, stamped onto every record written.\n *\n * A string on the record rather than an implied convention, so a second model\n * can be added without making the records already written unreadable. It is\n * not an enum anything switches on.\n */\nexport const EMAIL_ATTRIBUTION_MODEL = 'last-click'\n\n/**\n * Whether a click may be credited with an order placed at `orderedAtMs`.\n *\n * Both bounds matter and they fail differently. A click AFTER the order is\n * not a touch that led to it — it is the receipt, or a campaign that happened\n * to land between the sale and the webhook — and crediting it would let a\n * LATER campaign steal an earlier one's order. A click older than the window\n * is a touch nobody can argue caused the purchase.\n *\n * Inclusive at both ends: an order placed in the same millisecond as the\n * click is a plausible checkout from the landing page, and one placed exactly\n * seven days later is inside a window described as seven days.\n */\nexport function emailTouchIsInWindow(\n clickedAtMs: number,\n orderedAtMs: number,\n windowMs: number = EMAIL_ATTRIBUTION_WINDOW_MS,\n): boolean {\n if (!Number.isFinite(clickedAtMs) || !Number.isFinite(orderedAtMs)) {\n return false\n }\n if (clickedAtMs <= 0 || orderedAtMs <= 0) return false\n const age = orderedAtMs - clickedAtMs\n return age >= 0 && age <= windowMs\n}\n"],"names":["EMAIL_ATTRIBUTION_WINDOW_DAYS","EMAIL_ATTRIBUTION_WINDOW_MS","EMAIL_ATTRIBUTION_MODEL","emailTouchIsInWindow","clickedAtMs","orderedAtMs","windowMs","Number","isFinite","age"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;CAYC,GAED,+EAA+E,GAC/E,OAAO,MAAMA,gCAAgC,EAAC;AAE9C,qCAAqC,GACrC,OAAO,MAAMC,8BACXD,gCAAgC,KAAK,KAAK,KAAK,KAAI;AAErD;;;;;;CAMC,GACD,OAAO,MAAME,0BAA0B,aAAY;AAEnD;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC,qBACdC,WAAmB,EACnBC,WAAmB,EACnBC,WAAmBL,2BAA2B;IAE9C,IAAI,CAACM,OAAOC,QAAQ,CAACJ,gBAAgB,CAACG,OAAOC,QAAQ,CAACH,cAAc;QAClE,OAAO;IACT;IACA,IAAID,eAAe,KAAKC,eAAe,GAAG,OAAO;IACjD,MAAMI,MAAMJ,cAAcD;IAC1B,OAAOK,OAAO,KAAKA,OAAOH;AAC5B"}