@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.
- package/ADOPTING.md +145 -0
- package/README.md +65 -0
- package/dist/catalog-BOVZ2b-b.d.ts +132 -0
- package/dist/{chunk-XE7HZVMH.js → chunk-BGI4BQ66.js} +1 -1
- package/dist/chunk-BGI4BQ66.js.map +1 -0
- package/dist/chunk-EKUSNUBT.js +222 -0
- package/dist/chunk-EKUSNUBT.js.map +1 -0
- package/dist/chunk-FTFLU6KH.js +114 -0
- package/dist/chunk-FTFLU6KH.js.map +1 -0
- package/dist/{chunk-HHMRCMQU.js → chunk-GGAZOLNN.js} +2 -2
- package/dist/{chunk-CUZW62JS.js → chunk-KU4FVOFA.js} +2 -2
- package/dist/{chunk-WHBMPHQE.js → chunk-KZG7QGFA.js} +28 -6
- package/dist/chunk-KZG7QGFA.js.map +1 -0
- package/dist/{chunk-MMLV4EZT.js → chunk-M5XTROYM.js} +2 -2
- package/dist/chunk-ZFIYBNZ7.js +502 -0
- package/dist/chunk-ZFIYBNZ7.js.map +1 -0
- package/dist/{create-api-notifications-B3u6Kx3x.d.ts → create-api-notifications-B-VMezVB.d.ts} +43 -4
- package/dist/{create-web-notifications-BHCzaU2y.d.ts → create-web-notifications-B-xQ6R0B.d.ts} +2 -2
- package/dist/email/index.d.ts +26 -0
- package/dist/email/index.js +20 -0
- package/dist/email/index.js.map +1 -0
- package/dist/email/locales.d.ts +43 -0
- package/dist/email/locales.js +29 -0
- package/dist/email/locales.js.map +1 -0
- package/dist/email/previews/hono.d.ts +46 -0
- package/dist/email/previews/hono.js +33 -0
- package/dist/email/previews/hono.js.map +1 -0
- package/dist/email/previews/index.d.ts +67 -0
- package/dist/email/previews/index.js +12 -0
- package/dist/email/previews/index.js.map +1 -0
- package/dist/email/previews/react/index.d.ts +43 -0
- package/dist/email/previews/react/index.js +58 -0
- package/dist/email/previews/react/index.js.map +1 -0
- package/dist/{generators-B9xt3sRh.d.ts → generators-BsuXe8Mp.d.ts} +1 -1
- package/dist/hono/index.d.ts +6 -5
- package/dist/hono/index.js +4 -3
- package/dist/hono/index.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +5 -5
- package/dist/{jobs-BDZ7aGHV.d.ts → jobs-D64B_bg7.d.ts} +1 -1
- package/dist/manifest/index.d.ts +39 -1
- package/dist/manifest/index.js +9 -0
- package/dist/manifest/index.js.map +1 -1
- package/dist/manifest/server.d.ts +32 -7
- package/dist/manifest/server.js +15 -4
- package/dist/manifest/server.js.map +1 -1
- package/dist/manifest/web.d.ts +18 -4
- package/dist/manifest/web.js +9 -1
- package/dist/manifest/web.js.map +1 -1
- package/dist/{preferences-screen-IOW6Y2H2.js → preferences-screen-V3ZBWP7A.js} +2 -2
- package/dist/preview-screen-DYJRAnAY.d.ts +83 -0
- package/dist/react/index.d.ts +4 -4
- package/dist/react/index.js +10 -10
- package/dist/server/index.d.ts +8 -7
- package/dist/server/index.js +5 -4
- package/dist/template-Dlt5S54z.d.ts +196 -0
- package/dist/{types-CXLAG3UU.d.ts → types-BFmAY0P5.d.ts} +39 -2
- package/dist/web-push/index.d.ts +2 -2
- package/dist/{web-push-Cs14Wp9u.d.ts → web-push-D-ysQd5h.d.ts} +1 -1
- package/dist/{wire-6dzyfDE7.d.ts → wire-_coV3tJk.d.ts} +1 -1
- package/package.json +23 -3
- package/src/email/chrome.en-US.ts +8 -0
- package/src/email/chrome.pt-BR.ts +21 -0
- package/src/email/index.ts +43 -0
- package/src/email/locales.ts +29 -0
- package/src/email/previews/catalog.ts +208 -0
- package/src/email/previews/hono.ts +69 -0
- package/src/email/previews/index.ts +26 -0
- package/src/email/previews/react/copy.en-US.ts +24 -0
- package/src/email/previews/react/copy.pt-BR.ts +24 -0
- package/src/email/previews/react/copy.ts +42 -0
- package/src/email/previews/react/index.tsx +14 -0
- package/src/email/previews/react/loadable.tsx +83 -0
- package/src/email/previews/react/message-list.tsx +133 -0
- package/src/email/previews/react/message-view.tsx +167 -0
- package/src/email/previews/react/preview-screen.tsx +342 -0
- package/src/email/previews/react/transport.ts +60 -0
- package/src/email/previews/routes.ts +111 -0
- package/src/email/template.ts +365 -0
- package/src/email/text.ts +59 -0
- package/src/email/theme.ts +77 -0
- package/src/index.ts +1 -0
- package/src/manifest/index.ts +37 -0
- package/src/manifest/server.ts +23 -0
- package/src/manifest/web.ts +13 -0
- package/src/server/db.ts +12 -1
- package/src/server/dispatch.ts +4 -0
- package/src/server/router.ts +17 -1
- package/src/server/transports/email.ts +62 -0
- package/src/types.ts +39 -1
- package/dist/chunk-WHBMPHQE.js.map +0 -1
- package/dist/chunk-XE7HZVMH.js.map +0 -1
- /package/dist/{chunk-HHMRCMQU.js.map → chunk-GGAZOLNN.js.map} +0 -0
- /package/dist/{chunk-CUZW62JS.js.map → chunk-KU4FVOFA.js.map} +0 -0
- /package/dist/{chunk-MMLV4EZT.js.map → chunk-M5XTROYM.js.map} +0 -0
- /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
|
+
}
|