@12-apps/notifications 4.7.1 → 4.9.0

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 (96) hide show
  1. package/ADOPTING.md +145 -0
  2. package/README.md +65 -0
  3. package/dist/catalog-BOVZ2b-b.d.ts +132 -0
  4. package/dist/{chunk-XE7HZVMH.js → chunk-BGI4BQ66.js} +1 -1
  5. package/dist/chunk-BGI4BQ66.js.map +1 -0
  6. package/dist/chunk-EKUSNUBT.js +222 -0
  7. package/dist/chunk-EKUSNUBT.js.map +1 -0
  8. package/dist/chunk-FTFLU6KH.js +114 -0
  9. package/dist/chunk-FTFLU6KH.js.map +1 -0
  10. package/dist/{chunk-HHMRCMQU.js → chunk-GGAZOLNN.js} +2 -2
  11. package/dist/{chunk-CUZW62JS.js → chunk-KU4FVOFA.js} +2 -2
  12. package/dist/{chunk-WHBMPHQE.js → chunk-KZG7QGFA.js} +28 -6
  13. package/dist/chunk-KZG7QGFA.js.map +1 -0
  14. package/dist/{chunk-MMLV4EZT.js → chunk-M5XTROYM.js} +2 -2
  15. package/dist/chunk-ZFIYBNZ7.js +502 -0
  16. package/dist/chunk-ZFIYBNZ7.js.map +1 -0
  17. package/dist/{create-api-notifications-B3u6Kx3x.d.ts → create-api-notifications-B-VMezVB.d.ts} +43 -4
  18. package/dist/{create-web-notifications-BHCzaU2y.d.ts → create-web-notifications-B-xQ6R0B.d.ts} +2 -2
  19. package/dist/email/index.d.ts +26 -0
  20. package/dist/email/index.js +20 -0
  21. package/dist/email/index.js.map +1 -0
  22. package/dist/email/locales.d.ts +43 -0
  23. package/dist/email/locales.js +29 -0
  24. package/dist/email/locales.js.map +1 -0
  25. package/dist/email/previews/hono.d.ts +46 -0
  26. package/dist/email/previews/hono.js +33 -0
  27. package/dist/email/previews/hono.js.map +1 -0
  28. package/dist/email/previews/index.d.ts +67 -0
  29. package/dist/email/previews/index.js +12 -0
  30. package/dist/email/previews/index.js.map +1 -0
  31. package/dist/email/previews/react/index.d.ts +43 -0
  32. package/dist/email/previews/react/index.js +58 -0
  33. package/dist/email/previews/react/index.js.map +1 -0
  34. package/dist/{generators-B9xt3sRh.d.ts → generators-BsuXe8Mp.d.ts} +1 -1
  35. package/dist/hono/index.d.ts +6 -5
  36. package/dist/hono/index.js +4 -3
  37. package/dist/hono/index.js.map +1 -1
  38. package/dist/index.d.ts +3 -3
  39. package/dist/index.js +5 -5
  40. package/dist/{jobs-BDZ7aGHV.d.ts → jobs-D64B_bg7.d.ts} +1 -1
  41. package/dist/manifest/index.d.ts +39 -1
  42. package/dist/manifest/index.js +9 -0
  43. package/dist/manifest/index.js.map +1 -1
  44. package/dist/manifest/server.d.ts +32 -7
  45. package/dist/manifest/server.js +15 -4
  46. package/dist/manifest/server.js.map +1 -1
  47. package/dist/manifest/web.d.ts +18 -4
  48. package/dist/manifest/web.js +9 -1
  49. package/dist/manifest/web.js.map +1 -1
  50. package/dist/{preferences-screen-IOW6Y2H2.js → preferences-screen-V3ZBWP7A.js} +2 -2
  51. package/dist/preview-screen-DYJRAnAY.d.ts +83 -0
  52. package/dist/react/index.d.ts +4 -4
  53. package/dist/react/index.js +10 -10
  54. package/dist/server/index.d.ts +8 -7
  55. package/dist/server/index.js +5 -4
  56. package/dist/template-Dlt5S54z.d.ts +196 -0
  57. package/dist/{types-CXLAG3UU.d.ts → types-BFmAY0P5.d.ts} +39 -2
  58. package/dist/web-push/index.d.ts +2 -2
  59. package/dist/{web-push-Cs14Wp9u.d.ts → web-push-D-ysQd5h.d.ts} +1 -1
  60. package/dist/{wire-6dzyfDE7.d.ts → wire-_coV3tJk.d.ts} +1 -1
  61. package/package.json +23 -3
  62. package/src/email/chrome.en-US.ts +8 -0
  63. package/src/email/chrome.pt-BR.ts +21 -0
  64. package/src/email/index.ts +43 -0
  65. package/src/email/locales.ts +29 -0
  66. package/src/email/previews/catalog.ts +208 -0
  67. package/src/email/previews/hono.ts +69 -0
  68. package/src/email/previews/index.ts +26 -0
  69. package/src/email/previews/react/copy.en-US.ts +24 -0
  70. package/src/email/previews/react/copy.pt-BR.ts +24 -0
  71. package/src/email/previews/react/copy.ts +42 -0
  72. package/src/email/previews/react/index.tsx +14 -0
  73. package/src/email/previews/react/loadable.tsx +83 -0
  74. package/src/email/previews/react/message-list.tsx +133 -0
  75. package/src/email/previews/react/message-view.tsx +167 -0
  76. package/src/email/previews/react/preview-screen.tsx +342 -0
  77. package/src/email/previews/react/transport.ts +60 -0
  78. package/src/email/previews/routes.ts +111 -0
  79. package/src/email/template.ts +365 -0
  80. package/src/email/text.ts +59 -0
  81. package/src/email/theme.ts +77 -0
  82. package/src/index.ts +1 -0
  83. package/src/manifest/index.ts +37 -0
  84. package/src/manifest/server.ts +23 -0
  85. package/src/manifest/web.ts +13 -0
  86. package/src/server/db.ts +12 -1
  87. package/src/server/dispatch.ts +4 -0
  88. package/src/server/router.ts +17 -1
  89. package/src/server/transports/email.ts +62 -0
  90. package/src/types.ts +39 -1
  91. package/dist/chunk-WHBMPHQE.js.map +0 -1
  92. package/dist/chunk-XE7HZVMH.js.map +0 -1
  93. /package/dist/{chunk-HHMRCMQU.js.map → chunk-GGAZOLNN.js.map} +0 -0
  94. /package/dist/{chunk-CUZW62JS.js.map → chunk-KU4FVOFA.js.map} +0 -0
  95. /package/dist/{chunk-MMLV4EZT.js.map → chunk-M5XTROYM.js.map} +0 -0
  96. /package/dist/{preferences-screen-IOW6Y2H2.js.map → preferences-screen-V3ZBWP7A.js.map} +0 -0
@@ -0,0 +1,365 @@
1
+ import { renderEmailText } from './text';
2
+ import {
3
+ EMAIL_CONTENT_WIDTH,
4
+ EMAIL_FONT_STACK,
5
+ NEUTRAL_EMAIL_THEME,
6
+ type EmailTheme,
7
+ } from './theme';
8
+
9
+ /**
10
+ * THE transactional-mail layout: one document model, one HTML renderer, one
11
+ * plain-text twin.
12
+ *
13
+ * ## The problem it exists for
14
+ *
15
+ * A host that sends mail from more than one place ends up rendering it in more
16
+ * than one way — the notification pipeline emits a bold paragraph and a bare
17
+ * anchor, the sign-in flow emits a `div` with a max-width, the receipt builds
18
+ * its own list — and each grows its own `escapeHtml` and its own text twin. The
19
+ * three look like three different products, because a paragraph with no
20
+ * document around it inherits whatever the client decides: 13px Arial in Gmail,
21
+ * Times New Roman in Outlook, no centring anywhere.
22
+ *
23
+ * That is a LAYOUT problem rather than a wording one, which is what makes it a
24
+ * package's to solve. The words stay the host's; the document does not.
25
+ *
26
+ * ## Why the document is a MODEL and not a string
27
+ *
28
+ * Every caller hands over structure — a heading, some paragraphs, an optional
29
+ * facts table, at most one call to action — and never markup. Three things fall
30
+ * out of that, and each is a real defect in the hand-rolled renderers this
31
+ * replaces:
32
+ *
33
+ * 1. **Escaping cannot be forgotten.** Every field is escaped here, once. Of
34
+ * the three renderers this was extracted from, two escaped the body and
35
+ * interpolated the LINK raw.
36
+ * 2. **The plain-text twin cannot drift.** Both halves render from the same
37
+ * object, so a line added to one is in the other. A `text/html` part with no
38
+ * `text/plain` twin is scored by every major spam filter, and it is what a
39
+ * watch, a terminal client and a screen reader in plain-text mode show.
40
+ * 3. **A preview is honest.** `./server`'s catalogue renders exactly this, from
41
+ * exactly these inputs, so a preview cannot be right about a mail nobody
42
+ * gets.
43
+ *
44
+ * ## The client constraints this encodes
45
+ *
46
+ * Stated once here, so no caller has to know them:
47
+ *
48
+ * - **Tables, not divs.** Outlook 2016+ on Windows lays HTML out with Word,
49
+ * which supports no `flex`, no `grid` and no reliable `max-width` on a block.
50
+ * A centred 600px table is the one construction every client agrees on.
51
+ * - **Inline styles.** Gmail strips `<style>` from the document it renders in
52
+ * several contexts, and always in the Gmail app for a non-Gmail account. The
53
+ * `<style>` block below therefore carries only the mobile media query —
54
+ * progressive enhancement, never anything the layout depends on.
55
+ * - **No web fonts, no `color-mix()`, no CSS variables.** See `./theme`.
56
+ * - **A preheader.** The inbox list shows the first text in the body after the
57
+ * subject; without one it shows the footer's legal line.
58
+ * - **`role="presentation"`** on every layout table, so a screen reader reads
59
+ * the message rather than announcing a five-column grid.
60
+ */
61
+
62
+ /** The words the LAYOUT itself needs — never the message's own sentences. */
63
+ export interface EmailChromeCopy {
64
+ /** "If the button does not work, paste this address into your browser:" */
65
+ readonly fallbackHint: string;
66
+ /** "This is an automated message — please do not reply." */
67
+ readonly automated: string;
68
+ /** The footer's one line about the product, given the brand name. */
69
+ readonly tagline: (brand: string) => string;
70
+ }
71
+
72
+ /** The one call to action a message may carry. More than one dilutes both. */
73
+ export interface EmailAction {
74
+ readonly label: string;
75
+ readonly href: string;
76
+ }
77
+
78
+ /** One row of the facts panel — a receipt's total, an order's reference. */
79
+ export interface EmailFact {
80
+ readonly label: string;
81
+ readonly value: string;
82
+ /** Renders bolder and larger — the one number the reader came for. */
83
+ readonly emphasis?: boolean;
84
+ }
85
+
86
+ /** Everything a message says, with no opinion about how it looks. */
87
+ export interface EmailDocument {
88
+ /** The subject line. Also the document `<title>`. */
89
+ readonly subject: string;
90
+ /**
91
+ * The inbox-list preview, ~90 chars. Falls back to the first paragraph,
92
+ * which is right far more often than it is wrong.
93
+ */
94
+ readonly preheader?: string;
95
+ /** The `<h1>`. Usually a restatement of the subject in the reader's terms. */
96
+ readonly heading: string;
97
+ /** The body, one entry per paragraph. Plain sentences — never markup. */
98
+ readonly paragraphs?: readonly string[];
99
+ /** An optional label/value panel under the body. */
100
+ readonly facts?: readonly EmailFact[];
101
+ /** At most one CTA. */
102
+ readonly action?: EmailAction;
103
+ /** Small print under the CTA — a deadline, a "you can ignore this". */
104
+ readonly notes?: readonly string[];
105
+ /**
106
+ * The layout's own words, in the recipient's language.
107
+ *
108
+ * REQUIRED, with no default in any language — the copy-portability doctrine.
109
+ * `@12-apps/notifications/email/locales` ships a pack per language and a host
110
+ * passes one BY
111
+ * NAME, which is a decision rather than a silence.
112
+ */
113
+ readonly chrome: EmailChromeCopy;
114
+ /**
115
+ * The product name in the header and the footer. REQUIRED: a package that
116
+ * defaulted this would sign another company's mail.
117
+ */
118
+ readonly brand: string;
119
+ /** The recipient's language, for the document's `lang` attribute. */
120
+ readonly locale: string;
121
+ /** Defaults to {@link NEUTRAL_EMAIL_THEME} — see `./theme` for why this one may. */
122
+ readonly theme?: EmailTheme;
123
+ }
124
+
125
+ /**
126
+ * Escape everything that reaches the HTML body.
127
+ *
128
+ * A store name, a buyer's display name and a product title are all user input
129
+ * that reaches a mail, and the ONE thing a shared layout must guarantee is that
130
+ * none of them can close a tag. Single quotes are escaped too: attribute values
131
+ * below are double-quoted, but a caller reading this should not have to check.
132
+ */
133
+ function escapeHtml(value: string): string {
134
+ return value
135
+ .replaceAll("&", "&amp;")
136
+ .replaceAll("<", "&lt;")
137
+ .replaceAll(">", "&gt;")
138
+ .replaceAll('"', "&quot;")
139
+ .replaceAll("'", "&#39;");
140
+ }
141
+
142
+ /**
143
+ * The schemes a link in a mail may use.
144
+ *
145
+ * `javascript:` is inert in every mail client, so this is not the XSS guard it
146
+ * would be on a page — it is the guard for the PREVIEW, which renders the same
147
+ * HTML in a browser, and for the day some part of this markup is reused on a
148
+ * screen. A rejected href drops to `#`, which fails visibly rather than
149
+ * silently linking somewhere unexpected.
150
+ */
151
+ const SAFE_LINK_SCHEMES = new Set(["http:", "https:", "mailto:"]);
152
+
153
+ /** A URL as an attribute value, or `#` when it is not one this layout will emit. */
154
+ export function safeHref(href: string): string {
155
+ const trimmed = href.trim();
156
+ // A relative link is fine and common — every caller that has an origin
157
+ // resolves it before it gets here, and one that does not is better off with
158
+ // a path than with nothing.
159
+ if (trimmed.startsWith("/")) return escapeHtml(trimmed);
160
+ try {
161
+ const url = new URL(trimmed);
162
+ return SAFE_LINK_SCHEMES.has(url.protocol) ? escapeHtml(url.toString()) : "#";
163
+ } catch {
164
+ return "#";
165
+ }
166
+ }
167
+
168
+ /** The message's own first sentence, when the caller named no preheader. */
169
+ function preheaderOf(document: EmailDocument): string {
170
+ return document.preheader ?? document.paragraphs?.[0] ?? document.heading;
171
+ }
172
+
173
+ /** A `<td>` of body copy. One paragraph, at the layout's own rhythm. */
174
+ function paragraph(text: string, theme: EmailTheme): string {
175
+ return `<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:${theme.ink}">${escapeHtml(text)}</p>`;
176
+ }
177
+
178
+ /**
179
+ * The facts panel — a receipt's four lines, an order's reference.
180
+ *
181
+ * Two cells per row rather than a definition list: `<dl>` is styled
182
+ * inconsistently across clients and collapses to a single column in Outlook,
183
+ * which is the one place these are read side by side.
184
+ */
185
+ function facts(rows: readonly EmailFact[], theme: EmailTheme): string {
186
+ if (rows.length === 0) return "";
187
+ const cells = rows
188
+ .map((row, index) => {
189
+ const divider =
190
+ index === 0 ? "" : `border-top:1px solid ${theme.border};`;
191
+ const value = row.emphasis
192
+ ? `font-size:20px;font-weight:700;color:${theme.ink}`
193
+ : `font-size:15px;color:${theme.ink}`;
194
+ return [
195
+ `<tr>`,
196
+ `<td style="${divider}padding:12px 0 12px 16px;font-size:13px;line-height:1.4;color:${theme.muted};text-transform:uppercase;letter-spacing:.4px;white-space:nowrap">${escapeHtml(row.label)}</td>`,
197
+ `<td align="right" style="${divider}padding:12px 16px 12px 0;line-height:1.4;${value}">${escapeHtml(row.value)}</td>`,
198
+ `</tr>`,
199
+ ].join("");
200
+ })
201
+ .join("");
202
+ return [
203
+ `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:separate;background:${theme.page};border:1px solid ${theme.border};border-radius:10px;margin:0 0 24px">`,
204
+ cells,
205
+ `</table>`,
206
+ ].join("");
207
+ }
208
+
209
+ /**
210
+ * The CTA.
211
+ *
212
+ * A single-cell table with `bgcolor` AND a background style: Outlook reads the
213
+ * attribute and ignores the property, every other client does the reverse, and
214
+ * a button that loses its fill is invisible ink on white. `border-radius` is
215
+ * simply dropped by Word — a square button is the accepted degradation, and the
216
+ * alternative (a VML rounded rectangle) is forty lines of conditional comment
217
+ * that then has to be kept in step with the colour.
218
+ */
219
+ function action(cta: EmailAction, theme: EmailTheme): string {
220
+ return [
221
+ `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:8px 0 24px">`,
222
+ `<tr>`,
223
+ `<td align="center" bgcolor="${theme.accent}" style="border-radius:8px;background:${theme.accent}">`,
224
+ `<a href="${safeHref(cta.href)}" style="display:inline-block;padding:14px 32px;font-family:${EMAIL_FONT_STACK};font-size:16px;font-weight:600;line-height:1;color:${theme.onAccent};text-decoration:none;border-radius:8px">${escapeHtml(cta.label)}</a>`,
225
+ `</td>`,
226
+ `</tr>`,
227
+ `</table>`,
228
+ ].join("");
229
+ }
230
+
231
+ /**
232
+ * The "paste this address" fallback, printed whenever there is a CTA.
233
+ *
234
+ * Not optional, and the reason is measured elsewhere in this repo: corporate
235
+ * mail gateways rewrite link targets, and a reader whose gateway mangles the
236
+ * button has no other way to reach a verification or reset link. `word-break`
237
+ * because a signed URL is longer than the card.
238
+ */
239
+ function fallback(cta: EmailAction, chrome: EmailChromeCopy, theme: EmailTheme): string {
240
+ return [
241
+ `<p style="margin:0 0 8px;font-size:13px;line-height:1.5;color:${theme.muted}">${escapeHtml(chrome.fallbackHint)}</p>`,
242
+ `<p style="margin:0 0 24px;font-size:13px;line-height:1.5;word-break:break-all"><a href="${safeHref(cta.href)}" style="color:${theme.accent};text-decoration:underline">${escapeHtml(cta.href)}</a></p>`,
243
+ ].join("");
244
+ }
245
+
246
+ /** The brand header: wordmark over the ramp's rule. */
247
+ function header(document: EmailDocument, theme: EmailTheme): string {
248
+ return [
249
+ `<tr>`,
250
+ `<td style="padding:32px 40px 0">`,
251
+ `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">`,
252
+ `<tr><td style="font-size:20px;font-weight:700;letter-spacing:-.2px;color:${theme.ink}">${escapeHtml(document.brand)}</td></tr>`,
253
+ `<tr><td style="padding-top:12px"><div style="height:3px;width:44px;background:${theme.rule};border-radius:2px;font-size:0;line-height:0">&nbsp;</div></td></tr>`,
254
+ `</table>`,
255
+ `</td>`,
256
+ `</tr>`,
257
+ ].join("");
258
+ }
259
+
260
+ /** The footer: the tagline, the "do not reply" line, and nothing else. */
261
+ function footer(document: EmailDocument, theme: EmailTheme): string {
262
+ const { chrome, brand } = document;
263
+ return [
264
+ `<tr>`,
265
+ `<td style="padding:0 40px 40px">`,
266
+ `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">`,
267
+ `<tr><td style="border-top:1px solid ${theme.border};padding-top:20px">`,
268
+ `<p style="margin:0 0 6px;font-size:13px;line-height:1.5;color:${theme.muted}">${escapeHtml(chrome.tagline(brand))}</p>`,
269
+ `<p style="margin:0;font-size:12px;line-height:1.5;color:${theme.muted}">${escapeHtml(chrome.automated)}</p>`,
270
+ `</td></tr>`,
271
+ `</table>`,
272
+ `</td>`,
273
+ `</tr>`,
274
+ ].join("");
275
+ }
276
+
277
+ /**
278
+ * The mobile media query — the one thing in a `<style>` block.
279
+ *
280
+ * Everything it does is also correct without it: the card is already
281
+ * `width:100%` up to 600px, so a client that strips this renders a slightly
282
+ * roomier mail rather than a broken one. That is the test for whether a rule
283
+ * belongs here at all.
284
+ */
285
+ function styleBlock(): string {
286
+ return [
287
+ `<style>`,
288
+ `@media only screen and (max-width:620px){`,
289
+ `.fp-card{width:100%!important;border-radius:0!important;border-left:0!important;border-right:0!important}`,
290
+ `.fp-pad{padding-left:24px!important;padding-right:24px!important}`,
291
+ `.fp-h1{font-size:22px!important}`,
292
+ `}`,
293
+ `</style>`,
294
+ ].join("");
295
+ }
296
+
297
+ /**
298
+ * Render the document as the HTML half of the message.
299
+ *
300
+ * A complete document rather than a fragment: a `<!DOCTYPE>` is what puts
301
+ * Outlook into standards mode, and `x-apple-disable-message-reformatting` is
302
+ * what stops iOS Mail re-flowing the card to the screen width and shrinking the
303
+ * type with it.
304
+ */
305
+ export function renderEmailHtml(document: EmailDocument): string {
306
+ const theme = document.theme ?? NEUTRAL_EMAIL_THEME;
307
+ const body = [
308
+ `<h1 class="fp-h1" style="margin:0 0 16px;font-size:26px;line-height:1.25;font-weight:700;letter-spacing:-.3px;color:${theme.ink}">${escapeHtml(document.heading)}</h1>`,
309
+ ...(document.paragraphs ?? []).map((text) => paragraph(text, theme)),
310
+ facts(document.facts ?? [], theme),
311
+ ...(document.action ? [action(document.action, theme)] : []),
312
+ ...(document.action ? [fallback(document.action, document.chrome, theme)] : []),
313
+ ...(document.notes ?? []).map(
314
+ (note) =>
315
+ `<p style="margin:0 0 8px;font-size:13px;line-height:1.5;color:${theme.muted}">${escapeHtml(note)}</p>`,
316
+ ),
317
+ ].join("\n ");
318
+
319
+ return `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "https://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
320
+ <html xmlns="http://www.w3.org/1999/xhtml" lang="${escapeHtml(document.locale)}">
321
+ <head>
322
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
323
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
324
+ <meta name="x-apple-disable-message-reformatting" />
325
+ <meta name="color-scheme" content="light" />
326
+ <meta name="supported-color-schemes" content="light" />
327
+ <title>${escapeHtml(document.subject)}</title>
328
+ ${styleBlock()}
329
+ </head>
330
+ <body style="margin:0;padding:0;background:${theme.page};font-family:${EMAIL_FONT_STACK};-webkit-font-smoothing:antialiased">
331
+ <div style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all">${escapeHtml(preheaderOf(document))}</div>
332
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:${theme.page}">
333
+ <tr>
334
+ <td align="center" style="padding:32px 12px">
335
+ <table role="presentation" class="fp-card" width="${EMAIL_CONTENT_WIDTH}" cellpadding="0" cellspacing="0" border="0" style="width:${EMAIL_CONTENT_WIDTH}px;max-width:100%;background:${theme.surface};border:1px solid ${theme.border};border-radius:14px">
336
+ ${header(document, theme)}
337
+ <tr>
338
+ <td class="fp-pad" style="padding:24px 40px 8px">
339
+ ${body}
340
+ </td>
341
+ </tr>
342
+ ${footer(document, theme)}
343
+ </table>
344
+ </td>
345
+ </tr>
346
+ </table>
347
+ </body>
348
+ </html>`;
349
+ }
350
+
351
+ /** Both halves plus the subject — what every driver in this repo is handed. */
352
+ export interface RenderedEmail {
353
+ readonly subject: string;
354
+ readonly html: string;
355
+ readonly text: string;
356
+ }
357
+
358
+ /** Render one document into the message shape `EmailDriver.send` takes. */
359
+ export function renderEmail(document: EmailDocument): RenderedEmail {
360
+ return {
361
+ subject: document.subject,
362
+ html: renderEmailHtml(document),
363
+ text: renderEmailText(document),
364
+ };
365
+ }
@@ -0,0 +1,59 @@
1
+ import type { EmailDocument } from './template';
2
+
3
+ /**
4
+ * The plain-text half.
5
+ *
6
+ * Not a courtesy. Every major spam filter scores a `text/html` part with no
7
+ * `text/plain` twin, and a watch, a terminal client and a screen reader in
8
+ * plain-text mode show this and nothing else.
9
+ *
10
+ * It renders from the SAME `EmailDocument` the HTML half does, which is the
11
+ * property that matters: a line added to one is in the other, so the two cannot
12
+ * drift. Its own module only because the HTML renderer is already a file's
13
+ * worth of client workarounds — the seam is the output format, not the content.
14
+ */
15
+
16
+ /**
17
+ * Render the document as the plain-text half.
18
+ *
19
+ * Not a courtesy. Every major spam filter scores a `text/html` part with no
20
+ * `text/plain` twin, and a watch, a terminal client and a screen reader in
21
+ * plain-text mode show this and nothing else. It is rendered from the SAME
22
+ * object, so it cannot say less than the HTML does.
23
+ */
24
+ export function renderEmailText(document: EmailDocument): string {
25
+ const blocks = [
26
+ document.heading,
27
+ "",
28
+ ...textParagraphs(document),
29
+ ...textFacts(document),
30
+ ...textAction(document),
31
+ ...textNotes(document),
32
+ "--",
33
+ document.chrome.tagline(document.brand),
34
+ document.chrome.automated,
35
+ ];
36
+ return `${blocks.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
37
+ }
38
+
39
+ /** One blank line after each paragraph, as prose reads. */
40
+ function textParagraphs(document: EmailDocument): string[] {
41
+ return (document.paragraphs ?? []).flatMap((text) => [text, ""]);
42
+ }
43
+
44
+ /** `Label: value` per row, as a receipt is quoted back over the phone. */
45
+ function textFacts(document: EmailDocument): string[] {
46
+ const rows = document.facts ?? [];
47
+ return rows.length === 0 ? [] : [...rows.map((f) => `${f.label}: ${f.value}`), ""];
48
+ }
49
+
50
+ /** The CTA as label + URL: a plain-text reader has no button to press. */
51
+ function textAction(document: EmailDocument): string[] {
52
+ return document.action ? [`${document.action.label}: ${document.action.href}`, ""] : [];
53
+ }
54
+
55
+ function textNotes(document: EmailDocument): string[] {
56
+ const notes = document.notes ?? [];
57
+ return notes.length === 0 ? [] : [...notes, ""];
58
+ }
59
+
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The layout's design tokens — the nine colours a mail is drawn from.
3
+ *
4
+ * ## Why a theme is a VALUE here and copy is required config
5
+ *
6
+ * The copy-portability doctrine is that a package ships no user-facing
7
+ * sentences: a default in the origin host's language reads as finished to the
8
+ * next host right up until somebody's customer gets mail in a language they do
9
+ * not speak. Colour is not that. {@link NEUTRAL_EMAIL_THEME} is not anybody's
10
+ * brand — it is grey on white, which reads as *unstyled but tidy* rather than
11
+ * as *somebody else's product*. A host that passes nothing gets a mail that
12
+ * looks plain, not a mail that looks like a different company.
13
+ *
14
+ * So this one has a default and the copy does not, and the difference is
15
+ * exactly whether getting it wrong is invisible. A missing theme is visible in
16
+ * the first preview; a wrong-language default is invisible until a customer
17
+ * complains.
18
+ *
19
+ * ## What a host should know before overriding it
20
+ *
21
+ * Two rungs of most brand palettes cannot carry light text, and a mail has no
22
+ * theme to fall back on when a client ignores a colour. {@link accent} is the
23
+ * one that MUST clear 4.5:1 against white — it is the CTA fill and every link.
24
+ * A warm mid-tone that looks fine on a screen at 2.6:1 is unreadable in an
25
+ * inbox, and nothing in the pipeline will say so.
26
+ */
27
+ export interface EmailTheme {
28
+ /** The page behind the card. Never pure white: a white card needs a ground. */
29
+ readonly page: string;
30
+ /** The card itself. */
31
+ readonly surface: string;
32
+ /** A quieter panel inside the card — the facts table. */
33
+ readonly panel: string;
34
+ /** Every hairline. */
35
+ readonly border: string;
36
+ /** Body text. */
37
+ readonly ink: string;
38
+ /** Secondary text: the footer, the fallback link, a fact's label. */
39
+ readonly muted: string;
40
+ /** The action colour — the CTA fill and every link. Must clear 4.5:1 on white. */
41
+ readonly accent: string;
42
+ /** Ink ON {@link accent}. */
43
+ readonly onAccent: string;
44
+ /** The rule under the wordmark. Decorative — nothing is written on it. */
45
+ readonly rule: string;
46
+ }
47
+
48
+ /**
49
+ * A brand-free default: greys, one blue, nothing anybody would mistake for a
50
+ * product. Every value clears its contrast requirement against its own ground.
51
+ */
52
+ export const NEUTRAL_EMAIL_THEME: EmailTheme = {
53
+ page: '#F5F6F8',
54
+ surface: '#FFFFFF',
55
+ panel: '#F5F6F8',
56
+ border: '#E1E4E9',
57
+ ink: '#1A1D23',
58
+ muted: '#5C6470',
59
+ accent: '#1F5EDB',
60
+ onAccent: '#FFFFFF',
61
+ rule: '#1F5EDB',
62
+ };
63
+
64
+ /**
65
+ * The font stack, as one string.
66
+ *
67
+ * System fonts only, and no web font: a `@font-face` is stripped by Gmail and
68
+ * by Outlook, so a mail that depends on one renders in whatever the client
69
+ * substitutes — never the fallback the designer chose. The stack ends in the
70
+ * emoji faces so a status glyph in a subject line does not drop to a tofu box
71
+ * on Windows.
72
+ */
73
+ export const EMAIL_FONT_STACK =
74
+ "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'";
75
+
76
+ /** The card's width. 600px is the width every client lays out without scroll. */
77
+ export const EMAIL_CONTENT_WIDTH = 600;
package/src/index.ts CHANGED
@@ -28,6 +28,7 @@ export {
28
28
  type NotificationChannel,
29
29
  type NotificationContent,
30
30
  type NotificationEvent,
31
+ type NotificationGenerateContext,
31
32
  type NotificationGenerator,
32
33
  type NotificationLogger,
33
34
  type NotificationRecipient,
@@ -61,3 +61,40 @@ export const notificationsManifest = {
61
61
  server: ['http', 'jobs'],
62
62
  web: ['surface'],
63
63
  } as const satisfies PackageManifest;
64
+
65
+ /**
66
+ * The e-mail PREVIEW console — a second manifest, from the same package.
67
+ *
68
+ * ## Why a second manifest and not two more keys on the first
69
+ *
70
+ * `notificationsManifest` has already spent both slots this surface needs:
71
+ * `http` is the account inbox at `/api/account`, `surface` is the bell and the
72
+ * preference matrix. A capability is singular by the contract's shape, so a
73
+ * package with two genuinely different surfaces declares two manifests — which
74
+ * is exactly what `@12-apps/auth` does with `@12-apps/auth-platform`, and for
75
+ * the same reason it gives: those two switches turn a sign-in method off for
76
+ * EVERYBODY, so they do not belong behind the same gate as "reset my password".
77
+ *
78
+ * The split here is the same one. The inbox ships to every signed-in user; this
79
+ * console publishes the product's whole transactional-mail inventory and the
80
+ * exact wording and link shape of its verification and reset mails, which is
81
+ * the reference someone writing a convincing phishing mail would want. Two
82
+ * manifests keep that expressible: a host mounts the inbox and DECLINES the
83
+ * console, in writing, rather than silently getting both behind one gate.
84
+ *
85
+ * ## What it deliberately does not declare
86
+ *
87
+ * No `db` — a catalogue is DERIVED from what a host already sends, so an
88
+ * adopter mounts it without touching its schema. No `notifications`, because
89
+ * this surface authors no alert. And no `email`: that capability is a DELIVERY
90
+ * port, and this manifest renders and never sends. The delivery port is the
91
+ * OTHER half of this package, and it stays where it is.
92
+ */
93
+ export const notificationEmailPreviewsManifest = {
94
+ name: '@12-apps/notifications-email-previews',
95
+ contract: 1,
96
+ /** A refusal to render a preview files under its own namespace, not nowhere. */
97
+ observability: { namespace: 'email-previews' },
98
+ server: ['http'],
99
+ web: ['surface'],
100
+ } as const satisfies PackageManifest;
@@ -25,6 +25,8 @@
25
25
 
26
26
  import type { AnyServerManifest, WireRequest } from '@12-apps/wiring';
27
27
 
28
+ import { emailPreviewRoutes } from '../email/previews/routes';
29
+ import type { EmailPreviewsConfig } from '../email/previews/catalog';
28
30
  import {
29
31
  createApiNotifications,
30
32
  NOTIFICATIONS_JOBS,
@@ -84,3 +86,24 @@ export const notificationsServerManifest = {
84
86
  */
85
87
  jobs: NOTIFICATIONS_JOBS,
86
88
  } as const satisfies AnyServerManifest;
89
+
90
+ /**
91
+ * The preview console's server half — the two endpoints over the catalogue.
92
+ *
93
+ * A CONSTANT, not a factory. `http.create(config)` receives whatever the host
94
+ * bound at adoption, and "which messages exist" is precisely a host's binding:
95
+ * a package cannot know that a product sends a "your quota is exhausted"
96
+ * notice, let alone what data it renders from. Writing `EmailPreviewsConfig`
97
+ * as a factory argument instead would move that decision out of
98
+ * `bindings.http`, where `assemble()` can report on it, into a call the report
99
+ * never sees.
100
+ *
101
+ * The routes carry `kind: 'authenticated'` — see `../email/previews/routes`
102
+ * for why the descriptor states a posture it cannot itself enforce.
103
+ */
104
+ export const notificationEmailPreviewsServerManifest = {
105
+ name: '@12-apps/notifications-email-previews',
106
+ http: {
107
+ create: (config: EmailPreviewsConfig) => ({ routes: emailPreviewRoutes(config) }),
108
+ },
109
+ } as const satisfies AnyServerManifest;
@@ -38,9 +38,22 @@
38
38
 
39
39
  import type { AnyWebManifest } from '@12-apps/wiring';
40
40
 
41
+ import { createEmailPreviewScreen } from '../email/previews/react/preview-screen';
41
42
  import { createWebNotifications } from '../react/create-web-notifications';
42
43
 
43
44
  export const notificationsWebManifest = {
44
45
  name: '@12-apps/notifications',
45
46
  surface: { create: createWebNotifications },
46
47
  } as const satisfies AnyWebManifest;
48
+
49
+ /**
50
+ * The preview console's web half.
51
+ *
52
+ * One screen, named `page`, because a screen's NAME is what an area row
53
+ * resolves against — a surface that was itself the component is the shape that
54
+ * made an area row resolve to `undefined` in `@12-apps/auth` for a release.
55
+ */
56
+ export const notificationEmailPreviewsWebManifest = {
57
+ name: '@12-apps/notifications-email-previews',
58
+ surface: { create: createEmailPreviewScreen },
59
+ } as const satisfies AnyWebManifest;
package/src/server/db.ts CHANGED
@@ -246,7 +246,18 @@ export type NotificationsDbProvider = () => Promise<NotificationsDb>;
246
246
  * Returning `null` for a user id means "no such recipient", which `notify`
247
247
  * treats as a caller bug and throws on — a notification addressed to nobody is
248
248
  * never silently dropped.
249
+ *
250
+ * `locale` is the recipient's own language, and it is here rather than on the
251
+ * event because it is a fact about the PERSON, not about what happened
252
+ * The host owns it for the same reason it owns the address: this
253
+ * package has no user table to read it from. Absent — the field omitted, or
254
+ * `null` for a host that stores no language yet — means "nobody said", and
255
+ * every generator answers with its default exactly as before.
249
256
  */
250
257
  export interface NotificationContactDirectory {
251
- getContact(userId: string): Promise<{ email: string | null; phone: string | null } | null>;
258
+ getContact(userId: string): Promise<{
259
+ email: string | null;
260
+ phone: string | null;
261
+ locale?: string | null;
262
+ } | null>;
252
263
  }
@@ -99,6 +99,10 @@ export async function loadRecipient(
99
99
  userId,
100
100
  email: contact.email,
101
101
  phone: contact.phone,
102
+ // Carried through UNRESOLVED and only where the host supplied one: the
103
+ // absent case has to stay distinguishable from a stated language, because
104
+ // that is what lets a generator apply its own default in one place.
105
+ ...(contact.locale === undefined ? {} : { locale: contact.locale }),
102
106
  pushSubscriptionCount: await deps.pushSubscriptions.count(userId),
103
107
  };
104
108
  }
@@ -235,11 +235,27 @@ export function createNotificationRouter(deps: NotificationRouterDeps): Notifica
235
235
 
236
236
  async notify(event, options = {}) {
237
237
  const generator = deps.generators.resolve(event.type);
238
- const content = generator.generate(event.payload as never);
239
238
 
239
+ /*
240
+ The recipient is loaded BEFORE the content is rendered, and the order is
241
+ the whole of the fix.
242
+
243
+ `notify` is already per-person — `event.recipient.userId` is one user,
244
+ and a permission fan-out resolves its audience and calls this once each
245
+ — so the reader was knowable here all along. Rendering first simply
246
+ threw that away: the row was written in whatever language the generator
247
+ had been bound to at boot, for everyone.
248
+
249
+ Loading first also means a notification addressed to nobody now throws
250
+ before any content is built, which is the cheaper order anyway.
251
+ */
240
252
  const recipient = await loadRecipient(deps, event.recipient.userId);
241
253
  if (!recipient) throw new UnknownNotificationRecipientError(event.recipient.userId);
242
254
 
255
+ // Forwarded exactly as the directory stated it, `undefined` included: the
256
+ // generator owns the fallback, in one place a reader can find.
257
+ const content = generator.generate(event.payload as never, { locale: recipient.locale });
258
+
243
259
  const channels = await resolveChannels(deps, event, generator.category, recipient);
244
260
  const notification = await commit(deps, event, generator.category, content, channels);
245
261