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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/package.json +3 -3
  2. package/src/index.d.ts +3 -0
  3. package/src/index.js +9 -0
  4. package/src/index.js.map +1 -1
  5. package/src/lib/email-design-chrome.d.ts +61 -0
  6. package/src/lib/email-design-chrome.js +99 -0
  7. package/src/lib/email-design-chrome.js.map +1 -0
  8. package/src/lib/email-linkify.d.ts +3 -1
  9. package/src/lib/email-linkify.js +2 -2
  10. package/src/lib/email-linkify.js.map +1 -1
  11. package/src/lib/email-palette.d.ts +137 -0
  12. package/src/lib/email-palette.js +756 -0
  13. package/src/lib/email-palette.js.map +1 -0
  14. package/src/lib/email-render.d.ts +25 -0
  15. package/src/lib/email-render.js +27 -9
  16. package/src/lib/email-render.js.map +1 -1
  17. package/src/lib/held-page.d.ts +117 -0
  18. package/src/lib/held-page.js +172 -0
  19. package/src/lib/held-page.js.map +1 -0
  20. package/src/lib/host-email-render.d.ts +34 -7
  21. package/src/lib/host-email-render.js +69 -25
  22. package/src/lib/host-email-render.js.map +1 -1
  23. package/src/lib/hosted-page-screen.js +8 -6
  24. package/src/lib/hosted-page-screen.js.map +1 -1
  25. package/src/lib/outbound-phishing-screen.d.ts +24 -0
  26. package/src/lib/outbound-phishing-screen.js +81 -2
  27. package/src/lib/outbound-phishing-screen.js.map +1 -1
  28. package/src/lib/risk-notice-catalog.d.ts +204 -0
  29. package/src/lib/risk-notice-catalog.js +1702 -0
  30. package/src/lib/risk-notice-catalog.js.map +1 -0
  31. package/src/lib/risk-notice-emails.d.ts +25 -0
  32. package/src/lib/risk-notice-emails.js +182 -0
  33. package/src/lib/risk-notice-emails.js.map +1 -0
  34. package/src/lib/sender-readiness.d.ts +155 -0
  35. package/src/lib/sender-readiness.js +432 -0
  36. package/src/lib/sender-readiness.js.map +1 -0
  37. package/src/lib/system-email-catalog.js +7 -1
  38. package/src/lib/system-email-catalog.js.map +1 -1
  39. package/src/lib/tenant-email-catalog.d.ts +7 -0
  40. package/src/lib/tenant-email-catalog.js +183 -13
  41. package/src/lib/tenant-email-catalog.js.map +1 -1
  42. package/src/lib/text-email-html.d.ts +26 -0
  43. package/src/lib/text-email-html.js +56 -1
  44. package/src/lib/text-email-html.js.map +1 -1
@@ -0,0 +1,756 @@
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 palette an email is painted with (AGL-3370).
18
+ *
19
+ * A color an author picks in the Besigner is stored as a palette TOKEN PATH
20
+ * (`primary.main`, `grey.600`), which MUI resolves on a web page and a mail
21
+ * client never will. So an email needs the same palette the site renders
22
+ * with, as plain data, before it writes a single `style` attribute.
23
+ *
24
+ * That palette is built by `@aglyn/shared-ui-theme` — MUI's `createTheme`,
25
+ * then `createResponsiveTheme`'s shade and accessibility passes — and this
26
+ * lib may not import it: a `type:util` package cannot stand on a `type:ui`
27
+ * one, and a server rendering mail has no business loading MUI to do it. What
28
+ * follows is that derivation ported, light scheme only, with the INPUT colors
29
+ * of the two bases copied and everything else computed the way MUI computes
30
+ * it. `email-palette-parity.spec.ts` in `shared-ui-theme` builds both and
31
+ * holds them equal, so the copy cannot drift without a red.
32
+ */ /**
33
+ * A light-scheme MUI palette as plain data: `primary`, `secondary`,
34
+ * `tertiary`, `surface`, `error`, `warning`, `info`, `success` (each
35
+ * `{ main, light, dark, contrastText }`), `background`, `text`, `tint`,
36
+ * `divider`, `inputOutline`, `grey` and `common`.
37
+ */ import { _ as _extends } from "@swc/helpers/_/_extends";
38
+ var _process_env_NEXT_PUBLIC_PLATFORM_BRAND_HOSTS;
39
+ // ── The two bases' authored inputs ─────────────────────────────────────────
40
+ //
41
+ // Copied from `console.theme.ts` (`colorScheme.light`) and `tenant.theme.ts`
42
+ // (`tenantColorScheme.light`), minus the `svg*` records no email reads. Only
43
+ // what those files AUTHOR is here; every shade they leave to MUI is derived
44
+ // below, so the copy stays as short as the thing it mirrors.
45
+ const BRAND_GREY = {
46
+ 50: '#FAFAFA',
47
+ 100: '#F5F5F5',
48
+ 200: '#EEEEEE',
49
+ 300: '#E0E0E0',
50
+ 400: '#BDBDBD',
51
+ 500: '#9E9E9E',
52
+ 600: '#757575',
53
+ 700: '#616161',
54
+ 800: '#424242',
55
+ 900: '#212121',
56
+ A100: '#D5D5D5',
57
+ A200: '#AAAAAA',
58
+ A400: '#303030',
59
+ A700: '#616161'
60
+ };
61
+ const PLATFORM_LIGHT_INPUT = {
62
+ mode: 'light',
63
+ primary: {
64
+ main: '#00b0ff',
65
+ dark: '#0077ad',
66
+ contrastText: '#FFFFFF'
67
+ },
68
+ secondary: {
69
+ main: '#e040fb',
70
+ dark: '#9d2db0',
71
+ contrastText: '#FFFFFF'
72
+ },
73
+ tertiary: {
74
+ main: '#404C5C',
75
+ contrastText: '#FFFFFF'
76
+ },
77
+ surface: {
78
+ main: '#F8F9FA',
79
+ contrastText: '#000000'
80
+ },
81
+ tint: {
82
+ primary: '#E6F5FF',
83
+ secondary: '#FBE6FE',
84
+ tertiary: '#EEF0F2'
85
+ },
86
+ inputOutline: 'rgba(0, 0, 0, 0.23)',
87
+ background: {
88
+ default: '#F5F5F5',
89
+ paper: '#FFFFFF'
90
+ },
91
+ info: {
92
+ main: '#1878cd',
93
+ contrastText: '#FFFFFF'
94
+ },
95
+ error: {
96
+ main: '#e32c27',
97
+ contrastText: '#FFFFFF'
98
+ },
99
+ success: {
100
+ main: '#4CAF50',
101
+ contrastText: '#000000DE'
102
+ },
103
+ warning: {
104
+ main: '#FFAB40',
105
+ contrastText: '#000000DE'
106
+ },
107
+ grey: BRAND_GREY
108
+ };
109
+ const TENANT_LIGHT_INPUT = {
110
+ mode: 'light',
111
+ primary: {
112
+ main: '#1976d2',
113
+ dark: '#125393',
114
+ contrastText: '#FFFFFF'
115
+ },
116
+ secondary: {
117
+ main: '#9c27b0',
118
+ dark: '#6d1b7b',
119
+ contrastText: '#FFFFFF'
120
+ },
121
+ tertiary: {
122
+ main: '#4a5568',
123
+ dark: '#343b49',
124
+ contrastText: '#FFFFFF'
125
+ },
126
+ surface: {
127
+ main: '#f1f3f5',
128
+ contrastText: '#000000DE'
129
+ },
130
+ tint: {
131
+ primary: '#e8f1fb',
132
+ secondary: '#f5e9f7',
133
+ tertiary: '#eef0f3'
134
+ },
135
+ inputOutline: 'rgba(0, 0, 0, 0.23)',
136
+ background: {
137
+ default: '#fafafa',
138
+ paper: '#ffffff'
139
+ },
140
+ info: {
141
+ main: '#0288d1',
142
+ dark: '#015f92',
143
+ contrastText: '#000000DE'
144
+ },
145
+ error: {
146
+ main: '#d32f2f',
147
+ dark: '#942121',
148
+ contrastText: '#FFFFFF'
149
+ },
150
+ success: {
151
+ main: '#2e7d32',
152
+ dark: '#205823',
153
+ contrastText: '#FFFFFF'
154
+ },
155
+ warning: {
156
+ main: '#ed6c02',
157
+ dark: '#a64c01',
158
+ contrastText: '#000000DE'
159
+ },
160
+ grey: BRAND_GREY
161
+ };
162
+ // ── MUI's light-mode constants (`createPalette`, `colors/*`) ───────────────
163
+ const MUI_COMMON = {
164
+ black: '#000',
165
+ white: '#fff'
166
+ };
167
+ const MUI_GREY = {
168
+ 50: '#fafafa',
169
+ 100: '#f5f5f5',
170
+ 200: '#eeeeee',
171
+ 300: '#e0e0e0',
172
+ 400: '#bdbdbd',
173
+ 500: '#9e9e9e',
174
+ 600: '#757575',
175
+ 700: '#616161',
176
+ 800: '#424242',
177
+ 900: '#212121',
178
+ A100: '#f5f5f5',
179
+ A200: '#eeeeee',
180
+ A400: '#bdbdbd',
181
+ A700: '#616161'
182
+ };
183
+ /** `getLight()` — the mode-hydrated slots, minus `action` (no email reads it). */ function muiLightDefaults() {
184
+ return {
185
+ text: {
186
+ primary: 'rgba(0, 0, 0, 0.87)',
187
+ secondary: 'rgba(0, 0, 0, 0.6)',
188
+ disabled: 'rgba(0, 0, 0, 0.38)'
189
+ },
190
+ divider: 'rgba(0, 0, 0, 0.12)',
191
+ background: {
192
+ paper: MUI_COMMON.white,
193
+ default: MUI_COMMON.white
194
+ }
195
+ };
196
+ }
197
+ /** MUI's `light.text.primary`, the dark ink `getContrastText` falls back to. */ const MUI_DARK_INK = 'rgba(0, 0, 0, 0.87)';
198
+ const MUI_CONTRAST_THRESHOLD = 3;
199
+ /** MUI's default `tonalOffset` of 0.2, split the way `addLightOrDark` splits it. */ const TONAL_OFFSET_LIGHT = 0.2;
200
+ const TONAL_OFFSET_DARK = 0.2 * 1.5;
201
+ /** The six colors `createPalette` augments. Both bases author every one. */ const MUI_AUGMENTED_KEYS = [
202
+ 'primary',
203
+ 'secondary',
204
+ 'error',
205
+ 'warning',
206
+ 'info',
207
+ 'success'
208
+ ];
209
+ /** `createResponsiveTheme`'s FOREGROUND_COLOR_KEYS: `dark` is accent text. */ const FOREGROUND_COLOR_KEYS = [
210
+ 'primary',
211
+ 'secondary',
212
+ 'tertiary',
213
+ 'error',
214
+ 'warning',
215
+ 'info',
216
+ 'success'
217
+ ];
218
+ /** …and `contrastText` is text wherever it lives, so `surface` joins. */ const CONTRAST_TEXT_COLOR_KEYS = [
219
+ ...FOREGROUND_COLOR_KEYS,
220
+ 'surface'
221
+ ];
222
+ function hexToRgb(color) {
223
+ const hex = color.slice(1);
224
+ const pattern = new RegExp(`.{1,${hex.length >= 6 ? 2 : 1}}`, 'g');
225
+ let parts = hex.match(pattern);
226
+ if (parts && parts[0].length === 1) parts = parts.map((part)=>part + part);
227
+ return parts ? `rgb${parts.length === 4 ? 'a' : ''}(${parts.map((part, index)=>index < 3 ? parseInt(part, 16) : Math.round(parseInt(part, 16) / 255 * 1000) / 1000).join(', ')})` : '';
228
+ }
229
+ function decomposeColor(color) {
230
+ if (color.charAt(0) === '#') return decomposeColor(hexToRgb(color));
231
+ const marker = color.indexOf('(');
232
+ const type = color.substring(0, marker);
233
+ if (![
234
+ 'rgb',
235
+ 'rgba',
236
+ 'hsl',
237
+ 'hsla',
238
+ 'color'
239
+ ].includes(type)) {
240
+ throw new Error(`email-palette: unsupported color "${color}"`);
241
+ }
242
+ const body = color.substring(marker + 1, color.length - 1);
243
+ let raw;
244
+ let colorSpace;
245
+ if (type === 'color') {
246
+ raw = body.split(' ');
247
+ colorSpace = raw.shift();
248
+ if (raw.length === 4 && raw[3].charAt(0) === '/') raw[3] = raw[3].slice(1);
249
+ if (![
250
+ 'srgb',
251
+ 'display-p3',
252
+ 'a98-rgb',
253
+ 'prophoto-rgb',
254
+ 'rec-2020'
255
+ ].includes(colorSpace != null ? colorSpace : '')) {
256
+ throw new Error(`email-palette: unsupported color space "${colorSpace}"`);
257
+ }
258
+ } else {
259
+ raw = body.split(',');
260
+ }
261
+ return {
262
+ type,
263
+ values: raw.map((value)=>parseFloat(value)),
264
+ colorSpace
265
+ };
266
+ }
267
+ function recomposeColor({ type, colorSpace, values }) {
268
+ let parts = values;
269
+ if (type.includes('rgb')) {
270
+ parts = values.map((value, index)=>index < 3 ? parseInt(String(value), 10) : value);
271
+ } else if (type.includes('hsl')) {
272
+ parts = [
273
+ ...values
274
+ ];
275
+ parts[1] = `${values[1]}%`;
276
+ parts[2] = `${values[2]}%`;
277
+ }
278
+ const body = type.includes('color') ? `${colorSpace} ${parts.join(' ')}` : parts.join(', ');
279
+ return `${type}(${body})`;
280
+ }
281
+ function intToHex(value) {
282
+ const hex = value.toString(16);
283
+ return hex.length === 1 ? `0${hex}` : hex;
284
+ }
285
+ function rgbToHex(color) {
286
+ if (color.startsWith('#')) return color;
287
+ const { values } = decomposeColor(color);
288
+ return `#${values.map((value, index)=>intToHex(index === 3 ? Math.round(255 * value) : value)).join('')}`;
289
+ }
290
+ function hslToRgb(color) {
291
+ const decomposed = typeof color === 'string' ? decomposeColor(color) : color;
292
+ const { values } = decomposed;
293
+ const hue = values[0];
294
+ const saturation = values[1] / 100;
295
+ const lightness = values[2] / 100;
296
+ const chroma = saturation * Math.min(lightness, 1 - lightness);
297
+ const channel = (n, k = (n + hue / 30) % 12)=>lightness - chroma * Math.max(Math.min(k - 3, 9 - k, 1), -1);
298
+ const rgb = [
299
+ Math.round(channel(0) * 255),
300
+ Math.round(channel(8) * 255),
301
+ Math.round(channel(4) * 255)
302
+ ];
303
+ let type = 'rgb';
304
+ if (decomposed.type === 'hsla') {
305
+ type += 'a';
306
+ rgb.push(values[3]);
307
+ }
308
+ return recomposeColor({
309
+ type,
310
+ values: rgb
311
+ });
312
+ }
313
+ function clampUnit(value) {
314
+ return Math.min(1, Math.max(0, value));
315
+ }
316
+ function lighten(color, coefficient) {
317
+ const decomposed = decomposeColor(color);
318
+ const amount = clampUnit(coefficient);
319
+ const { type, values } = decomposed;
320
+ if (type.includes('hsl')) {
321
+ values[2] += (100 - values[2]) * amount;
322
+ } else if (type.includes('rgb')) {
323
+ for(let i = 0; i < 3; i += 1)values[i] += (255 - values[i]) * amount;
324
+ } else if (type.includes('color')) {
325
+ for(let i = 0; i < 3; i += 1)values[i] += (1 - values[i]) * amount;
326
+ }
327
+ return recomposeColor(decomposed);
328
+ }
329
+ function darken(color, coefficient) {
330
+ const decomposed = decomposeColor(color);
331
+ const amount = clampUnit(coefficient);
332
+ const { type, values } = decomposed;
333
+ if (type.includes('hsl')) {
334
+ values[2] *= 1 - amount;
335
+ } else if (type.includes('rgb') || type.includes('color')) {
336
+ for(let i = 0; i < 3; i += 1)values[i] *= 1 - amount;
337
+ }
338
+ return recomposeColor(decomposed);
339
+ }
340
+ function srgbChannelToLinear(value) {
341
+ return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
342
+ }
343
+ /** MUI's `getLuminance`: WCAG luminance, rounded to three places. */ function muiLuminance(color) {
344
+ const decomposed = decomposeColor(color);
345
+ const rgb = decomposed.type === 'hsl' || decomposed.type === 'hsla' ? decomposeColor(hslToRgb(decomposed)).values : decomposed.values;
346
+ const linear = rgb.map((value)=>srgbChannelToLinear(decomposed.type !== 'color' ? value / 255 : value));
347
+ return Number((0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]).toFixed(3));
348
+ }
349
+ /** MUI's `getContrastRatio`, on the rounded luminance. */ function muiContrastRatio(foreground, background) {
350
+ const a = muiLuminance(foreground);
351
+ const b = muiLuminance(background);
352
+ return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
353
+ }
354
+ // ── `accessible-shade.ts`, ported ──────────────────────────────────────────
355
+ const AA_TEXT_CONTRAST = 4.5;
356
+ /** Full-precision WCAG luminance, as `accessible-shade.ts` measures it. */ function relativeLuminance(color) {
357
+ const decomposed = decomposeColor(color);
358
+ if (decomposed.type === 'color') {
359
+ throw new Error(`email-palette: unsupported color format "${color}"`);
360
+ }
361
+ const values = decomposed.type.startsWith('hsl') ? decomposeColor(hslToRgb(color)).values : decomposed.values;
362
+ return 0.2126 * srgbChannelToLinear(values[0] / 255) + 0.7152 * srgbChannelToLinear(values[1] / 255) + 0.0722 * srgbChannelToLinear(values[2] / 255);
363
+ }
364
+ function contrastRatio(colorA, colorB) {
365
+ const a = relativeLuminance(colorA);
366
+ const b = relativeLuminance(colorB);
367
+ return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
368
+ }
369
+ function meetsContrast(foreground, backgrounds) {
370
+ return backgrounds.every((background)=>contrastRatio(foreground, background) >= AA_TEXT_CONTRAST);
371
+ }
372
+ function toHslValues(color) {
373
+ const decomposed = decomposeColor(color);
374
+ if (decomposed.type.startsWith('hsl')) {
375
+ const [h, s, l] = decomposed.values;
376
+ return [
377
+ h,
378
+ s / 100,
379
+ l / 100
380
+ ];
381
+ }
382
+ const r = decomposed.values[0] / 255;
383
+ const g = decomposed.values[1] / 255;
384
+ const b = decomposed.values[2] / 255;
385
+ const max = Math.max(r, g, b);
386
+ const min = Math.min(r, g, b);
387
+ const lightness = (max + min) / 2;
388
+ if (max === min) return [
389
+ 0,
390
+ 0,
391
+ lightness
392
+ ];
393
+ const delta = max - min;
394
+ const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min);
395
+ let hue;
396
+ if (max === r) hue = (g - b) / delta + (g < b ? 6 : 0);
397
+ else if (max === g) hue = (b - r) / delta + 2;
398
+ else hue = (r - g) / delta + 4;
399
+ return [
400
+ hue * 60,
401
+ saturation,
402
+ lightness
403
+ ];
404
+ }
405
+ function hslToHex(hue, saturation, lightness) {
406
+ return rgbToHex(hslToRgb(`hsl(${hue}, ${saturation * 100}%, ${lightness * 100}%)`));
407
+ }
408
+ /** The nearest shade of `color`, walking lightness one way, that clears AA. */ function accessibleShade(color, backgrounds, direction) {
409
+ if (!backgrounds.length || meetsContrast(color, backgrounds)) return color;
410
+ const [hue, saturation, initialLightness] = toHslValues(color);
411
+ const delta = direction === 'lighten' ? 0.01 : -0.01;
412
+ let lightness = initialLightness;
413
+ let candidate = color;
414
+ for(let i = 0; i < 120; i += 1){
415
+ lightness = Math.min(1, Math.max(0, lightness + delta));
416
+ candidate = hslToHex(hue, saturation, lightness);
417
+ if (meetsContrast(candidate, backgrounds)) return candidate;
418
+ if (lightness === 0 || lightness === 1) return candidate;
419
+ }
420
+ return candidate;
421
+ }
422
+ // ── Palette assembly ───────────────────────────────────────────────────────
423
+ function isRecord(value) {
424
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
425
+ }
426
+ /** MUI's `deepmerge` with `clone: true`, for plain data. */ function deepMerge(target, source) {
427
+ const output = _extends({}, target);
428
+ for (const key of Object.keys(source)){
429
+ const next = source[key];
430
+ const current = target[key];
431
+ if (isRecord(next) && Object.prototype.hasOwnProperty.call(target, key) && isRecord(current)) {
432
+ output[key] = deepMerge(current, next);
433
+ } else {
434
+ output[key] = isRecord(next) ? deepMerge({}, next) : next;
435
+ }
436
+ }
437
+ return output;
438
+ }
439
+ /**
440
+ * `createPalette`'s `augmentColor` for an object with a `main`: fill `light`
441
+ * and `dark` from the tonal offset, and `contrastText` by MUI's threshold.
442
+ */ function augmentColor(color) {
443
+ const augmented = _extends({}, color);
444
+ const main = augmented['main'];
445
+ if (typeof main !== 'string') {
446
+ throw new Error('email-palette: a palette color needs a string `main`');
447
+ }
448
+ if (!augmented['light']) augmented['light'] = lighten(main, TONAL_OFFSET_LIGHT);
449
+ if (!augmented['dark']) augmented['dark'] = darken(main, TONAL_OFFSET_DARK);
450
+ if (!augmented['contrastText']) {
451
+ augmented['contrastText'] = muiContrastRatio(main, MUI_COMMON.white) >= MUI_CONTRAST_THRESHOLD ? MUI_COMMON.white : MUI_DARK_INK;
452
+ }
453
+ return augmented;
454
+ }
455
+ /**
456
+ * `createResponsiveTheme`'s `addShadeVariants`, for the two colors MUI does
457
+ * not know about. Its contrast pick is handed the DARK tonal offset (0.3) as
458
+ * the threshold, so it lands on white for any color at all; the port keeps
459
+ * that, because the parity it owes is to what the site renders, and any
460
+ * pairing below AA is repaired by the accessibility pass that follows.
461
+ */ function addShadeVariants(color) {
462
+ if (!isRecord(color) || !color['main']) return;
463
+ const main = color['main'];
464
+ if (!color['dark']) color['dark'] = darken(main, TONAL_OFFSET_DARK);
465
+ if (!color['light']) color['light'] = lighten(main, TONAL_OFFSET_LIGHT);
466
+ if (!color['contrastText']) {
467
+ color['contrastText'] = muiContrastRatio(main, '#FFFFFF') >= TONAL_OFFSET_DARK ? '#FFFFFF' : 'rgba(0, 0, 0, 0.87)';
468
+ }
469
+ }
470
+ /**
471
+ * `ensureAccessibleShades` for the light scheme: a DERIVED `dark` that misses
472
+ * 4.5:1 on either background is walked darker, and a derived `contrastText`
473
+ * that misses 4.5:1 on its `main` is walked toward the pole with more room.
474
+ * Whatever the input authored passes through untouched.
475
+ */ function ensureAccessibleShades(palette, input) {
476
+ const background = isRecord(palette['background']) ? palette['background'] : {};
477
+ const backgrounds = [
478
+ background['default'],
479
+ background['paper']
480
+ ].filter((value)=>typeof value === 'string');
481
+ if (!backgrounds.length) return;
482
+ for (const key of CONTRAST_TEXT_COLOR_KEYS){
483
+ const color = palette[key];
484
+ if (!isRecord(color) || typeof color['main'] !== 'string') continue;
485
+ const provided = input[key];
486
+ if (isRecord(provided) && !('main' in provided)) continue;
487
+ const explicit = isRecord(provided) ? provided : {};
488
+ const main = color['main'];
489
+ try {
490
+ if (FOREGROUND_COLOR_KEYS.includes(key) && !explicit['dark'] && typeof color['dark'] === 'string' && !meetsContrast(color['dark'], backgrounds)) {
491
+ color['dark'] = accessibleShade(color['dark'], backgrounds, 'darken');
492
+ }
493
+ if (!explicit['contrastText'] && typeof color['contrastText'] === 'string' && !meetsContrast(color['contrastText'], [
494
+ main
495
+ ])) {
496
+ const direction = contrastRatio('#fff', main) >= contrastRatio('#000', main) ? 'lighten' : 'darken';
497
+ color['contrastText'] = accessibleShade(color['contrastText'], [
498
+ main
499
+ ], direction);
500
+ }
501
+ } catch (unused) {
502
+ // An unparseable color is left as derived, as the theme leaves it.
503
+ }
504
+ }
505
+ }
506
+ /** `createTheme` + `createResponsiveTheme`'s passes, palette only. */ function derivePalette(input) {
507
+ const rest = _extends({}, input);
508
+ delete rest['mode'];
509
+ delete rest['contrastThreshold'];
510
+ delete rest['tonalOffset'];
511
+ const augmented = {};
512
+ for (const key of MUI_AUGMENTED_KEYS){
513
+ const color = input[key];
514
+ if (!isRecord(color)) {
515
+ throw new Error(`email-palette: the base palette is missing \`${key}\``);
516
+ }
517
+ augmented[key] = augmentColor(color);
518
+ }
519
+ const palette = deepMerge(_extends({
520
+ common: _extends({}, MUI_COMMON),
521
+ mode: 'light'
522
+ }, augmented, {
523
+ grey: MUI_GREY
524
+ }, muiLightDefaults()), rest);
525
+ addShadeVariants(palette['tertiary']);
526
+ addShadeVariants(palette['surface']);
527
+ ensureAccessibleShades(palette, input);
528
+ return palette;
529
+ }
530
+ // ── A site's colors, as `hostThemeToThemeOptions` reads them ───────────────
531
+ const SCHEME_COLOR_KEYS = [
532
+ 'primary',
533
+ 'secondary',
534
+ 'tertiary',
535
+ 'surface',
536
+ 'error',
537
+ 'warning',
538
+ 'info',
539
+ 'success'
540
+ ];
541
+ /** Truthy string, or nothing: the "copy what was set" test the theme uses. */ function setString(value) {
542
+ return typeof value === 'string' && value ? value : undefined;
543
+ }
544
+ /** `pickPaletteColor`: a color needs a `main`; its shades ride along if set. */ function pickPaletteColor(color) {
545
+ if (!isRecord(color)) return undefined;
546
+ const main = setString(color['main']);
547
+ if (!main) return undefined;
548
+ const picked = {
549
+ main
550
+ };
551
+ for (const shade of [
552
+ 'light',
553
+ 'dark',
554
+ 'contrastText'
555
+ ]){
556
+ const value = setString(color[shade]);
557
+ if (value) picked[shade] = value;
558
+ }
559
+ return picked;
560
+ }
561
+ /** Picks the listed string leaves of a group that were set, or nothing. */ function pickLeaves(group, keys) {
562
+ if (!isRecord(group)) return undefined;
563
+ const picked = {};
564
+ for (const key of keys){
565
+ const value = setString(group[key]);
566
+ if (value) picked[key] = value;
567
+ }
568
+ return Object.keys(picked).length ? picked : undefined;
569
+ }
570
+ /** `schemeColorsToPaletteOptions` for the light scheme. */ function siteColorOverrides(input) {
571
+ const overrides = {
572
+ mode: 'light'
573
+ };
574
+ if (!isRecord(input)) return overrides;
575
+ const colors = input;
576
+ for (const key of SCHEME_COLOR_KEYS){
577
+ const color = pickPaletteColor(colors[key]);
578
+ if (color) overrides[key] = color;
579
+ }
580
+ const background = pickLeaves(colors['background'], [
581
+ 'default',
582
+ 'paper'
583
+ ]);
584
+ if (background) overrides['background'] = background;
585
+ const text = pickLeaves(colors['text'], [
586
+ 'primary',
587
+ 'secondary',
588
+ 'disabled'
589
+ ]);
590
+ if (text) overrides['text'] = text;
591
+ const tint = pickLeaves(colors['tint'], [
592
+ 'primary',
593
+ 'secondary',
594
+ 'tertiary'
595
+ ]);
596
+ if (tint) overrides['tint'] = tint;
597
+ const divider = setString(colors['divider']);
598
+ if (divider) overrides['divider'] = divider;
599
+ return overrides;
600
+ }
601
+ // ── Literal CSS colors ─────────────────────────────────────────────────────
602
+ const HEX_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
603
+ const NUMBER = String.raw`[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[-+]?\d+)?(?:%|deg|grad|rad|turn)?`;
604
+ /**
605
+ * `rgb()`, `rgba()`, `hsl()`, `hsla()` holding numbers and nothing else —
606
+ * comma or space separated, with an optional `/ alpha`. Anything that could
607
+ * close the declaration or open another (`;`, `"`, `(`, `url`) cannot match.
608
+ */ const FUNCTION_COLOR = new RegExp(String.raw`^(?:rgba?|hsla?)\(\s*${NUMBER}(?:\s*,\s*|\s+)${NUMBER}(?:\s*,\s*|\s+)${NUMBER}(?:(?:\s*,\s*|\s*\/\s*)${NUMBER})?\s*\)$`, 'i');
609
+ /** A color MUI's own manipulators parse: hex, or a comma-separated function. */ const MUI_PARSEABLE_FUNCTION = new RegExp(String.raw`^(?:rgba?|hsla?)\(\s*${NUMBER}\s*,\s*${NUMBER}\s*,\s*${NUMBER}(?:\s*,\s*${NUMBER})?\s*\)$`, 'i');
610
+ /** CSS Color Module Level 4 named colors, plus `transparent`. */ const NAMED_COLORS = new Set(('aliceblue antiquewhite aqua aquamarine azure beige bisque black ' + 'blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse ' + 'chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan ' + 'darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta ' + 'darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen ' + 'darkslateblue darkslategray darkslategrey darkturquoise darkviolet ' + 'deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite ' + 'forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green ' + 'greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender ' + 'lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan ' + 'lightgoldenrodyellow lightgray lightgreen lightgrey lightpink ' + 'lightsalmon lightseagreen lightskyblue lightslategray lightslategrey ' + 'lightsteelblue lightyellow lime limegreen linen magenta maroon ' + 'mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen ' + 'mediumslateblue mediumspringgreen mediumturquoise mediumvioletred ' + 'midnightblue mintcream mistyrose moccasin navajowhite navy oldlace ' + 'olive olivedrab orange orangered orchid palegoldenrod palegreen ' + 'paleturquoise palevioletred papayawhip peachpuff peru pink plum ' + 'powderblue purple rebeccapurple red rosybrown royalblue saddlebrown ' + 'salmon sandybrown seagreen seashell sienna silver skyblue slateblue ' + 'slategray slategrey snow springgreen steelblue tan teal thistle tomato ' + 'turquoise violet wheat white whitesmoke yellow yellowgreen transparent').split(' '));
611
+ /** True for a literal CSS color that is safe to write into a style attribute. */ function isLiteralColor(value) {
612
+ return HEX_COLOR.test(value) || FUNCTION_COLOR.test(value) || NAMED_COLORS.has(value.toLowerCase());
613
+ }
614
+ /** True for a color MUI can derive shades from without throwing. */ function isDerivableColor(value) {
615
+ return HEX_COLOR.test(value) || MUI_PARSEABLE_FUNCTION.test(value);
616
+ }
617
+ // ── Public API ─────────────────────────────────────────────────────────────
618
+ /**
619
+ * The fully derived light palette a site or sender renders with: the
620
+ * chosen base, the site's authored colors layered one level deep (a color's
621
+ * whole record replaced, so MUI re-derives its shades from the new `main`),
622
+ * then a white-label `primaryColor` over that.
623
+ *
624
+ * Equal, for every token the Besigner's color picker offers, to the palette
625
+ * of `createResponsiveTheme` over `mergeThemeOptions(siteBaseOptions(…))`
626
+ * and the host theme's light `hostThemeToThemeOptions`.
627
+ *
628
+ * Where the site's colors cannot be derived at all — a `main` MUI cannot
629
+ * parse, on which `createTheme` itself throws — the email falls back to the
630
+ * base rather than failing the send: there is no rendered page to agree with.
631
+ */ export function buildEmailPalette(input) {
632
+ const base = input.base === 'platform' ? PLATFORM_LIGHT_INPUT : TENANT_LIGHT_INPUT;
633
+ const primaryColor = typeof input.primaryColor === 'string' && isDerivableColor(input.primaryColor.trim()) ? input.primaryColor.trim() : undefined;
634
+ const brand = primaryColor ? {
635
+ primary: {
636
+ main: primaryColor
637
+ }
638
+ } : {};
639
+ const layered = _extends({}, base, siteColorOverrides(input.colors), brand);
640
+ try {
641
+ return derivePalette(layered);
642
+ } catch (unused) {
643
+ return derivePalette(_extends({}, base, brand));
644
+ }
645
+ }
646
+ /** The platform's own palette — the console brand, light scheme. */ export const PLATFORM_EMAIL_PALETTE = buildEmailPalette({
647
+ base: 'platform'
648
+ });
649
+ /** Palette token paths: a key, optionally one dot and a leaf (`grey.600`). */ const TOKEN_PATH = /^[a-zA-Z][a-zA-Z0-9]*(?:\.[a-zA-Z0-9]+)?$/;
650
+ function lookupToken(palette, path) {
651
+ let node = palette;
652
+ for (const key of path.split('.')){
653
+ if (!isRecord(node) || !Object.prototype.hasOwnProperty.call(node, key)) {
654
+ return undefined;
655
+ }
656
+ node = node[key];
657
+ }
658
+ return typeof node === 'string' ? node : undefined;
659
+ }
660
+ /**
661
+ * A palette value as an opaque `#rrggbb`. Translucent values (`text.primary`
662
+ * is `rgba(0, 0, 0, 0.87)`, some `contrastText`s are `#000000DE`) are
663
+ * composited over the palette's paper, the ground an email body sits on:
664
+ * Outlook's Word engine drops `rgba()` and eight-digit hex outright, and the
665
+ * flattened color is what a reader sees on that ground anyway.
666
+ */ function toOpaqueHex(value, palette) {
667
+ var _ref;
668
+ const channels = rgbaChannels(value);
669
+ if (!channels) return undefined;
670
+ const [r, g, b, alpha] = channels;
671
+ if (alpha >= 1) return channelsToHex(r, g, b);
672
+ const background = palette['background'];
673
+ const paper = isRecord(background) ? background['paper'] : undefined;
674
+ const ground = (_ref = typeof paper === 'string' ? rgbaChannels(paper) : undefined) != null ? _ref : [
675
+ 255,
676
+ 255,
677
+ 255,
678
+ 1
679
+ ];
680
+ const over = (fore, back)=>fore * alpha + back * (1 - alpha);
681
+ return channelsToHex(over(r, ground[0]), over(g, ground[1]), over(b, ground[2]));
682
+ }
683
+ function rgbaChannels(value) {
684
+ if (!isDerivableColor(value)) return undefined;
685
+ try {
686
+ const decomposed = decomposeColor(value);
687
+ const rgb = decomposed.type.startsWith('hsl') ? decomposeColor(hslToRgb(decomposed)).values : decomposed.values;
688
+ const alpha = decomposed.values.length > 3 ? decomposed.values[3] : 1;
689
+ const channels = [
690
+ rgb[0],
691
+ rgb[1],
692
+ rgb[2],
693
+ alpha
694
+ ];
695
+ return channels.every(Number.isFinite) ? channels : undefined;
696
+ } catch (unused) {
697
+ return undefined;
698
+ }
699
+ }
700
+ function channelsToHex(r, g, b) {
701
+ return `#${[
702
+ r,
703
+ g,
704
+ b
705
+ ].map((value)=>intToHex(Math.round(Math.min(255, Math.max(0, value))))).join('')}`;
706
+ }
707
+ /**
708
+ * Resolves a stored color prop for an email's markup.
709
+ *
710
+ * - A palette token path (`primary.main`, `grey.600`, `divider`) resolves
711
+ * against `palette` to an opaque hex — or to nothing without a palette.
712
+ * - A literal CSS color (hex, `rgb()`/`rgba()`/`hsl()`/`hsla()`,
713
+ * `transparent`, a named color) passes through as written.
714
+ * - Anything else — an unknown token, garbage, an empty value, and anything
715
+ * that could step outside a `style` attribute — is `undefined`, so the
716
+ * caller uses its own default.
717
+ */ export function resolveEmailColor(value, palette) {
718
+ if (typeof value !== 'string') return undefined;
719
+ const trimmed = value.trim();
720
+ if (!trimmed) return undefined;
721
+ if (palette && TOKEN_PATH.test(trimmed)) {
722
+ const resolved = lookupToken(palette, trimmed);
723
+ if (resolved !== undefined) {
724
+ const hex = toOpaqueHex(resolved, palette);
725
+ if (hex) return hex;
726
+ if (isLiteralColor(resolved)) return resolved;
727
+ return undefined;
728
+ }
729
+ }
730
+ return isLiteralColor(trimmed) ? trimmed : undefined;
731
+ }
732
+ // ── Which base a site wears ────────────────────────────────────────────────
733
+ /**
734
+ * The prefix the tenant middleware puts on a custom domain before it becomes
735
+ * the `[host]` route segment — mirrored from `tenant.theme.ts`.
736
+ */ const CNAME_PREFIX = 'cname--';
737
+ /**
738
+ * The hosts whose brand is the operator's own, mirrored from
739
+ * `PLATFORM_BRAND_HOSTS` in `tenant.theme.ts` and read from the same
740
+ * variable. Dot notation for the same reason it has there: Next substitutes
741
+ * `process.env.NAME` textually and never the bracket form.
742
+ */ const PLATFORM_BRAND_HOSTS = new Set(((_process_env_NEXT_PUBLIC_PLATFORM_BRAND_HOSTS = process.env.NEXT_PUBLIC_PLATFORM_BRAND_HOSTS) != null ? _process_env_NEXT_PUBLIC_PLATFORM_BRAND_HOSTS : 'aglyn.com,aglyn.io').split(',').map((host)=>host.trim().toLowerCase()).filter(Boolean));
743
+ /**
744
+ * Which base a site's mail layers onto: `'platform'` for the operator's own
745
+ * hosts, `'tenant'` for every customer site — the answer
746
+ * `wearsPlatformBrand(hostBrandKey(host))` gives the page itself, so a site's
747
+ * email and its published page start from the same palette.
748
+ */ export function emailPaletteBaseForHost(host) {
749
+ const key = (host == null ? void 0 : host.cname) || (host == null ? void 0 : host.subdomain) || undefined;
750
+ if (!key) return 'tenant';
751
+ const normalized = key.trim().toLowerCase();
752
+ const bare = normalized.startsWith(CNAME_PREFIX) ? normalized.slice(CNAME_PREFIX.length) : normalized;
753
+ return PLATFORM_BRAND_HOSTS.has(bare) ? 'platform' : 'tenant';
754
+ }
755
+
756
+ //# sourceMappingURL=email-palette.js.map