@12-apps/notifications 4.8.0 → 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 (80) hide show
  1. package/ADOPTING.md +113 -0
  2. package/README.md +65 -0
  3. package/dist/catalog-BOVZ2b-b.d.ts +132 -0
  4. package/dist/chunk-EKUSNUBT.js +222 -0
  5. package/dist/chunk-EKUSNUBT.js.map +1 -0
  6. package/dist/chunk-FTFLU6KH.js +114 -0
  7. package/dist/chunk-FTFLU6KH.js.map +1 -0
  8. package/dist/{chunk-7234ROOP.js → chunk-KZG7QGFA.js} +21 -3
  9. package/dist/chunk-KZG7QGFA.js.map +1 -0
  10. package/dist/chunk-ZFIYBNZ7.js +502 -0
  11. package/dist/chunk-ZFIYBNZ7.js.map +1 -0
  12. package/dist/{create-api-notifications-WaexrZnn.d.ts → create-api-notifications-B-VMezVB.d.ts} +35 -4
  13. package/dist/{create-web-notifications-DrgqtU4Q.d.ts → create-web-notifications-B-xQ6R0B.d.ts} +2 -2
  14. package/dist/email/index.d.ts +26 -0
  15. package/dist/email/index.js +20 -0
  16. package/dist/email/index.js.map +1 -0
  17. package/dist/email/locales.d.ts +43 -0
  18. package/dist/email/locales.js +29 -0
  19. package/dist/email/locales.js.map +1 -0
  20. package/dist/email/previews/hono.d.ts +46 -0
  21. package/dist/email/previews/hono.js +33 -0
  22. package/dist/email/previews/hono.js.map +1 -0
  23. package/dist/email/previews/index.d.ts +67 -0
  24. package/dist/email/previews/index.js +12 -0
  25. package/dist/email/previews/index.js.map +1 -0
  26. package/dist/email/previews/react/index.d.ts +43 -0
  27. package/dist/email/previews/react/index.js +58 -0
  28. package/dist/email/previews/react/index.js.map +1 -0
  29. package/dist/{generators-CH5m8Ucj.d.ts → generators-BsuXe8Mp.d.ts} +1 -1
  30. package/dist/hono/index.d.ts +6 -5
  31. package/dist/hono/index.js +3 -2
  32. package/dist/hono/index.js.map +1 -1
  33. package/dist/index.d.ts +3 -3
  34. package/dist/index.js +3 -3
  35. package/dist/{jobs-CcsSA0YR.d.ts → jobs-D64B_bg7.d.ts} +1 -1
  36. package/dist/manifest/index.d.ts +39 -1
  37. package/dist/manifest/index.js +9 -0
  38. package/dist/manifest/index.js.map +1 -1
  39. package/dist/manifest/server.d.ts +32 -7
  40. package/dist/manifest/server.js +13 -2
  41. package/dist/manifest/server.js.map +1 -1
  42. package/dist/manifest/web.d.ts +18 -4
  43. package/dist/manifest/web.js +8 -0
  44. package/dist/manifest/web.js.map +1 -1
  45. package/dist/preview-screen-DYJRAnAY.d.ts +83 -0
  46. package/dist/react/index.d.ts +4 -4
  47. package/dist/react/index.js +9 -9
  48. package/dist/server/index.d.ts +8 -7
  49. package/dist/server/index.js +3 -2
  50. package/dist/template-Dlt5S54z.d.ts +196 -0
  51. package/dist/{types-CYJN1xCM.d.ts → types-BFmAY0P5.d.ts} +1 -1
  52. package/dist/web-push/index.d.ts +2 -2
  53. package/dist/{web-push-iER5StBm.d.ts → web-push-D-ysQd5h.d.ts} +1 -1
  54. package/dist/{wire-CmRrB3_s.d.ts → wire-_coV3tJk.d.ts} +1 -1
  55. package/package.json +22 -2
  56. package/src/email/chrome.en-US.ts +8 -0
  57. package/src/email/chrome.pt-BR.ts +21 -0
  58. package/src/email/index.ts +43 -0
  59. package/src/email/locales.ts +29 -0
  60. package/src/email/previews/catalog.ts +208 -0
  61. package/src/email/previews/hono.ts +69 -0
  62. package/src/email/previews/index.ts +26 -0
  63. package/src/email/previews/react/copy.en-US.ts +24 -0
  64. package/src/email/previews/react/copy.pt-BR.ts +24 -0
  65. package/src/email/previews/react/copy.ts +42 -0
  66. package/src/email/previews/react/index.tsx +14 -0
  67. package/src/email/previews/react/loadable.tsx +83 -0
  68. package/src/email/previews/react/message-list.tsx +133 -0
  69. package/src/email/previews/react/message-view.tsx +167 -0
  70. package/src/email/previews/react/preview-screen.tsx +342 -0
  71. package/src/email/previews/react/transport.ts +60 -0
  72. package/src/email/previews/routes.ts +111 -0
  73. package/src/email/template.ts +365 -0
  74. package/src/email/text.ts +59 -0
  75. package/src/email/theme.ts +77 -0
  76. package/src/manifest/index.ts +37 -0
  77. package/src/manifest/server.ts +23 -0
  78. package/src/manifest/web.ts +13 -0
  79. package/src/server/transports/email.ts +62 -0
  80. package/dist/chunk-7234ROOP.js.map +0 -1
package/ADOPTING.md CHANGED
@@ -22,6 +22,8 @@ sweep — endpoints and screens included.
22
22
  | **Hono** | `@12-apps/notifications/hono` | `const notifications = notificationsRouter({ ...serverConfig, resolveActor }); app.route('/api/account', notifications.router)`. A one-call mount; `hono` is an OPTIONAL peer, so importing the root, `/server` or `/react` never resolves it. |
23
23
  | **React** | `@12-apps/notifications/react` | Call `createWebNotifications({ apiBase })`. `BellWithPanel` is the whole feature as one element; `BellButton` + `Panel` are the pair for a host with its own chrome; `page` is the preferences screen you route to. pt-BR product copy and the origin host's test ids ship inside. |
24
24
  | **Web Push** | `@12-apps/notifications/web-push` | `sender: vapidPushSender({ subject, publicKey, privateKey })` on the `WEB_PUSH` declaration. Its own subpath because it is the only piece that needs `web-push` — an OPTIONAL peer a host that never enables the channel never installs. |
25
+ | **Mail layout** | `@12-apps/notifications/email` | `renderEmail(document)` → `{ subject, html, text }`. Framework-free and dependency-free. Turn it on for the EMAIL transport by declaring `layout`; the copy packs are `@12-apps/notifications/email/locales`. |
26
+ | **Previews** | `@12-apps/notifications/email/previews` | The preview catalogue and its two route descriptors. `@12-apps/notifications/email/previews/hono` is the one-call mount; `.../previews/react` is the operator screen. A SECOND wiring manifest — see "The mail layout and its preview console". |
25
27
  | **Prisma** | `prisma/notifications.prisma` + `prisma/migrations/*` | Run `pnpm --filter @12-apps/notifications prisma:sync -- <host schema dir>`: the partial is **COPIED** into the host's multi-file schema folder — never symlinked (a symlinked migration is silently skipped by Prisma; a symlinked partial dangles under `turbo prune`). Migrations are discovered structurally from the installed package's `prisma/migrations` by the host's plugin-migration sync. |
26
28
 
27
29
  ## Host wiring rules (the ones that bite)
@@ -339,6 +341,113 @@ There are **no foreign keys into host tables**: `user_id` and `client_id` are
339
341
  by-value scalars. Add your own in a host migration — `ON DELETE CASCADE` on
340
342
  both is the recommendation, so a deleted account takes its inbox with it.
341
343
 
344
+ ## The mail layout and its preview console
345
+
346
+ Both are optional and independent of everything above: the layout is a pure
347
+ function, and the console declares no `db`, so adopting either touches no schema.
348
+
349
+ ### 1. Turning the layout on for the EMAIL transport
350
+
351
+ Add `layout` to the driver declaration. Without it you keep the previous
352
+ rendering byte for byte — this is opt-in because `brand` and `chrome` are
353
+ REQUIRED with no default, so making them mandatory would break every host
354
+ already declaring EMAIL, at runtime, on the first send.
355
+
356
+ ```ts
357
+ import { PT_BR_EMAIL_CHROME } from '@12-apps/notifications/email/locales';
358
+
359
+ { channel: 'EMAIL', driver: 'resend', apiKey, from, linkLabel: 'Ver detalhes',
360
+ layout: { brand: 'Loja Exemplo', chrome: PT_BR_EMAIL_CHROME, locale: 'pt-BR' } }
361
+ ```
362
+
363
+ `theme` may be omitted; the layout ships a neutral grey-on-white palette. That
364
+ one asymmetry is deliberate and argued in `src/email/theme.ts`: a wrong-language
365
+ default is invisible until a customer complains, where a missing theme is
366
+ visible in the first preview.
367
+
368
+ For a host whose recipients do not share a language, resolve `chrome` per
369
+ message rather than at the mount — a mail's language is the RECIPIENT's, not the
370
+ request's.
371
+
372
+ ### 2. YOU gate the console. The routes only ask
373
+
374
+ `emailPreviewRoutes` declares `kind: 'authenticated'` on both descriptors — the
375
+ wiring contract's word for "behind the host's session resolution and its RBAC".
376
+ It names no permission id, because the ids are yours. **So the descriptor asks
377
+ for a gate; it cannot supply one.**
378
+
379
+ The surface publishes your whole transactional-mail inventory and the exact
380
+ wording and link shape of your verification and password-reset mails — the
381
+ reference somebody writing a convincing phishing mail for your product would
382
+ want. Mount it behind whichever gate you already use for platform staff:
383
+
384
+ ```ts
385
+ import { emailPreviewsRouter } from '@12-apps/notifications/email/previews/hono';
386
+
387
+ app.use('/api/platform/email-previews/*', requirePlatformOperator);
388
+ app.route('/api/platform/email-previews', emailPreviewsRouter(previews).router);
389
+ ```
390
+
391
+ `allow` on the config is there for a host that would rather state the refusal
392
+ once in the config than mount middleware around the router. Either is fine;
393
+ neither is optional.
394
+
395
+ ### 3. Declare the SOURCES — and expect to be asked repeatedly
396
+
397
+ A package cannot know that you send a "your quota is exhausted" notice, let
398
+ alone what data it renders from. So you declare sources, and each is asked
399
+ **per request**:
400
+
401
+ ```ts
402
+ const previews = {
403
+ sources: [authMailSource(), notificationMailSource()],
404
+ locales: ['pt-BR', 'en-US'],
405
+ defaultLocale: 'pt-BR',
406
+ };
407
+ ```
408
+
409
+ Per request rather than once at the mount, because domain modules typically
410
+ register their messages as an import side effect. A catalogue built once lists
411
+ whatever happened to be imported first — which looks exactly like a product that
412
+ sends fewer mails than it does.
413
+
414
+ Ids must be unique across every source; a duplicate THROWS, because the
415
+ alternative is one message unreachable and the other ambiguous, silently.
416
+
417
+ ### 4. `coverage` is optional, and worth writing
418
+
419
+ A catalogue that quietly omits a message looks exactly like a product that does
420
+ not send it. Report what you cannot show — a message with no sample data, or
421
+ sample data for a message that no longer exists — and the screen renders it as a
422
+ warning strip. Pair it with a unit test asserting `missing` is empty, and a mail
423
+ added without a sample becomes a red test naming the type rather than a gap
424
+ nobody sees.
425
+
426
+ ### 5. Adopt it as the SECOND manifest
427
+
428
+ `@12-apps/notifications-email-previews` is a separate manifest from the same
429
+ package, so a host mounts the inbox and declines the console (or vice versa) in
430
+ writing rather than silently getting both behind one gate:
431
+
432
+ ```ts
433
+ host.adoptServer({
434
+ manifest: notificationEmailPreviewsManifest,
435
+ server: notificationEmailPreviewsServerManifest,
436
+ bindings: { http: { mountPath: '/api/platform/email-previews', config: previews } },
437
+ });
438
+ ```
439
+
440
+ The web half binds `{ apiBase, copy }`, with `copy` a pack passed by name —
441
+ `EN_US_EMAIL_PREVIEW_COPY` or `PT_BR_EMAIL_PREVIEW_COPY` from
442
+ `@12-apps/notifications/email/previews/react`.
443
+
444
+ ### 6. Nothing can be sent from the console
445
+
446
+ It holds no driver, no transport and no address, and `render` is pure by the
447
+ contract `EmailPreviewMessage` states. The one mistake a preview surface must be
448
+ incapable of — putting a sample in somebody's inbox — is not reachable from that
449
+ code at all, rather than merely not done.
450
+
342
451
  ## What does NOT come with it
343
452
 
344
453
  - **The events.** Generators are host code (see rule 6).
@@ -346,3 +455,7 @@ both is the recommendation, so a deleted account takes its inbox with it.
346
455
  - **A plan model.** `channelPolicy` answers; it does not decide.
347
456
  - **An authorization engine.** `audience` answers; it does not decide.
348
457
  - **A service worker.** The file is the host's (rule 12).
458
+ - **The mail INVENTORY.** The preview console renders what you declare as
459
+ sources; it cannot discover what your product sends.
460
+ - **A brand, a palette or a sentence.** The layout requires the first, defaults
461
+ only the second, and ships the third as packs you pass by name.
package/README.md CHANGED
@@ -121,6 +121,71 @@ a form B's keys decrypt) and the preferences screen derives "this browser is
121
121
  receiving alerts" from the SERVER's answer, not from the browser's own
122
122
  subscription object.
123
123
 
124
+ ## One mail layout, and a console that previews it
125
+
126
+ `EMAIL` used to render a mail as three bare `<p>` tags. That is a **layout**
127
+ problem rather than a wording one — a paragraph with no document around it
128
+ inherits whatever the client decides: 13px Arial in Gmail, Times New Roman in
129
+ Outlook, no centring anywhere — so the layout ships here, beside the transport
130
+ that needed it.
131
+
132
+ ```ts
133
+ import { renderEmail } from '@12-apps/notifications/email';
134
+ import { PT_BR_EMAIL_CHROME } from '@12-apps/notifications/email/locales';
135
+ ```
136
+
137
+ `renderEmail(document)` takes STRUCTURE — a heading, paragraphs, an optional
138
+ facts table, at most one call to action — and never markup. Three things follow,
139
+ and each was a real defect in the renderers it replaces:
140
+
141
+ 1. **Escaping cannot be forgotten.** It happens in one place.
142
+ 2. **The plain-text twin cannot drift.** Both halves render from the same
143
+ object. A `text/html` part with no `text/plain` twin is scored by every major
144
+ spam filter.
145
+ 3. **A preview is honest**, because the console renders exactly this.
146
+
147
+ The client constraints are encoded once so no caller has to know them: tables
148
+ rather than divs (Outlook lays HTML out with Word), inline styles only (Gmail
149
+ strips `<style>`), no web fonts or `color-mix()` or CSS variables, a hidden
150
+ preheader, a `bgcolor` attribute **and** a background style on the CTA.
151
+
152
+ **The EMAIL transport opts in.** Declare `layout` on the driver declaration and
153
+ `formatEmail` renders the shared document; omit it and you keep the previous
154
+ rendering byte for byte. Opt-in rather than automatic because `brand` and
155
+ `chrome` are required with no default — a package that defaulted them would sign
156
+ another company's mail, in a language nobody chose — so making them mandatory
157
+ would break every host already declaring EMAIL, at runtime, on the first send.
158
+
159
+ ```ts
160
+ { channel: 'EMAIL', driver: 'resend', apiKey, from, linkLabel: 'Ver detalhes',
161
+ layout: { brand: 'Loja Exemplo', chrome: PT_BR_EMAIL_CHROME, locale: 'pt-BR' } }
162
+ ```
163
+
164
+ ### The preview console
165
+
166
+ There is normally no way to *see* a transactional mail without triggering the
167
+ event that sends it — signing up with a throwaway address for the verification
168
+ mail, settling an order for the receipt. So nobody looks, and a product
169
+ rendering three ways does not find out. A layout nobody can see is one release
170
+ from being three again, which is why the console ships in the same package.
171
+
172
+ It is the package's **second wiring manifest**
173
+ (`@12-apps/notifications-email-previews`), because the first has already spent
174
+ `http` on the account inbox and `surface` on the bell. That split is deliberate:
175
+ the inbox ships to every signed-in user, and this console publishes the whole
176
+ mail inventory and the exact wording and link shape of the verification and
177
+ reset mails. **You gate the mount** — the routes declare `kind: 'authenticated'`
178
+ and name no permission id, because the ids are the host's.
179
+
180
+ WHICH messages exist is yours too, declared as `sources` that are asked **per
181
+ request** (a source backed by a registry that fills at import time would
182
+ otherwise answer with whatever was imported first). A source reports its own
183
+ `coverage` — what it cannot show — and the screen renders that as a warning
184
+ strip rather than pretending to be complete. Nothing can be sent from it: it
185
+ holds no driver, no transport and no address.
186
+
187
+ See **[ADOPTING.md](./ADOPTING.md)** for the mount, the gate and the sources.
188
+
124
189
  ## The models
125
190
 
126
191
  `prisma/notifications.prisma` — `Notification`, `NotificationDelivery`,
@@ -0,0 +1,132 @@
1
+ import { R as RenderedEmail } from './template-Dlt5S54z.js';
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
+ /** One previewable message. */
41
+ interface EmailPreviewMessage {
42
+ /**
43
+ * Stable id, and the path segment the console navigates by.
44
+ *
45
+ * The host chooses the spelling; the surface only requires it to be unique
46
+ * across every source. Ids that survive a rename are what make a link to one
47
+ * mail worth sending to a colleague.
48
+ */
49
+ readonly id: string;
50
+ /** The wire identity — an event type, a template key. Shown beside the subject. */
51
+ readonly key: string;
52
+ /** Which mail path this belongs to, for grouping inside one owner. */
53
+ readonly family: string;
54
+ /** Render it for one reader. MUST be pure — nothing may be sent. */
55
+ render(locale: string): RenderedEmail;
56
+ }
57
+ /** What a source knows it cannot show. Both directions are worth reporting. */
58
+ interface EmailPreviewCoverage {
59
+ /** Messages that exist but have no sample data, so no preview. */
60
+ readonly missing: readonly string[];
61
+ /** Sample data for a message nothing produces any more. */
62
+ readonly orphan: readonly string[];
63
+ }
64
+ /** One group of messages, owned by whichever code words them. */
65
+ interface EmailPreviewSource {
66
+ /**
67
+ * Who owns these messages — a package name, or the host's own module.
68
+ *
69
+ * This is the console's grouping, and it is the answer to "which parts of
70
+ * this system send mail", which is a question most hosts cannot otherwise
71
+ * answer at all.
72
+ */
73
+ readonly owner: string;
74
+ /** Called PER REQUEST — see the docblock above for why that matters. */
75
+ list(): readonly EmailPreviewMessage[];
76
+ /** Optional: what this source cannot show. Absent means "nothing missing". */
77
+ coverage?(): EmailPreviewCoverage;
78
+ }
79
+ /** One catalogue row: everything the sidebar needs, and no rendered bytes. */
80
+ interface EmailPreviewRow {
81
+ readonly id: string;
82
+ readonly key: string;
83
+ readonly family: string;
84
+ readonly owner: string;
85
+ /** The subject in the requested language, so the list reads as an inbox. */
86
+ readonly subject: string;
87
+ }
88
+ /** One rendered message: what the vendor would be handed, verbatim. */
89
+ interface EmailPreviewDetail extends EmailPreviewRow {
90
+ readonly locale: string;
91
+ readonly html: string;
92
+ readonly text: string;
93
+ }
94
+ interface EmailPreviewIndex {
95
+ readonly locale: string;
96
+ readonly locales: readonly string[];
97
+ readonly items: readonly EmailPreviewRow[];
98
+ readonly coverage: EmailPreviewCoverage;
99
+ }
100
+ interface EmailPreviewsConfig {
101
+ /** The host's sources, in the order the console should list their owners. */
102
+ readonly sources: readonly EmailPreviewSource[];
103
+ /**
104
+ * The languages a preview may be asked for.
105
+ *
106
+ * REQUIRED, and not derived from the shipped packs: a host's own messages may
107
+ * be written in languages this package has never heard of, and a preview that
108
+ * silently refused one of them would look exactly like a product with no
109
+ * translation.
110
+ */
111
+ readonly locales: readonly string[];
112
+ /** The language a preview opens in when the caller names none. */
113
+ readonly defaultLocale: string;
114
+ }
115
+ /** An id that appears in more than one source — the one thing that must not happen. */
116
+ declare class DuplicateEmailPreviewIdError extends Error {
117
+ constructor(id: string);
118
+ }
119
+ /** The catalogue, assembled from every source. */
120
+ interface ApiEmailPreviews {
121
+ /** Every message, with each subject rendered in `locale`. */
122
+ index(locale?: string): EmailPreviewIndex;
123
+ /** One rendered message, or `null` when no source owns that id. */
124
+ render(id: string, locale?: string): EmailPreviewDetail | null;
125
+ /** The union of every source's gaps. */
126
+ coverage(): EmailPreviewCoverage;
127
+ /** Is this a language the host said it writes in? */
128
+ supportsLocale(locale: string): boolean;
129
+ }
130
+ declare function createEmailPreviews(config: EmailPreviewsConfig): ApiEmailPreviews;
131
+
132
+ export { type ApiEmailPreviews as A, DuplicateEmailPreviewIdError as D, type EmailPreviewsConfig as E, type EmailPreviewCoverage as a, type EmailPreviewDetail as b, type EmailPreviewIndex as c, type EmailPreviewMessage as d, type EmailPreviewRow as e, type EmailPreviewSource as f, createEmailPreviews as g };
@@ -0,0 +1,222 @@
1
+ import {
2
+ __name
3
+ } from "./chunk-7QVYU63E.js";
4
+
5
+ // src/email/text.ts
6
+ function renderEmailText(document) {
7
+ const blocks = [
8
+ document.heading,
9
+ "",
10
+ ...textParagraphs(document),
11
+ ...textFacts(document),
12
+ ...textAction(document),
13
+ ...textNotes(document),
14
+ "--",
15
+ document.chrome.tagline(document.brand),
16
+ document.chrome.automated
17
+ ];
18
+ return `${blocks.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}
19
+ `;
20
+ }
21
+ __name(renderEmailText, "renderEmailText");
22
+ function textParagraphs(document) {
23
+ return (document.paragraphs ?? []).flatMap((text) => [text, ""]);
24
+ }
25
+ __name(textParagraphs, "textParagraphs");
26
+ function textFacts(document) {
27
+ const rows = document.facts ?? [];
28
+ return rows.length === 0 ? [] : [...rows.map((f) => `${f.label}: ${f.value}`), ""];
29
+ }
30
+ __name(textFacts, "textFacts");
31
+ function textAction(document) {
32
+ return document.action ? [`${document.action.label}: ${document.action.href}`, ""] : [];
33
+ }
34
+ __name(textAction, "textAction");
35
+ function textNotes(document) {
36
+ const notes = document.notes ?? [];
37
+ return notes.length === 0 ? [] : [...notes, ""];
38
+ }
39
+ __name(textNotes, "textNotes");
40
+
41
+ // src/email/theme.ts
42
+ var NEUTRAL_EMAIL_THEME = {
43
+ page: "#F5F6F8",
44
+ surface: "#FFFFFF",
45
+ panel: "#F5F6F8",
46
+ border: "#E1E4E9",
47
+ ink: "#1A1D23",
48
+ muted: "#5C6470",
49
+ accent: "#1F5EDB",
50
+ onAccent: "#FFFFFF",
51
+ rule: "#1F5EDB"
52
+ };
53
+ var EMAIL_FONT_STACK = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'";
54
+ var EMAIL_CONTENT_WIDTH = 600;
55
+
56
+ // src/email/template.ts
57
+ function escapeHtml(value) {
58
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
59
+ }
60
+ __name(escapeHtml, "escapeHtml");
61
+ var SAFE_LINK_SCHEMES = /* @__PURE__ */ new Set(["http:", "https:", "mailto:"]);
62
+ function safeHref(href) {
63
+ const trimmed = href.trim();
64
+ if (trimmed.startsWith("/")) return escapeHtml(trimmed);
65
+ try {
66
+ const url = new URL(trimmed);
67
+ return SAFE_LINK_SCHEMES.has(url.protocol) ? escapeHtml(url.toString()) : "#";
68
+ } catch {
69
+ return "#";
70
+ }
71
+ }
72
+ __name(safeHref, "safeHref");
73
+ function preheaderOf(document) {
74
+ return document.preheader ?? document.paragraphs?.[0] ?? document.heading;
75
+ }
76
+ __name(preheaderOf, "preheaderOf");
77
+ function paragraph(text, theme) {
78
+ return `<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:${theme.ink}">${escapeHtml(text)}</p>`;
79
+ }
80
+ __name(paragraph, "paragraph");
81
+ function facts(rows, theme) {
82
+ if (rows.length === 0) return "";
83
+ const cells = rows.map((row, index) => {
84
+ const divider = index === 0 ? "" : `border-top:1px solid ${theme.border};`;
85
+ const value = row.emphasis ? `font-size:20px;font-weight:700;color:${theme.ink}` : `font-size:15px;color:${theme.ink}`;
86
+ return [
87
+ `<tr>`,
88
+ `<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>`,
89
+ `<td align="right" style="${divider}padding:12px 16px 12px 0;line-height:1.4;${value}">${escapeHtml(row.value)}</td>`,
90
+ `</tr>`
91
+ ].join("");
92
+ }).join("");
93
+ return [
94
+ `<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">`,
95
+ cells,
96
+ `</table>`
97
+ ].join("");
98
+ }
99
+ __name(facts, "facts");
100
+ function action(cta, theme) {
101
+ return [
102
+ `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:8px 0 24px">`,
103
+ `<tr>`,
104
+ `<td align="center" bgcolor="${theme.accent}" style="border-radius:8px;background:${theme.accent}">`,
105
+ `<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>`,
106
+ `</td>`,
107
+ `</tr>`,
108
+ `</table>`
109
+ ].join("");
110
+ }
111
+ __name(action, "action");
112
+ function fallback(cta, chrome, theme) {
113
+ return [
114
+ `<p style="margin:0 0 8px;font-size:13px;line-height:1.5;color:${theme.muted}">${escapeHtml(chrome.fallbackHint)}</p>`,
115
+ `<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>`
116
+ ].join("");
117
+ }
118
+ __name(fallback, "fallback");
119
+ function header(document, theme) {
120
+ return [
121
+ `<tr>`,
122
+ `<td style="padding:32px 40px 0">`,
123
+ `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">`,
124
+ `<tr><td style="font-size:20px;font-weight:700;letter-spacing:-.2px;color:${theme.ink}">${escapeHtml(document.brand)}</td></tr>`,
125
+ `<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>`,
126
+ `</table>`,
127
+ `</td>`,
128
+ `</tr>`
129
+ ].join("");
130
+ }
131
+ __name(header, "header");
132
+ function footer(document, theme) {
133
+ const { chrome, brand } = document;
134
+ return [
135
+ `<tr>`,
136
+ `<td style="padding:0 40px 40px">`,
137
+ `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">`,
138
+ `<tr><td style="border-top:1px solid ${theme.border};padding-top:20px">`,
139
+ `<p style="margin:0 0 6px;font-size:13px;line-height:1.5;color:${theme.muted}">${escapeHtml(chrome.tagline(brand))}</p>`,
140
+ `<p style="margin:0;font-size:12px;line-height:1.5;color:${theme.muted}">${escapeHtml(chrome.automated)}</p>`,
141
+ `</td></tr>`,
142
+ `</table>`,
143
+ `</td>`,
144
+ `</tr>`
145
+ ].join("");
146
+ }
147
+ __name(footer, "footer");
148
+ function styleBlock() {
149
+ return [
150
+ `<style>`,
151
+ `@media only screen and (max-width:620px){`,
152
+ `.fp-card{width:100%!important;border-radius:0!important;border-left:0!important;border-right:0!important}`,
153
+ `.fp-pad{padding-left:24px!important;padding-right:24px!important}`,
154
+ `.fp-h1{font-size:22px!important}`,
155
+ `}`,
156
+ `</style>`
157
+ ].join("");
158
+ }
159
+ __name(styleBlock, "styleBlock");
160
+ function renderEmailHtml(document) {
161
+ const theme = document.theme ?? NEUTRAL_EMAIL_THEME;
162
+ const body = [
163
+ `<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>`,
164
+ ...(document.paragraphs ?? []).map((text) => paragraph(text, theme)),
165
+ facts(document.facts ?? [], theme),
166
+ ...document.action ? [action(document.action, theme)] : [],
167
+ ...document.action ? [fallback(document.action, document.chrome, theme)] : [],
168
+ ...(document.notes ?? []).map(
169
+ (note) => `<p style="margin:0 0 8px;font-size:13px;line-height:1.5;color:${theme.muted}">${escapeHtml(note)}</p>`
170
+ )
171
+ ].join("\n ");
172
+ return `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "https://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
173
+ <html xmlns="http://www.w3.org/1999/xhtml" lang="${escapeHtml(document.locale)}">
174
+ <head>
175
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
176
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
177
+ <meta name="x-apple-disable-message-reformatting" />
178
+ <meta name="color-scheme" content="light" />
179
+ <meta name="supported-color-schemes" content="light" />
180
+ <title>${escapeHtml(document.subject)}</title>
181
+ ${styleBlock()}
182
+ </head>
183
+ <body style="margin:0;padding:0;background:${theme.page};font-family:${EMAIL_FONT_STACK};-webkit-font-smoothing:antialiased">
184
+ <div style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all">${escapeHtml(preheaderOf(document))}</div>
185
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:${theme.page}">
186
+ <tr>
187
+ <td align="center" style="padding:32px 12px">
188
+ <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">
189
+ ${header(document, theme)}
190
+ <tr>
191
+ <td class="fp-pad" style="padding:24px 40px 8px">
192
+ ${body}
193
+ </td>
194
+ </tr>
195
+ ${footer(document, theme)}
196
+ </table>
197
+ </td>
198
+ </tr>
199
+ </table>
200
+ </body>
201
+ </html>`;
202
+ }
203
+ __name(renderEmailHtml, "renderEmailHtml");
204
+ function renderEmail(document) {
205
+ return {
206
+ subject: document.subject,
207
+ html: renderEmailHtml(document),
208
+ text: renderEmailText(document)
209
+ };
210
+ }
211
+ __name(renderEmail, "renderEmail");
212
+
213
+ export {
214
+ renderEmailText,
215
+ NEUTRAL_EMAIL_THEME,
216
+ EMAIL_FONT_STACK,
217
+ EMAIL_CONTENT_WIDTH,
218
+ safeHref,
219
+ renderEmailHtml,
220
+ renderEmail
221
+ };
222
+ //# sourceMappingURL=chunk-EKUSNUBT.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/email/text.ts","../src/email/theme.ts","../src/email/template.ts"],"sourcesContent":["import type { EmailDocument } from './template';\n\n/**\n * The plain-text half.\n *\n * Not a courtesy. Every major spam filter scores a `text/html` part with no\n * `text/plain` twin, and a watch, a terminal client and a screen reader in\n * plain-text mode show this and nothing else.\n *\n * It renders from the SAME `EmailDocument` the HTML half does, which is the\n * property that matters: a line added to one is in the other, so the two cannot\n * drift. Its own module only because the HTML renderer is already a file's\n * worth of client workarounds — the seam is the output format, not the content.\n */\n\n/**\n * Render the document as the plain-text half.\n *\n * Not a courtesy. Every major spam filter scores a `text/html` part with no\n * `text/plain` twin, and a watch, a terminal client and a screen reader in\n * plain-text mode show this and nothing else. It is rendered from the SAME\n * object, so it cannot say less than the HTML does.\n */\nexport function renderEmailText(document: EmailDocument): string {\n const blocks = [\n document.heading,\n \"\",\n ...textParagraphs(document),\n ...textFacts(document),\n ...textAction(document),\n ...textNotes(document),\n \"--\",\n document.chrome.tagline(document.brand),\n document.chrome.automated,\n ];\n return `${blocks.join(\"\\n\").replace(/\\n{3,}/g, \"\\n\\n\").trimEnd()}\\n`;\n}\n\n/** One blank line after each paragraph, as prose reads. */\nfunction textParagraphs(document: EmailDocument): string[] {\n return (document.paragraphs ?? []).flatMap((text) => [text, \"\"]);\n}\n\n/** `Label: value` per row, as a receipt is quoted back over the phone. */\nfunction textFacts(document: EmailDocument): string[] {\n const rows = document.facts ?? [];\n return rows.length === 0 ? [] : [...rows.map((f) => `${f.label}: ${f.value}`), \"\"];\n}\n\n/** The CTA as label + URL: a plain-text reader has no button to press. */\nfunction textAction(document: EmailDocument): string[] {\n return document.action ? [`${document.action.label}: ${document.action.href}`, \"\"] : [];\n}\n\nfunction textNotes(document: EmailDocument): string[] {\n const notes = document.notes ?? [];\n return notes.length === 0 ? [] : [...notes, \"\"];\n}\n\n","/**\n * The layout's design tokens — the nine colours a mail is drawn from.\n *\n * ## Why a theme is a VALUE here and copy is required config\n *\n * The copy-portability doctrine is that a package ships no user-facing\n * sentences: a default in the origin host's language reads as finished to the\n * next host right up until somebody's customer gets mail in a language they do\n * not speak. Colour is not that. {@link NEUTRAL_EMAIL_THEME} is not anybody's\n * brand — it is grey on white, which reads as *unstyled but tidy* rather than\n * as *somebody else's product*. A host that passes nothing gets a mail that\n * looks plain, not a mail that looks like a different company.\n *\n * So this one has a default and the copy does not, and the difference is\n * exactly whether getting it wrong is invisible. A missing theme is visible in\n * the first preview; a wrong-language default is invisible until a customer\n * complains.\n *\n * ## What a host should know before overriding it\n *\n * Two rungs of most brand palettes cannot carry light text, and a mail has no\n * theme to fall back on when a client ignores a colour. {@link accent} is the\n * one that MUST clear 4.5:1 against white — it is the CTA fill and every link.\n * A warm mid-tone that looks fine on a screen at 2.6:1 is unreadable in an\n * inbox, and nothing in the pipeline will say so.\n */\nexport interface EmailTheme {\n /** The page behind the card. Never pure white: a white card needs a ground. */\n readonly page: string;\n /** The card itself. */\n readonly surface: string;\n /** A quieter panel inside the card — the facts table. */\n readonly panel: string;\n /** Every hairline. */\n readonly border: string;\n /** Body text. */\n readonly ink: string;\n /** Secondary text: the footer, the fallback link, a fact's label. */\n readonly muted: string;\n /** The action colour — the CTA fill and every link. Must clear 4.5:1 on white. */\n readonly accent: string;\n /** Ink ON {@link accent}. */\n readonly onAccent: string;\n /** The rule under the wordmark. Decorative — nothing is written on it. */\n readonly rule: string;\n}\n\n/**\n * A brand-free default: greys, one blue, nothing anybody would mistake for a\n * product. Every value clears its contrast requirement against its own ground.\n */\nexport const NEUTRAL_EMAIL_THEME: EmailTheme = {\n page: '#F5F6F8',\n surface: '#FFFFFF',\n panel: '#F5F6F8',\n border: '#E1E4E9',\n ink: '#1A1D23',\n muted: '#5C6470',\n accent: '#1F5EDB',\n onAccent: '#FFFFFF',\n rule: '#1F5EDB',\n};\n\n/**\n * The font stack, as one string.\n *\n * System fonts only, and no web font: a `@font-face` is stripped by Gmail and\n * by Outlook, so a mail that depends on one renders in whatever the client\n * substitutes — never the fallback the designer chose. The stack ends in the\n * emoji faces so a status glyph in a subject line does not drop to a tofu box\n * on Windows.\n */\nexport const EMAIL_FONT_STACK =\n \"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'\";\n\n/** The card's width. 600px is the width every client lays out without scroll. */\nexport const EMAIL_CONTENT_WIDTH = 600;\n","import { renderEmailText } from './text';\nimport {\n EMAIL_CONTENT_WIDTH,\n EMAIL_FONT_STACK,\n NEUTRAL_EMAIL_THEME,\n type EmailTheme,\n} from './theme';\n\n/**\n * THE transactional-mail layout: one document model, one HTML renderer, one\n * plain-text twin.\n *\n * ## The problem it exists for\n *\n * A host that sends mail from more than one place ends up rendering it in more\n * than one way — the notification pipeline emits a bold paragraph and a bare\n * anchor, the sign-in flow emits a `div` with a max-width, the receipt builds\n * its own list — and each grows its own `escapeHtml` and its own text twin. The\n * three look like three different products, because a paragraph with no\n * document around it inherits whatever the client decides: 13px Arial in Gmail,\n * Times New Roman in Outlook, no centring anywhere.\n *\n * That is a LAYOUT problem rather than a wording one, which is what makes it a\n * package's to solve. The words stay the host's; the document does not.\n *\n * ## Why the document is a MODEL and not a string\n *\n * Every caller hands over structure — a heading, some paragraphs, an optional\n * facts table, at most one call to action — and never markup. Three things fall\n * out of that, and each is a real defect in the hand-rolled renderers this\n * replaces:\n *\n * 1. **Escaping cannot be forgotten.** Every field is escaped here, once. Of\n * the three renderers this was extracted from, two escaped the body and\n * interpolated the LINK raw.\n * 2. **The plain-text twin cannot drift.** Both halves render from the same\n * object, so a line added to one is in the other. A `text/html` part with no\n * `text/plain` twin is scored by every major spam filter, and it is what a\n * watch, a terminal client and a screen reader in plain-text mode show.\n * 3. **A preview is honest.** `./server`'s catalogue renders exactly this, from\n * exactly these inputs, so a preview cannot be right about a mail nobody\n * gets.\n *\n * ## The client constraints this encodes\n *\n * Stated once here, so no caller has to know them:\n *\n * - **Tables, not divs.** Outlook 2016+ on Windows lays HTML out with Word,\n * which supports no `flex`, no `grid` and no reliable `max-width` on a block.\n * A centred 600px table is the one construction every client agrees on.\n * - **Inline styles.** Gmail strips `<style>` from the document it renders in\n * several contexts, and always in the Gmail app for a non-Gmail account. The\n * `<style>` block below therefore carries only the mobile media query —\n * progressive enhancement, never anything the layout depends on.\n * - **No web fonts, no `color-mix()`, no CSS variables.** See `./theme`.\n * - **A preheader.** The inbox list shows the first text in the body after the\n * subject; without one it shows the footer's legal line.\n * - **`role=\"presentation\"`** on every layout table, so a screen reader reads\n * the message rather than announcing a five-column grid.\n */\n\n/** The words the LAYOUT itself needs — never the message's own sentences. */\nexport interface EmailChromeCopy {\n /** \"If the button does not work, paste this address into your browser:\" */\n readonly fallbackHint: string;\n /** \"This is an automated message — please do not reply.\" */\n readonly automated: string;\n /** The footer's one line about the product, given the brand name. */\n readonly tagline: (brand: string) => string;\n}\n\n/** The one call to action a message may carry. More than one dilutes both. */\nexport interface EmailAction {\n readonly label: string;\n readonly href: string;\n}\n\n/** One row of the facts panel — a receipt's total, an order's reference. */\nexport interface EmailFact {\n readonly label: string;\n readonly value: string;\n /** Renders bolder and larger — the one number the reader came for. */\n readonly emphasis?: boolean;\n}\n\n/** Everything a message says, with no opinion about how it looks. */\nexport interface EmailDocument {\n /** The subject line. Also the document `<title>`. */\n readonly subject: string;\n /**\n * The inbox-list preview, ~90 chars. Falls back to the first paragraph,\n * which is right far more often than it is wrong.\n */\n readonly preheader?: string;\n /** The `<h1>`. Usually a restatement of the subject in the reader's terms. */\n readonly heading: string;\n /** The body, one entry per paragraph. Plain sentences — never markup. */\n readonly paragraphs?: readonly string[];\n /** An optional label/value panel under the body. */\n readonly facts?: readonly EmailFact[];\n /** At most one CTA. */\n readonly action?: EmailAction;\n /** Small print under the CTA — a deadline, a \"you can ignore this\". */\n readonly notes?: readonly string[];\n /**\n * The layout's own words, in the recipient's language.\n *\n * REQUIRED, with no default in any language — the copy-portability doctrine.\n * `@12-apps/notifications/email/locales` ships a pack per language and a host\n * passes one BY\n * NAME, which is a decision rather than a silence.\n */\n readonly chrome: EmailChromeCopy;\n /**\n * The product name in the header and the footer. REQUIRED: a package that\n * defaulted this would sign another company's mail.\n */\n readonly brand: string;\n /** The recipient's language, for the document's `lang` attribute. */\n readonly locale: string;\n /** Defaults to {@link NEUTRAL_EMAIL_THEME} — see `./theme` for why this one may. */\n readonly theme?: EmailTheme;\n}\n\n/**\n * Escape everything that reaches the HTML body.\n *\n * A store name, a buyer's display name and a product title are all user input\n * that reaches a mail, and the ONE thing a shared layout must guarantee is that\n * none of them can close a tag. Single quotes are escaped too: attribute values\n * below are double-quoted, but a caller reading this should not have to check.\n */\nfunction escapeHtml(value: string): string {\n return value\n .replaceAll(\"&\", \"&amp;\")\n .replaceAll(\"<\", \"&lt;\")\n .replaceAll(\">\", \"&gt;\")\n .replaceAll('\"', \"&quot;\")\n .replaceAll(\"'\", \"&#39;\");\n}\n\n/**\n * The schemes a link in a mail may use.\n *\n * `javascript:` is inert in every mail client, so this is not the XSS guard it\n * would be on a page — it is the guard for the PREVIEW, which renders the same\n * HTML in a browser, and for the day some part of this markup is reused on a\n * screen. A rejected href drops to `#`, which fails visibly rather than\n * silently linking somewhere unexpected.\n */\nconst SAFE_LINK_SCHEMES = new Set([\"http:\", \"https:\", \"mailto:\"]);\n\n/** A URL as an attribute value, or `#` when it is not one this layout will emit. */\nexport function safeHref(href: string): string {\n const trimmed = href.trim();\n // A relative link is fine and common — every caller that has an origin\n // resolves it before it gets here, and one that does not is better off with\n // a path than with nothing.\n if (trimmed.startsWith(\"/\")) return escapeHtml(trimmed);\n try {\n const url = new URL(trimmed);\n return SAFE_LINK_SCHEMES.has(url.protocol) ? escapeHtml(url.toString()) : \"#\";\n } catch {\n return \"#\";\n }\n}\n\n/** The message's own first sentence, when the caller named no preheader. */\nfunction preheaderOf(document: EmailDocument): string {\n return document.preheader ?? document.paragraphs?.[0] ?? document.heading;\n}\n\n/** A `<td>` of body copy. One paragraph, at the layout's own rhythm. */\nfunction paragraph(text: string, theme: EmailTheme): string {\n return `<p style=\"margin:0 0 16px;font-size:16px;line-height:1.6;color:${theme.ink}\">${escapeHtml(text)}</p>`;\n}\n\n/**\n * The facts panel — a receipt's four lines, an order's reference.\n *\n * Two cells per row rather than a definition list: `<dl>` is styled\n * inconsistently across clients and collapses to a single column in Outlook,\n * which is the one place these are read side by side.\n */\nfunction facts(rows: readonly EmailFact[], theme: EmailTheme): string {\n if (rows.length === 0) return \"\";\n const cells = rows\n .map((row, index) => {\n const divider =\n index === 0 ? \"\" : `border-top:1px solid ${theme.border};`;\n const value = row.emphasis\n ? `font-size:20px;font-weight:700;color:${theme.ink}`\n : `font-size:15px;color:${theme.ink}`;\n return [\n `<tr>`,\n `<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>`,\n `<td align=\"right\" style=\"${divider}padding:12px 16px 12px 0;line-height:1.4;${value}\">${escapeHtml(row.value)}</td>`,\n `</tr>`,\n ].join(\"\");\n })\n .join(\"\");\n return [\n `<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\">`,\n cells,\n `</table>`,\n ].join(\"\");\n}\n\n/**\n * The CTA.\n *\n * A single-cell table with `bgcolor` AND a background style: Outlook reads the\n * attribute and ignores the property, every other client does the reverse, and\n * a button that loses its fill is invisible ink on white. `border-radius` is\n * simply dropped by Word — a square button is the accepted degradation, and the\n * alternative (a VML rounded rectangle) is forty lines of conditional comment\n * that then has to be kept in step with the colour.\n */\nfunction action(cta: EmailAction, theme: EmailTheme): string {\n return [\n `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"margin:8px 0 24px\">`,\n `<tr>`,\n `<td align=\"center\" bgcolor=\"${theme.accent}\" style=\"border-radius:8px;background:${theme.accent}\">`,\n `<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>`,\n `</td>`,\n `</tr>`,\n `</table>`,\n ].join(\"\");\n}\n\n/**\n * The \"paste this address\" fallback, printed whenever there is a CTA.\n *\n * Not optional, and the reason is measured elsewhere in this repo: corporate\n * mail gateways rewrite link targets, and a reader whose gateway mangles the\n * button has no other way to reach a verification or reset link. `word-break`\n * because a signed URL is longer than the card.\n */\nfunction fallback(cta: EmailAction, chrome: EmailChromeCopy, theme: EmailTheme): string {\n return [\n `<p style=\"margin:0 0 8px;font-size:13px;line-height:1.5;color:${theme.muted}\">${escapeHtml(chrome.fallbackHint)}</p>`,\n `<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>`,\n ].join(\"\");\n}\n\n/** The brand header: wordmark over the ramp's rule. */\nfunction header(document: EmailDocument, theme: EmailTheme): string {\n return [\n `<tr>`,\n `<td style=\"padding:32px 40px 0\">`,\n `<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\">`,\n `<tr><td style=\"font-size:20px;font-weight:700;letter-spacing:-.2px;color:${theme.ink}\">${escapeHtml(document.brand)}</td></tr>`,\n `<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>`,\n `</table>`,\n `</td>`,\n `</tr>`,\n ].join(\"\");\n}\n\n/** The footer: the tagline, the \"do not reply\" line, and nothing else. */\nfunction footer(document: EmailDocument, theme: EmailTheme): string {\n const { chrome, brand } = document;\n return [\n `<tr>`,\n `<td style=\"padding:0 40px 40px\">`,\n `<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\">`,\n `<tr><td style=\"border-top:1px solid ${theme.border};padding-top:20px\">`,\n `<p style=\"margin:0 0 6px;font-size:13px;line-height:1.5;color:${theme.muted}\">${escapeHtml(chrome.tagline(brand))}</p>`,\n `<p style=\"margin:0;font-size:12px;line-height:1.5;color:${theme.muted}\">${escapeHtml(chrome.automated)}</p>`,\n `</td></tr>`,\n `</table>`,\n `</td>`,\n `</tr>`,\n ].join(\"\");\n}\n\n/**\n * The mobile media query — the one thing in a `<style>` block.\n *\n * Everything it does is also correct without it: the card is already\n * `width:100%` up to 600px, so a client that strips this renders a slightly\n * roomier mail rather than a broken one. That is the test for whether a rule\n * belongs here at all.\n */\nfunction styleBlock(): string {\n return [\n `<style>`,\n `@media only screen and (max-width:620px){`,\n `.fp-card{width:100%!important;border-radius:0!important;border-left:0!important;border-right:0!important}`,\n `.fp-pad{padding-left:24px!important;padding-right:24px!important}`,\n `.fp-h1{font-size:22px!important}`,\n `}`,\n `</style>`,\n ].join(\"\");\n}\n\n/**\n * Render the document as the HTML half of the message.\n *\n * A complete document rather than a fragment: a `<!DOCTYPE>` is what puts\n * Outlook into standards mode, and `x-apple-disable-message-reformatting` is\n * what stops iOS Mail re-flowing the card to the screen width and shrinking the\n * type with it.\n */\nexport function renderEmailHtml(document: EmailDocument): string {\n const theme = document.theme ?? NEUTRAL_EMAIL_THEME;\n const body = [\n `<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>`,\n ...(document.paragraphs ?? []).map((text) => paragraph(text, theme)),\n facts(document.facts ?? [], theme),\n ...(document.action ? [action(document.action, theme)] : []),\n ...(document.action ? [fallback(document.action, document.chrome, theme)] : []),\n ...(document.notes ?? []).map(\n (note) =>\n `<p style=\"margin:0 0 8px;font-size:13px;line-height:1.5;color:${theme.muted}\">${escapeHtml(note)}</p>`,\n ),\n ].join(\"\\n \");\n\n return `<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"https://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">\n<html xmlns=\"http://www.w3.org/1999/xhtml\" lang=\"${escapeHtml(document.locale)}\">\n<head>\n<meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\" />\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n<meta name=\"x-apple-disable-message-reformatting\" />\n<meta name=\"color-scheme\" content=\"light\" />\n<meta name=\"supported-color-schemes\" content=\"light\" />\n<title>${escapeHtml(document.subject)}</title>\n${styleBlock()}\n</head>\n<body style=\"margin:0;padding:0;background:${theme.page};font-family:${EMAIL_FONT_STACK};-webkit-font-smoothing:antialiased\">\n<div style=\"display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all\">${escapeHtml(preheaderOf(document))}</div>\n<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"background:${theme.page}\">\n<tr>\n<td align=\"center\" style=\"padding:32px 12px\">\n<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\">\n${header(document, theme)}\n<tr>\n<td class=\"fp-pad\" style=\"padding:24px 40px 8px\">\n ${body}\n</td>\n</tr>\n${footer(document, theme)}\n</table>\n</td>\n</tr>\n</table>\n</body>\n</html>`;\n}\n\n/** Both halves plus the subject — what every driver in this repo is handed. */\nexport interface RenderedEmail {\n readonly subject: string;\n readonly html: string;\n readonly text: string;\n}\n\n/** Render one document into the message shape `EmailDriver.send` takes. */\nexport function renderEmail(document: EmailDocument): RenderedEmail {\n return {\n subject: document.subject,\n html: renderEmailHtml(document),\n text: renderEmailText(document),\n };\n}\n"],"mappings":"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","names":[]}