@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
@@ -84,12 +84,44 @@ interface NotificationEvent<TPayload = unknown> {
84
84
  * generator never touches existing generators, the router, or any transport
85
85
  * (open/closed).
86
86
  */
87
+ /**
88
+ * Who the content is being rendered FOR — the reader, at the moment the
89
+ * generator is asked.
90
+ *
91
+ * A notification is stored as rendered TEXT: title and body are columns, so
92
+ * the language is chosen once, when the row is written, and never again. That
93
+ * makes this the only honest place to ask. A generator is registered at BOOT —
94
+ * a host that resolved its words there would pin every future reader to
95
+ * whichever language the process happened to start in, invisibly, because a
96
+ * single-locale host cannot tell the difference.
97
+ *
98
+ * The tag is the RECIPIENT's, never the request's. The person who triggers a
99
+ * notification is routinely not the person who reads it: an invite is sent
100
+ * because an administrator acted and is read by the invitee. Reading
101
+ * `Accept-Language` here would be a bug that only ever surfaces as somebody
102
+ * being told things in a language they do not speak.
103
+ *
104
+ * Absent means "nobody said" — a host with one audience, or one that stores no
105
+ * per-user language, populates nothing and every generator answers with its
106
+ * own default exactly as it did before this existed.
107
+ */
108
+ interface NotificationGenerateContext {
109
+ readonly locale?: string | null;
110
+ }
87
111
  interface NotificationGenerator<TPayload = unknown> {
88
112
  /** The event key, dot-namespaced ("order.paid"). One generator per type. */
89
113
  type: string;
90
114
  /** The preference category the router gates this type's fan-out on. */
91
115
  category: NotificationCategory;
92
- generate: (payload: TPayload) => NotificationContent;
116
+ /**
117
+ * Render this event's content for ONE recipient.
118
+ *
119
+ * `context` is OPTIONAL, and that is what keeps every generator written
120
+ * before it working: a one-parameter `generate` is assignable to this
121
+ * signature unchanged. A host that passes nothing is stating a fact — it has
122
+ * no language for this reader — rather than asserting a default.
123
+ */
124
+ generate: (payload: TPayload, context?: NotificationGenerateContext) => NotificationContent;
93
125
  }
94
126
  /**
95
127
  * The recipient as a transport sees them: resolved destinations only. Built by
@@ -100,6 +132,11 @@ interface NotificationGenerator<TPayload = unknown> {
100
132
  interface TransportRecipient {
101
133
  userId: string;
102
134
  email: string | null;
135
+ /**
136
+ * The recipient's own language, when the host's contact directory states
137
+ * one. Absent means "nobody said" — see {@link NotificationGenerateContext}.
138
+ */
139
+ locale?: string | null;
103
140
  /** Phone as the host stores it (transports normalize per provider rules). */
104
141
  phone: string | null;
105
142
  /** How many active browser push subscriptions the user holds. */
@@ -152,4 +189,4 @@ interface NotificationLogger {
152
189
  error(message: string, ...meta: unknown[]): void;
153
190
  }
154
191
 
155
- export { type DeliveryStatus as D, NOTIFICATION_CHANNELS as N, type TransportRecipient as T, type NotificationCategory as a, type NotificationChannel as b, type NotificationContent as c, type NotificationEvent as d, type NotificationGenerator as e, type NotificationLogger as f, type NotificationRecipient as g, type NotificationTaxonomy as h, type NotificationTransport as i, taxonomyOf as t };
192
+ export { type DeliveryStatus as D, type NotificationGenerator as N, type TransportRecipient as T, NOTIFICATION_CHANNELS as a, type NotificationCategory as b, type NotificationChannel as c, type NotificationContent as d, type NotificationEvent as e, type NotificationGenerateContext as f, type NotificationLogger as g, type NotificationRecipient as h, type NotificationTaxonomy as i, type NotificationTransport as j, taxonomyOf as t };
@@ -1,5 +1,5 @@
1
- import { c as WebPushSender } from '../web-push-Cs14Wp9u.js';
2
- import '../types-CXLAG3UU.js';
1
+ import { c as WebPushSender } from '../web-push-D-ysQd5h.js';
2
+ import '../types-BFmAY0P5.js';
3
3
 
4
4
  /**
5
5
  * `@12-apps/notifications/web-push` — the VAPID sender, behind its own subpath.
@@ -1,4 +1,4 @@
1
- import { f as NotificationLogger, c as NotificationContent, i as NotificationTransport } from './types-CXLAG3UU.js';
1
+ import { g as NotificationLogger, d as NotificationContent, j as NotificationTransport } from './types-BFmAY0P5.js';
2
2
 
3
3
  /**
4
4
  * The DRIVER port — the reason a host adds a vendor with a config entry and no
@@ -1,4 +1,4 @@
1
- import { a as NotificationCategory, b as NotificationChannel } from './types-CXLAG3UU.js';
1
+ import { b as NotificationCategory, c as NotificationChannel } from './types-BFmAY0P5.js';
2
2
 
3
3
  /**
4
4
  * Every sentence this package can say to a USER, stated by the HOST.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/notifications",
3
- "version": "4.7.1",
3
+ "version": "4.9.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Plug-and-play notification system (12-15): an always-on in-app inbox, per-user × per-category channel preferences, and email / SMS / WhatsApp / web-push transports behind vendor DRIVERS so a second provider is a config entry. Framework-free core (.), host-mounted backend surface (./server: inbox / preferences / push-subscription endpoints, the channel router with delivery records + retry sweep, the permission fan-out, duck-typed Prisma seam), Hono adapter (./hono), React surface (./react: bell + badge, inbox drawer, preferences screen), VAPID sender (./web-push) and the package-owned Prisma partial + migrations. Standardized adoption contract in ADOPTING.md.",
@@ -9,6 +9,26 @@
9
9
  "types": "./dist/index.d.ts",
10
10
  "default": "./dist/index.js"
11
11
  },
12
+ "./email": {
13
+ "types": "./dist/email/index.d.ts",
14
+ "default": "./dist/email/index.js"
15
+ },
16
+ "./email/locales": {
17
+ "types": "./dist/email/locales.d.ts",
18
+ "default": "./dist/email/locales.js"
19
+ },
20
+ "./email/previews": {
21
+ "types": "./dist/email/previews/index.d.ts",
22
+ "default": "./dist/email/previews/index.js"
23
+ },
24
+ "./email/previews/hono": {
25
+ "types": "./dist/email/previews/hono.d.ts",
26
+ "default": "./dist/email/previews/hono.js"
27
+ },
28
+ "./email/previews/react": {
29
+ "types": "./dist/email/previews/react/index.d.ts",
30
+ "default": "./dist/email/previews/react/index.js"
31
+ },
12
32
  "./server": {
13
33
  "types": "./dist/server/index.d.ts",
14
34
  "default": "./dist/server/index.js"
@@ -51,7 +71,7 @@
51
71
  "prisma:sync:check": "node scripts/sync-notifications-schema.mjs --check"
52
72
  },
53
73
  "dependencies": {
54
- "@12-apps/ui": "^6.11.1"
74
+ "@12-apps/ui": "^6.14.0"
55
75
  },
56
76
  "peerDependencies": {
57
77
  "@12-apps/wiring": ">=1.3.0",
@@ -80,7 +100,7 @@
80
100
  "devDependencies": {
81
101
  "@12-apps/eslint-config": "^1.22.0",
82
102
  "@12-apps/typescript-config": "^1.21.0",
83
- "@12-apps/wiring": "^1.14.0",
103
+ "@12-apps/wiring": "^1.16.0",
84
104
  "@testing-library/react": "^16.1.0",
85
105
  "@types/react": "19.2.2",
86
106
  "@types/react-dom": "19.2.2",
@@ -0,0 +1,8 @@
1
+ import type { EmailChromeCopy } from './template';
2
+
3
+ /** US English — the twin that makes {@link PT_BR_EMAIL_CHROME} a choice. */
4
+ export const EN_US_EMAIL_CHROME: EmailChromeCopy = {
5
+ fallbackHint: 'If the button above does not work, copy and paste this address into your browser:',
6
+ automated: 'This is an automated message. Please do not reply to this email.',
7
+ tagline: (brand) => `Sent by ${brand}.`,
8
+ };
@@ -0,0 +1,21 @@
1
+ import type { EmailChromeCopy } from './template';
2
+
3
+ /**
4
+ * Brazilian Portuguese, as a NAMED pack.
5
+ *
6
+ * A host passes this by name — `chrome: PT_BR_EMAIL_CHROME`, or
7
+ * `localeCopy(EMAIL_CHROME)` for a host whose readers do not share one
8
+ * language. It is never a default: a pack a host CHOOSES is a decision in a
9
+ * diff, and a pack a package applies silently is another product's voice in
10
+ * somebody's inbox.
11
+ *
12
+ * The tagline names the brand and stops. It is deliberately not a description
13
+ * of what the product does — that sentence is the host's, it belongs to a
14
+ * vocabulary this package cannot know, and a package that guessed one would be
15
+ * putting words in every adopter's footer.
16
+ */
17
+ export const PT_BR_EMAIL_CHROME: EmailChromeCopy = {
18
+ fallbackHint: 'Se o botão acima não funcionar, copie e cole este endereço no seu navegador:',
19
+ automated: 'Esta é uma mensagem automática. Não responda a este e-mail.',
20
+ tagline: (brand) => `Enviado por ${brand}.`,
21
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `@12-apps/notifications/email` — ONE transactional-mail layout.
3
+ *
4
+ * ## Why it lives in this package
5
+ *
6
+ * This package already owned the delivery half: the `EmailDriver` port, the
7
+ * vendor table (`EMAIL_DRIVERS`) and, in `server/transports/email.ts`, a
8
+ * `formatEmail` that built a mail out of three `<p>` tags. That formatter is
9
+ * one of the three ad-hoc renderers this layout replaces — so shipping the
10
+ * layout anywhere else would have left the replacement in a package this one
11
+ * does not depend on, and the estate's own lesson is that adoption without
12
+ * deletion is the worst of both: two ways to render a mail, both maintained.
13
+ *
14
+ * Here, `formatEmail` renders THROUGH the layout the moment a host supplies a
15
+ * brand and a copy pack, and the ad-hoc path is what remains when it does not.
16
+ *
17
+ * Framework-free and dependency-free: safe in a browser, in a job, in a
18
+ * webhook — which is why it is its own subpath rather than part of `.`, whose
19
+ * consumers are the inbox and the preference matrix.
20
+ *
21
+ * The preview catalogue and its routes are `./email/previews`; the operator
22
+ * screen is `./email/previews/react`; the layout's own copy packs are
23
+ * `./email/locales`.
24
+ */
25
+ export {
26
+ renderEmail,
27
+ renderEmailHtml,
28
+ safeHref,
29
+ type EmailAction,
30
+ type EmailChromeCopy,
31
+ type EmailDocument,
32
+ type EmailFact,
33
+ type RenderedEmail,
34
+ } from './template';
35
+
36
+ export { renderEmailText } from './text';
37
+
38
+ export {
39
+ EMAIL_CONTENT_WIDTH,
40
+ EMAIL_FONT_STACK,
41
+ NEUTRAL_EMAIL_THEME,
42
+ type EmailTheme,
43
+ } from './theme';
@@ -0,0 +1,29 @@
1
+ import { EN_US_EMAIL_CHROME } from './chrome.en-US';
2
+ import { PT_BR_EMAIL_CHROME } from './chrome.pt-BR';
3
+ import type { EmailChromeCopy } from './template';
4
+
5
+ /**
6
+ * The layout's own words, in every language this package ships.
7
+ *
8
+ * Its own subpath (`@12-apps/notifications/email/locales`) for the reason every
9
+ * copy-shipping package here uses one: keeping the packs off the layout entry
10
+ * is what lets that entry stay the framework-free mechanism a browser can
11
+ * import, and it is what makes "which language" a decision a host takes by
12
+ * name rather than one this package takes by default.
13
+ *
14
+ * Shaped as `Record<tag, pack>` rather than importing `@12-apps/i18n`'s
15
+ * `LocalePack`: a package must stay liftable into a repo that has never heard
16
+ * of that one, so the type is a local structural mirror. The two agree by tag,
17
+ * and `scripts/locale-coverage-gate.mjs` is what checks that they do.
18
+ *
19
+ * A host with ONE audience passes a pack by name. A host whose readers do not
20
+ * share a language passes `localeCopy(EMAIL_CHROME)` and each message is
21
+ * written in its own recipient's language — which is the whole reason
22
+ * `EmailDocument.chrome` is resolved per message rather than at a mount.
23
+ */
24
+ export const EMAIL_CHROME: Readonly<Record<string, EmailChromeCopy>> = {
25
+ 'pt-BR': PT_BR_EMAIL_CHROME,
26
+ 'en-US': EN_US_EMAIL_CHROME,
27
+ };
28
+
29
+ export { EN_US_EMAIL_CHROME, PT_BR_EMAIL_CHROME };
@@ -0,0 +1,208 @@
1
+ import type { RenderedEmail } from '../template';
2
+
3
+ /**
4
+ * The preview catalogue: every message a host can send, as a navigable set.
5
+ *
6
+ * ## The problem it exists for
7
+ *
8
+ * There is normally no way to SEE a transactional mail without triggering the
9
+ * event that sends it — signing up with a throwaway address to look at the
10
+ * verification mail, settling an order to look at the receipt, exhausting a
11
+ * quota to look at that alert. So nobody looks, and a host that renders mail in
12
+ * three different ways does not find out.
13
+ *
14
+ * A layout nobody can see is one release from being three layouts again, which
15
+ * is why this ships in the same package as the layout rather than beside it.
16
+ *
17
+ * ## What is the package's and what is the host's
18
+ *
19
+ * The MECHANISM is here: group by owner, render one message for one reader,
20
+ * report what cannot be shown. WHICH messages exist is the host's, and it
21
+ * cannot be otherwise — a package cannot know that a host sends a
22
+ * "your quota is exhausted" notice, let alone what data that notice is
23
+ * rendered from.
24
+ *
25
+ * So a host declares SOURCES. Each names an owner and answers with its
26
+ * messages, and the catalogue is the union. That inverts the obvious design,
27
+ * where the package would hold a registry a host writes into: a source is
28
+ * asked, per request, so a source backed by a registry that fills at import
29
+ * time answers with whatever is registered NOW rather than with whatever had
30
+ * been registered when the mount was built.
31
+ *
32
+ * ## Why coverage is a first-class output
33
+ *
34
+ * A catalogue that quietly omits a message looks exactly like a product that
35
+ * does not send it. So a source may report what it knows it cannot show —
36
+ * a message with no sample data, or sample data for a message that no longer
37
+ * exists — and the surface returns that beside the list rather than swallowing
38
+ * it. A gap that is visible is a gap somebody closes.
39
+ */
40
+
41
+ /** One previewable message. */
42
+ export interface EmailPreviewMessage {
43
+ /**
44
+ * Stable id, and the path segment the console navigates by.
45
+ *
46
+ * The host chooses the spelling; the surface only requires it to be unique
47
+ * across every source. Ids that survive a rename are what make a link to one
48
+ * mail worth sending to a colleague.
49
+ */
50
+ readonly id: string;
51
+ /** The wire identity — an event type, a template key. Shown beside the subject. */
52
+ readonly key: string;
53
+ /** Which mail path this belongs to, for grouping inside one owner. */
54
+ readonly family: string;
55
+ /** Render it for one reader. MUST be pure — nothing may be sent. */
56
+ render(locale: string): RenderedEmail;
57
+ }
58
+
59
+ /** What a source knows it cannot show. Both directions are worth reporting. */
60
+ export interface EmailPreviewCoverage {
61
+ /** Messages that exist but have no sample data, so no preview. */
62
+ readonly missing: readonly string[];
63
+ /** Sample data for a message nothing produces any more. */
64
+ readonly orphan: readonly string[];
65
+ }
66
+
67
+ /** One group of messages, owned by whichever code words them. */
68
+ export interface EmailPreviewSource {
69
+ /**
70
+ * Who owns these messages — a package name, or the host's own module.
71
+ *
72
+ * This is the console's grouping, and it is the answer to "which parts of
73
+ * this system send mail", which is a question most hosts cannot otherwise
74
+ * answer at all.
75
+ */
76
+ readonly owner: string;
77
+ /** Called PER REQUEST — see the docblock above for why that matters. */
78
+ list(): readonly EmailPreviewMessage[];
79
+ /** Optional: what this source cannot show. Absent means "nothing missing". */
80
+ coverage?(): EmailPreviewCoverage;
81
+ }
82
+
83
+ /** One catalogue row: everything the sidebar needs, and no rendered bytes. */
84
+ export interface EmailPreviewRow {
85
+ readonly id: string;
86
+ readonly key: string;
87
+ readonly family: string;
88
+ readonly owner: string;
89
+ /** The subject in the requested language, so the list reads as an inbox. */
90
+ readonly subject: string;
91
+ }
92
+
93
+ /** One rendered message: what the vendor would be handed, verbatim. */
94
+ export interface EmailPreviewDetail extends EmailPreviewRow {
95
+ readonly locale: string;
96
+ readonly html: string;
97
+ readonly text: string;
98
+ }
99
+
100
+ export interface EmailPreviewIndex {
101
+ readonly locale: string;
102
+ readonly locales: readonly string[];
103
+ readonly items: readonly EmailPreviewRow[];
104
+ readonly coverage: EmailPreviewCoverage;
105
+ }
106
+
107
+ export interface EmailPreviewsConfig {
108
+ /** The host's sources, in the order the console should list their owners. */
109
+ readonly sources: readonly EmailPreviewSource[];
110
+ /**
111
+ * The languages a preview may be asked for.
112
+ *
113
+ * REQUIRED, and not derived from the shipped packs: a host's own messages may
114
+ * be written in languages this package has never heard of, and a preview that
115
+ * silently refused one of them would look exactly like a product with no
116
+ * translation.
117
+ */
118
+ readonly locales: readonly string[];
119
+ /** The language a preview opens in when the caller names none. */
120
+ readonly defaultLocale: string;
121
+ }
122
+
123
+ /** An id that appears in more than one source — the one thing that must not happen. */
124
+ export class DuplicateEmailPreviewIdError extends Error {
125
+ constructor(id: string) {
126
+ super(`@12-apps/notifications: two e-mail preview messages share the id "${id}".`);
127
+ this.name = 'DuplicateEmailPreviewIdError';
128
+ Object.setPrototypeOf(this, DuplicateEmailPreviewIdError.prototype);
129
+ }
130
+ }
131
+
132
+ /** The catalogue, assembled from every source. */
133
+ export interface ApiEmailPreviews {
134
+ /** Every message, with each subject rendered in `locale`. */
135
+ index(locale?: string): EmailPreviewIndex;
136
+ /** One rendered message, or `null` when no source owns that id. */
137
+ render(id: string, locale?: string): EmailPreviewDetail | null;
138
+ /** The union of every source's gaps. */
139
+ coverage(): EmailPreviewCoverage;
140
+ /** Is this a language the host said it writes in? */
141
+ supportsLocale(locale: string): boolean;
142
+ }
143
+
144
+ /** One source's messages, tagged with their owner. */
145
+ interface OwnedMessage {
146
+ readonly owner: string;
147
+ readonly message: EmailPreviewMessage;
148
+ }
149
+
150
+ function collect(sources: readonly EmailPreviewSource[]): OwnedMessage[] {
151
+ const owned = sources.flatMap((source) =>
152
+ source.list().map((message) => ({ owner: source.owner, message })),
153
+ );
154
+ const seen = new Set<string>();
155
+ for (const { message } of owned) {
156
+ // A duplicate id makes one message unreachable and the other ambiguous —
157
+ // and silently, since the console would simply show whichever came first.
158
+ // Throwing here surfaces it on the request that would have hidden it.
159
+ if (seen.has(message.id)) throw new DuplicateEmailPreviewIdError(message.id);
160
+ seen.add(message.id);
161
+ }
162
+ return owned;
163
+ }
164
+
165
+ export function createEmailPreviews(config: EmailPreviewsConfig): ApiEmailPreviews {
166
+ const { sources, locales, defaultLocale } = config;
167
+ const localeOf = (locale?: string): string =>
168
+ locale && locales.includes(locale) ? locale : defaultLocale;
169
+
170
+ const rowOf = ({ owner, message }: OwnedMessage, locale: string): EmailPreviewRow => ({
171
+ id: message.id,
172
+ key: message.key,
173
+ family: message.family,
174
+ owner,
175
+ subject: message.render(locale).subject,
176
+ });
177
+
178
+ return {
179
+ index(locale) {
180
+ const tag = localeOf(locale);
181
+ return {
182
+ locale: tag,
183
+ locales,
184
+ items: collect(sources).map((owned) => rowOf(owned, tag)),
185
+ coverage: this.coverage(),
186
+ };
187
+ },
188
+ render(id, locale) {
189
+ const owned = collect(sources).find((entry) => entry.message.id === id);
190
+ if (!owned) return null;
191
+ const tag = localeOf(locale);
192
+ const message = owned.message.render(tag);
193
+ return { ...rowOf(owned, tag), locale: tag, html: message.html, text: message.text };
194
+ },
195
+ coverage() {
196
+ const missing: string[] = [];
197
+ const orphan: string[] = [];
198
+ for (const source of sources) {
199
+ const gap = source.coverage?.();
200
+ if (!gap) continue;
201
+ missing.push(...gap.missing);
202
+ orphan.push(...gap.orphan);
203
+ }
204
+ return { missing: missing.sort(), orphan: orphan.sort() };
205
+ },
206
+ supportsLocale: (locale) => locales.includes(locale),
207
+ };
208
+ }
@@ -0,0 +1,69 @@
1
+ import { Hono } from 'hono';
2
+ import type { Context } from 'hono';
3
+
4
+ import { emailPreviewRoutes } from './routes';
5
+ import type { EmailPreviewsConfig } from './catalog';
6
+
7
+ /**
8
+ * `@12-apps/notifications/email/previews/hono` — the catalogue as a router.
9
+ *
10
+ * The framework-neutral descriptors in `./server` are the contract; this is the
11
+ * adapter for the framework we happen to use, behind its own subpath with
12
+ * `hono` as an OPTIONAL peer — a host on Express, or one that only wants the
13
+ * layout, never resolves it.
14
+ *
15
+ * A host writes:
16
+ *
17
+ * const previews = emailPreviewsRouter({ sources, locales, defaultLocale });
18
+ * app.use('/api/platform/email-previews/*', requirePlatformOperator);
19
+ * app.route('/api/platform/email-previews', previews.router);
20
+ *
21
+ * ## The `use` line above is not decoration
22
+ *
23
+ * This surface publishes a host's whole transactional-mail inventory and the
24
+ * exact wording of its verification and reset mails. The routes declare no
25
+ * session of their own because a package cannot know who a host lets look — so
26
+ * the gate is the host's, it is REQUIRED, and it is written here rather than
27
+ * left to be inferred, because the failure is silent: an ungated mount answers
28
+ * every stranger and nothing about it looks wrong.
29
+ */
30
+
31
+ export interface EmailPreviewsHonoConfig extends EmailPreviewsConfig {
32
+ /**
33
+ * Optional last-resort refusal, for a host that would rather state the gate
34
+ * once here than mount middleware around the router.
35
+ *
36
+ * Returning `false` answers 403 before any handler runs. Absent means the
37
+ * host has gated the mount itself — which is the ordinary case, and why this
38
+ * is not required: a package that demanded its own guard would be a second
39
+ * authorization system beside the one the host already runs.
40
+ */
41
+ readonly allow?: (c: Context) => Promise<boolean> | boolean;
42
+ }
43
+
44
+ /** The mounted router, plus the descriptors it was built from. */
45
+ export interface EmailPreviewsRouter {
46
+ router: Hono;
47
+ }
48
+
49
+ export function emailPreviewsRouter(config: EmailPreviewsHonoConfig): EmailPreviewsRouter {
50
+ const router = new Hono();
51
+
52
+ for (const route of emailPreviewRoutes(config)) {
53
+ router.get(route.path, async (c) => {
54
+ if (config.allow && !(await config.allow(c))) {
55
+ return c.json({ error: 'Forbidden.' }, 403);
56
+ }
57
+ const response = await route.handle({
58
+ params: c.req.param() as Record<string, string | undefined>,
59
+ // `Object.fromEntries` over the parsed query rather than the raw string:
60
+ // a repeated `?locale=` is a caller error, and taking the last value is
61
+ // the same answer every framework here gives.
62
+ query: c.req.query() as Record<string, string | undefined>,
63
+ });
64
+ return c.json(response.body as Record<string, unknown>, response.status as 200 | 400 | 404);
65
+ });
66
+ }
67
+
68
+ return { router };
69
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `@12-apps/notifications/email/previews` — the preview catalogue and the
3
+ * routes over it.
4
+ *
5
+ * Behind its own subpath so a web bundle importing `./email` (the layout) or
6
+ * `./react` (the inbox) never resolves the server half.
7
+ */
8
+ export {
9
+ createEmailPreviews,
10
+ DuplicateEmailPreviewIdError,
11
+ type ApiEmailPreviews,
12
+ type EmailPreviewCoverage,
13
+ type EmailPreviewDetail,
14
+ type EmailPreviewIndex,
15
+ type EmailPreviewMessage,
16
+ type EmailPreviewRow,
17
+ type EmailPreviewSource,
18
+ type EmailPreviewsConfig,
19
+ } from './catalog';
20
+
21
+ export {
22
+ emailPreviewRoutes,
23
+ type EmailPreviewRequest,
24
+ type EmailPreviewResponse,
25
+ type EmailPreviewRoute,
26
+ } from './routes';
@@ -0,0 +1,24 @@
1
+ import type { EmailPreviewScreenCopy } from './copy';
2
+
3
+ /** US English — the twin that makes {@link PT_BR_EMAIL_PREVIEW_COPY} a choice. */
4
+ export const EN_US_EMAIL_PREVIEW_COPY: EmailPreviewScreenCopy = {
5
+ title: 'Email previews',
6
+ description: 'Every email this system sends, grouped by the package that owns it. Nothing is sent from here.',
7
+ searchLabel: 'Filter',
8
+ searchPlaceholder: 'subject, event or package',
9
+ noMatches: 'No message matches the filter.',
10
+ pickOne: 'Pick a message from the list to see its preview.',
11
+ tabHtml: 'HTML',
12
+ tabText: 'Text',
13
+ tabSource: 'Source',
14
+ widthDesktop: 'Desktop',
15
+ widthMobile: 'Mobile',
16
+ subjectLabel: 'Subject',
17
+ frameTitle: 'Email preview',
18
+ coverageTitle: 'Incomplete coverage',
19
+ missingSamples: (keys) => `No sample data, so no preview: ${keys}.`,
20
+ orphanSamples: (keys) => `A sample exists but nothing produces this message any more: ${keys}.`,
21
+ loading: 'Loading...',
22
+ loadError: 'The previews could not be loaded.',
23
+ retry: 'Try again',
24
+ };
@@ -0,0 +1,24 @@
1
+ import type { EmailPreviewScreenCopy } from './copy';
2
+
3
+ /** Brazilian Portuguese, as a NAMED pack — never a default. */
4
+ export const PT_BR_EMAIL_PREVIEW_COPY: EmailPreviewScreenCopy = {
5
+ title: 'Prévia de e-mails',
6
+ description: 'Todos os e-mails que este sistema envia, agrupados por pacote. Nada é enviado a partir daqui.',
7
+ searchLabel: 'Filtrar',
8
+ searchPlaceholder: 'assunto, evento ou pacote',
9
+ noMatches: 'Nenhuma mensagem corresponde ao filtro.',
10
+ pickOne: 'Escolha uma mensagem na lista para ver a prévia.',
11
+ tabHtml: 'HTML',
12
+ tabText: 'Texto',
13
+ tabSource: 'Código',
14
+ widthDesktop: 'Computador',
15
+ widthMobile: 'Celular',
16
+ subjectLabel: 'Assunto',
17
+ frameTitle: 'Prévia do e-mail',
18
+ coverageTitle: 'Cobertura incompleta',
19
+ missingSamples: (keys) => `Sem dados de exemplo, então não há prévia: ${keys}.`,
20
+ orphanSamples: (keys) => `Há exemplo, mas nada mais produz esta mensagem: ${keys}.`,
21
+ loading: 'Carregando...',
22
+ loadError: 'Não foi possível carregar as prévias.',
23
+ retry: 'Tentar novamente',
24
+ };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The SHAPE of the operator screen's words — no words.
3
+ *
4
+ * REQUIRED config with no default in any language, the copy-portability
5
+ * doctrine. `./copy.pt-BR` and `./copy.en-US` ship packs a host passes BY NAME.
6
+ *
7
+ * What is NOT here is anything from the mails themselves: a subject, a body and
8
+ * a button label all arrive from the server already rendered in whichever
9
+ * language the preview was asked for. That separation is the point of the
10
+ * language switch — the CHROME follows the operator, the MESSAGE follows its
11
+ * own recipient.
12
+ */
13
+ export interface EmailPreviewScreenCopy {
14
+ readonly title: string;
15
+ readonly description: string;
16
+ /** The sidebar's filter field. */
17
+ readonly searchLabel: string;
18
+ readonly searchPlaceholder: string;
19
+ /** Nothing matched the filter. */
20
+ readonly noMatches: string;
21
+ /** No row is selected yet. */
22
+ readonly pickOne: string;
23
+ /** The three views of one message. */
24
+ readonly tabHtml: string;
25
+ readonly tabText: string;
26
+ readonly tabSource: string;
27
+ /** The two widths the HTML view renders at. */
28
+ readonly widthDesktop: string;
29
+ readonly widthMobile: string;
30
+ /** The subject line's label above the preview. */
31
+ readonly subjectLabel: string;
32
+ /** The sandboxed frame's title, for screen readers. */
33
+ readonly frameTitle: string;
34
+ /** The coverage strip, when the surface reports a gap. */
35
+ readonly coverageTitle: string;
36
+ readonly missingSamples: (keys: string) => string;
37
+ readonly orphanSamples: (keys: string) => string;
38
+ /** Loading and failure states. */
39
+ readonly loading: string;
40
+ readonly loadError: string;
41
+ readonly retry: string;
42
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `@12-apps/notifications/email/previews/react` — the operator screen over the
3
+ * preview catalogue.
4
+ *
5
+ * Its own subpath rather than a member of `./react`, which is the INBOX: the
6
+ * bell and the preference matrix ship to every signed-in user, and this screen
7
+ * is a platform-staff diagnostic. A host that mounts one has no reason to
8
+ * resolve the other.
9
+ */
10
+ export { createEmailPreviewScreen, type EmailPreviewScreenConfig } from './preview-screen';
11
+ export { type EmailPreviewScreenCopy } from './copy';
12
+ export { EN_US_EMAIL_PREVIEW_COPY } from './copy.en-US';
13
+ export { PT_BR_EMAIL_PREVIEW_COPY } from './copy.pt-BR';
14
+ export { type PreviewTab, type PreviewWidth } from './message-view';