@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,342 @@
1
+ import { useCallback, useMemo, useState, type JSX } from 'react';
2
+
3
+ import { Alert } from '@12-apps/ui/data-display/Alert';
4
+ import { Input } from '@12-apps/ui/form/Input';
5
+ import { ToggleGroup } from '@12-apps/ui/form/ToggleGroup';
6
+ import { Box } from '@12-apps/ui/mui/Box';
7
+ import { Heading } from '@12-apps/ui/typography/Heading';
8
+ import { Text } from '@12-apps/ui/typography/Text';
9
+
10
+ import type { EmailPreviewCoverage, EmailPreviewDetail, EmailPreviewIndex } from '../catalog';
11
+
12
+ import type { EmailPreviewScreenCopy } from './copy';
13
+ import { Failure, useLoadable } from './loadable';
14
+ import { MessageList, matchesFilter } from './message-list';
15
+ import { MessageView, type PreviewTab, type PreviewWidth } from './message-view';
16
+ import { fetchEmailPreview, fetchEmailPreviewIndex } from './transport';
17
+
18
+ /**
19
+ * The operator screen over the `./email/previews` catalogue.
20
+ *
21
+ * ## What a host supplies, and what it does not
22
+ *
23
+ * `apiBase` — where the routes are mounted — and `copy`, by name. Nothing else:
24
+ * the owners, the languages, the messages and their subjects all arrive from
25
+ * the surface, because they are facts about the host's own mail that no prop
26
+ * could usefully restate.
27
+ *
28
+ * ## The selection lives in the URL, without a router
29
+ *
30
+ * `?id=` and `?locale=`, read and written through `history.replaceState`. A
31
+ * link to one mail in one language is the actual workflow this screen serves —
32
+ * "look at what the reset mail says now" — and local state would make every
33
+ * such conversation a set of instructions instead of a link.
34
+ *
35
+ * Deliberately NOT a router integration: this package cannot know whether a
36
+ * host runs react-router, TanStack Router or a framework's own, and a screen
37
+ * that imported one would be unmountable in the other two. `replaceState` is
38
+ * the one API all of them are built on, and `replace` rather than `push` so
39
+ * browsing twenty mails is not twenty back-button steps.
40
+ */
41
+
42
+ export interface EmailPreviewScreenConfig {
43
+ /** Where the routes are mounted, e.g. `/api/platform/email-previews`. */
44
+ readonly apiBase: string;
45
+ /** The screen's words. REQUIRED — see `./copy`. */
46
+ readonly copy: EmailPreviewScreenCopy;
47
+ }
48
+
49
+ /** Read one search param without assuming a router owns the URL. */
50
+ function searchParam(name: string): string | null {
51
+ if (typeof window === 'undefined') return null;
52
+ return new URLSearchParams(window.location.search).get(name);
53
+ }
54
+
55
+ /**
56
+ * Patch the query string in place, keeping everything else about the URL.
57
+ *
58
+ * The HASH is carried over deliberately, and it is not defensive coding: a host
59
+ * that routes on `location.hash` — the consumer harness does, and so does any
60
+ * SPA served from a static file — would otherwise be navigated off this screen
61
+ * by its own locale switch, because rebuilding the URL from `pathname` alone
62
+ * silently drops the fragment that says which page this is.
63
+ *
64
+ * `replaceState` rather than `pushState` for the reason the screen exists:
65
+ * browsing twenty messages is one place to come back from, not twenty
66
+ * back-button steps.
67
+ */
68
+ function patchSearch(patch: Record<string, string>): void {
69
+ if (typeof window === 'undefined') return;
70
+ const next = new URLSearchParams(window.location.search);
71
+ for (const [key, value] of Object.entries(patch)) next.set(key, value);
72
+ const { pathname, hash } = window.location;
73
+ window.history.replaceState({}, '', `${pathname}?${next.toString()}${hash}`);
74
+ }
75
+
76
+ /** The surface's honest report about what it cannot show. */
77
+ function CoverageNotice({
78
+ coverage,
79
+ copy,
80
+ }: {
81
+ coverage: EmailPreviewCoverage;
82
+ copy: EmailPreviewScreenCopy;
83
+ }): JSX.Element | null {
84
+ if (coverage.missing.length === 0 && coverage.orphan.length === 0) return null;
85
+ return (
86
+ <Alert severity="warning" data-testid="email-preview-coverage">
87
+ <Text as="p" size="sm" weight="medium">
88
+ {copy.coverageTitle}
89
+ </Text>
90
+ {coverage.missing.length > 0 ? (
91
+ <Text as="p" size="sm">
92
+ {copy.missingSamples(coverage.missing.join(', '))}
93
+ </Text>
94
+ ) : null}
95
+ {coverage.orphan.length > 0 ? (
96
+ <Text as="p" size="sm">
97
+ {copy.orphanSamples(coverage.orphan.join(', '))}
98
+ </Text>
99
+ ) : null}
100
+ </Alert>
101
+ );
102
+ }
103
+
104
+ /** The right-hand pane: the selected message, or an invitation to pick one. */
105
+ function PreviewPane({
106
+ apiBase,
107
+ id,
108
+ locale,
109
+ copy,
110
+ tab,
111
+ width,
112
+ onTabChange,
113
+ onWidthChange,
114
+ }: {
115
+ apiBase: string;
116
+ id: string | null;
117
+ locale: string;
118
+ copy: EmailPreviewScreenCopy;
119
+ tab: PreviewTab;
120
+ width: PreviewWidth;
121
+ onTabChange: (tab: PreviewTab) => void;
122
+ onWidthChange: (width: PreviewWidth) => void;
123
+ }): JSX.Element {
124
+ const load = useCallback(
125
+ () =>
126
+ id === null
127
+ ? Promise.resolve(null as EmailPreviewDetail | null)
128
+ : fetchEmailPreview(apiBase, id, locale),
129
+ [apiBase, id, locale],
130
+ );
131
+ const detail = useLoadable(load);
132
+
133
+ if (id === null) {
134
+ return (
135
+ <Text as="p" size="sm" color="secondary" data-testid="email-preview-empty">
136
+ {copy.pickOne}
137
+ </Text>
138
+ );
139
+ }
140
+ if (detail.error !== null) {
141
+ return <Failure message={detail.error} copy={copy} onRetry={detail.reload} />;
142
+ }
143
+ if (detail.data === null) {
144
+ return (
145
+ <Text as="p" size="sm" color="secondary" data-testid="email-preview-detail-loading">
146
+ {copy.loading}
147
+ </Text>
148
+ );
149
+ }
150
+ return (
151
+ <MessageView
152
+ detail={detail.data}
153
+ copy={copy}
154
+ tab={tab}
155
+ width={width}
156
+ onTabChange={onTabChange}
157
+ onWidthChange={onWidthChange}
158
+ />
159
+ );
160
+ }
161
+
162
+ /** The left column: the filter, and the rows under their owners. */
163
+ function CatalogueColumn({
164
+ index,
165
+ copy,
166
+ selectedId,
167
+ onSelect,
168
+ }: {
169
+ index: EmailPreviewIndex;
170
+ copy: EmailPreviewScreenCopy;
171
+ selectedId: string | null;
172
+ onSelect: (id: string) => void;
173
+ }): JSX.Element {
174
+ const [filter, setFilter] = useState('');
175
+ const visible = useMemo(
176
+ () => index.items.filter((row) => matchesFilter(row, filter)),
177
+ [index.items, filter],
178
+ );
179
+ return (
180
+ /*
181
+ The list owns its OWN scroll rather than growing the page.
182
+
183
+ A host's console is typically a fixed-viewport shell whose centre column
184
+ scrolls, and a twenty-mail catalogue then scrolls the preview frame off
185
+ the screen — the two things this screen exists to show side by side
186
+ cannot both be on it. `sticky` keeps the list put while the frame is
187
+ read; the height is the viewport minus the chrome above it, so the column
188
+ ends where the window does rather than at an arbitrary pixel count.
189
+ */
190
+ <Box
191
+ sx={{
192
+ width: 320,
193
+ flexShrink: 0,
194
+ display: 'flex',
195
+ flexDirection: 'column',
196
+ gap: 1.5,
197
+ position: 'sticky',
198
+ top: 0,
199
+ maxHeight: 'calc(100dvh - 220px)',
200
+ minHeight: 240,
201
+ }}
202
+ >
203
+ <Input
204
+ label={copy.searchLabel}
205
+ placeholder={copy.searchPlaceholder}
206
+ value={filter}
207
+ data-testid="email-preview-filter"
208
+ onChange={(event) => setFilter(event.target.value)}
209
+ fullWidth
210
+ />
211
+ {/* Only the ROWS scroll — the filter field stays reachable. */}
212
+ <Box sx={{ overflowY: 'auto', flex: 1, pr: 0.5 }}>
213
+ <MessageList rows={visible} selectedId={selectedId} copy={copy} onSelect={onSelect} />
214
+ </Box>
215
+ </Box>
216
+ );
217
+ }
218
+
219
+ /** The catalogue and the preview, once the index has loaded. */
220
+ function Browser({
221
+ apiBase,
222
+ index,
223
+ copy,
224
+ locale,
225
+ selectedId,
226
+ onPatch,
227
+ }: {
228
+ apiBase: string;
229
+ index: EmailPreviewIndex;
230
+ copy: EmailPreviewScreenCopy;
231
+ locale: string;
232
+ selectedId: string | null;
233
+ onPatch: (patch: Record<string, string>) => void;
234
+ }): JSX.Element {
235
+ const [tab, setTab] = useState<PreviewTab>('html');
236
+ const [width, setWidth] = useState<PreviewWidth>('desktop');
237
+
238
+ return (
239
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
240
+ <CoverageNotice coverage={index.coverage} copy={copy} />
241
+ <ToggleGroup
242
+ dataTestId="email-preview-locale"
243
+ exclusive
244
+ value={locale}
245
+ size="sm"
246
+ options={index.locales.map((tag) => ({ value: tag, label: tag }))}
247
+ onChange={(_event, value) => {
248
+ if (value) onPatch({ locale: String(value) });
249
+ }}
250
+ />
251
+ <Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start', flexWrap: 'wrap' }}>
252
+ <CatalogueColumn
253
+ index={index}
254
+ copy={copy}
255
+ selectedId={selectedId}
256
+ onSelect={(id) => onPatch({ id })}
257
+ />
258
+ <Box sx={{ flex: 1, minWidth: 360 }}>
259
+ <PreviewPane
260
+ apiBase={apiBase}
261
+ id={selectedId}
262
+ locale={locale}
263
+ copy={copy}
264
+ tab={tab}
265
+ width={width}
266
+ onTabChange={setTab}
267
+ onWidthChange={setWidth}
268
+ />
269
+ </Box>
270
+ </Box>
271
+ </Box>
272
+ );
273
+ }
274
+
275
+ /**
276
+ * Build the screen. One call, one config object — the shape every factory in
277
+ * this estate has.
278
+ */
279
+ export function createEmailPreviewScreen(config: EmailPreviewScreenConfig): {
280
+ page: () => JSX.Element;
281
+ } {
282
+ const { apiBase, copy } = config;
283
+
284
+ function EmailPreviewsPage(): JSX.Element {
285
+ // The VALUE is deliberately discarded: nothing reads the counter, and the
286
+ // only thing it has to do is change, so React re-renders and the URL is
287
+ // re-read below. It used to be a dependency of the catalogue fetch, which
288
+ // is what made every row click refetch the list.
289
+ const [, setUrlNonce] = useState(0);
290
+ const locale = searchParam('locale') ?? '';
291
+ const selectedId = searchParam('id');
292
+ // The catalogue depends on the LANGUAGE and on nothing else. `urlNonce` is
293
+ // deliberately absent: it counts every URL patch, selection included, and
294
+ // including it here refetched the whole catalogue on each row click — for a
295
+ // list whose contents cannot have changed. What the click actually needs is
296
+ // a re-RENDER, so `selectedId` is re-read, and `setUrlNonce` already does
297
+ // that on its own.
298
+ const load = useCallback(
299
+ () => fetchEmailPreviewIndex(apiBase, locale),
300
+ [apiBase, locale],
301
+ );
302
+ const index = useLoadable(load, { keepPrevious: true });
303
+
304
+ const patch = (next: Record<string, string>): void => {
305
+ patchSearch(next);
306
+ // `replaceState` does not notify React, so the screen re-reads the URL
307
+ // through this counter rather than through a router's own subscription.
308
+ setUrlNonce((n) => n + 1);
309
+ };
310
+
311
+ return (
312
+ <Box data-testid="page-email-previews" sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
313
+ <Box>
314
+ <Heading level="h2">{copy.title}</Heading>
315
+ <Text as="p" size="sm" color="secondary">
316
+ {copy.description}
317
+ </Text>
318
+ </Box>
319
+ {index.error !== null ? (
320
+ <Failure message={index.error} copy={copy} onRetry={index.reload} />
321
+ ) : null}
322
+ {index.error === null && index.data === null ? (
323
+ <Text as="p" size="sm" color="secondary" data-testid="email-preview-index-loading">
324
+ {copy.loading}
325
+ </Text>
326
+ ) : null}
327
+ {index.data !== null ? (
328
+ <Browser
329
+ apiBase={apiBase}
330
+ index={index.data}
331
+ copy={copy}
332
+ locale={index.data.locale}
333
+ selectedId={selectedId}
334
+ onPatch={patch}
335
+ />
336
+ ) : null}
337
+ </Box>
338
+ );
339
+ }
340
+
341
+ return { page: EmailPreviewsPage };
342
+ }
@@ -0,0 +1,60 @@
1
+ import type { EmailPreviewDetail, EmailPreviewIndex } from '../catalog';
2
+
3
+ /**
4
+ * How the screen reaches its own endpoints.
5
+ *
6
+ * Plain `fetch` rather than a data library: this package cannot know whether a
7
+ * host runs react-query, SWR or nothing at all, and a screen that dragged one
8
+ * in would put a second cache beside whichever the host already has. The two
9
+ * calls here are a list and a document — neither needs invalidation, retries or
10
+ * shared state, which is most of what a data library is for.
11
+ *
12
+ * Both unwrap the `{ data }` envelope the routes write, and both surface a
13
+ * non-2xx as a thrown `Error` carrying whatever the surface said, so the screen
14
+ * can show the operator the real refusal (an unknown locale, a 403 from the
15
+ * host's own gate) rather than a generic failure.
16
+ */
17
+
18
+ /** The envelope every route in this package answers with. */
19
+ interface Envelope<T> {
20
+ data?: T;
21
+ error?: string;
22
+ }
23
+
24
+ async function get<T>(url: string): Promise<T> {
25
+ const response = await fetch(url, { headers: { Accept: 'application/json' } });
26
+ let body: Envelope<T> = {};
27
+ try {
28
+ body = (await response.json()) as Envelope<T>;
29
+ } catch {
30
+ // A gate that refuses before the router runs may answer HTML, not JSON.
31
+ // Falling through to the status line below is more useful than a parse
32
+ // error naming a character offset.
33
+ }
34
+ if (!response.ok || body.data === undefined) {
35
+ throw new Error(body.error ?? `The request failed (${response.status}).`);
36
+ }
37
+ return body.data;
38
+ }
39
+
40
+ const withLocale = (base: string, locale: string): string =>
41
+ `${base}?locale=${encodeURIComponent(locale)}`;
42
+
43
+ /** The catalogue, with every subject rendered in `locale`. */
44
+ export function fetchEmailPreviewIndex(
45
+ apiBase: string,
46
+ locale: string,
47
+ ): Promise<EmailPreviewIndex> {
48
+ return get<EmailPreviewIndex>(withLocale(apiBase, locale));
49
+ }
50
+
51
+ /** One rendered message. */
52
+ export function fetchEmailPreview(
53
+ apiBase: string,
54
+ id: string,
55
+ locale: string,
56
+ ): Promise<EmailPreviewDetail> {
57
+ return get<EmailPreviewDetail>(
58
+ withLocale(`${apiBase}/${encodeURIComponent(id)}`, locale),
59
+ );
60
+ }
@@ -0,0 +1,111 @@
1
+ import {
2
+ createEmailPreviews,
3
+ type ApiEmailPreviews,
4
+ type EmailPreviewsConfig,
5
+ } from './catalog';
6
+
7
+ /**
8
+ * The two endpoints over the catalogue, as descriptors.
9
+ *
10
+ * `GET /` answers the list — one row per message, a subject each, no rendered
11
+ * bytes — and `GET /:id` answers one whole document. Two calls rather than one
12
+ * because a rendered mail is 6-10 KB and a catalogue of twenty would be twenty
13
+ * times that on a screen where the operator reads one.
14
+ *
15
+ * ## Nothing here can send anything
16
+ *
17
+ * The surface holds no driver, no transport and no address. `render` is pure by
18
+ * the contract `EmailPreviewMessage` states, so the one mistake a preview
19
+ * surface must be incapable of — putting a sample in somebody's inbox — is not
20
+ * reachable from this code at all rather than merely not done.
21
+ *
22
+ * ## The gate is the HOST's — but the POSTURE is declared here
23
+ *
24
+ * Every route carries `kind: 'authenticated'`, the wiring contract's own word
25
+ * for "behind the host's session resolution and its RBAC". A package cannot
26
+ * know WHICH gate a host uses, and this one does not try: it names no
27
+ * permission id, because the ids belong to whichever host mounts it.
28
+ *
29
+ * What it must not do is stay silent. `public` is the contract's word for
30
+ * anonymous-by-design, and the contract's DEFAULT is `authenticated` — so a
31
+ * descriptor that declared nothing would still read as authenticated to a
32
+ * host's gates, while saying nothing to the person adopting it. This surface
33
+ * publishes a host's whole transactional-mail inventory and the exact wording
34
+ * and link shape of its verification and password-reset mails, which is the
35
+ * reference somebody writing a convincing phishing mail would want. That is
36
+ * worth one word in the descriptor rather than a sentence in a document.
37
+ *
38
+ * `ADOPTING.md` states the obligation in prose as well, because a host still
39
+ * has to supply the gate this word only asks for.
40
+ *
41
+ * An unknown LOCALE is a 400 rather than a silent fall back to the default.
42
+ * This is a diagnostic surface: quietly answering the default language to
43
+ * `?locale=es-AR` looks exactly like a product with no Spanish, which is the
44
+ * question the operator was asking.
45
+ */
46
+
47
+ /** Twin of the wiring contract's request; no import, so this package stays liftable. */
48
+ export interface EmailPreviewRequest {
49
+ /** The path segment after the mount, when the caller asked for one message. */
50
+ params?: Readonly<Record<string, string | undefined>>;
51
+ query?: Readonly<Record<string, string | undefined>>;
52
+ }
53
+
54
+ export interface EmailPreviewResponse {
55
+ status: number;
56
+ body: unknown;
57
+ }
58
+
59
+ export interface EmailPreviewRoute {
60
+ method: 'GET';
61
+ path: string;
62
+ /**
63
+ * The wiring contract's `WireRouteKind`, restated. Always `authenticated`
64
+ * here: the host supplies the gate, and this is the descriptor asking for
65
+ * one — see the docblock above for why silence was not an option.
66
+ */
67
+ kind: 'authenticated';
68
+ handle(request: EmailPreviewRequest): Promise<EmailPreviewResponse>;
69
+ }
70
+
71
+ const ok = (body: unknown): EmailPreviewResponse => ({ status: 200, body: { data: body } });
72
+ const fail = (status: number, error: string): EmailPreviewResponse => ({ status, body: { error } });
73
+
74
+ /** `undefined` passes through as "not asked"; anything unknown is refused. */
75
+ function readLocale(
76
+ previews: ApiEmailPreviews,
77
+ request: EmailPreviewRequest,
78
+ ): { ok: true; locale?: string } | { ok: false; tag: string } {
79
+ const tag = request.query?.locale;
80
+ if (tag === undefined || tag === '') return { ok: true };
81
+ return previews.supportsLocale(tag) ? { ok: true, locale: tag } : { ok: false, tag };
82
+ }
83
+
84
+ export function emailPreviewRoutes(config: EmailPreviewsConfig): EmailPreviewRoute[] {
85
+ const previews = createEmailPreviews(config);
86
+
87
+ return [
88
+ {
89
+ method: 'GET',
90
+ path: '/',
91
+ kind: 'authenticated',
92
+ handle: (request) => {
93
+ const locale = readLocale(previews, request);
94
+ if (!locale.ok) return Promise.resolve(fail(400, `Unknown locale "${locale.tag}".`));
95
+ return Promise.resolve(ok(previews.index(locale.locale)));
96
+ },
97
+ },
98
+ {
99
+ method: 'GET',
100
+ path: '/:id',
101
+ kind: 'authenticated',
102
+ handle: (request) => {
103
+ const locale = readLocale(previews, request);
104
+ if (!locale.ok) return Promise.resolve(fail(400, `Unknown locale "${locale.tag}".`));
105
+ const id = request.params?.id ?? '';
106
+ const detail = previews.render(id, locale.locale);
107
+ return Promise.resolve(detail ? ok(detail) : fail(404, `No e-mail preview named "${id}".`));
108
+ },
109
+ },
110
+ ];
111
+ }