@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
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)
|
|
@@ -55,6 +57,20 @@ sweep — endpoints and screens included.
|
|
|
55
57
|
}
|
|
56
58
|
```
|
|
57
59
|
|
|
60
|
+
`getContact` may also answer `locale` — the recipient's own language. It
|
|
61
|
+
belongs here rather than on the event because it is a fact about the PERSON,
|
|
62
|
+
not about what happened, and this package has no user table to read it from.
|
|
63
|
+
Supplying it is what makes a bilingual generator mean anything: a
|
|
64
|
+
notification is stored as rendered TEXT, so the language is fixed when the
|
|
65
|
+
row is written, and the router asks for it at exactly that moment. Omit the
|
|
66
|
+
field, or answer `null`, and every generator falls back to its own default
|
|
67
|
+
exactly as before — which is the honest answer for a host that stores no
|
|
68
|
+
per-user language yet.
|
|
69
|
+
|
|
70
|
+
The tag is the RECIPIENT's, never the request's. The person who triggers a
|
|
71
|
+
notification is routinely not the person who reads it, so resolving from
|
|
72
|
+
`Accept-Language` would word the notice for the wrong human.
|
|
73
|
+
|
|
58
74
|
Returning `null` means "no such recipient", and `notify` throws
|
|
59
75
|
`UnknownNotificationRecipientError` on it. That is deliberate: a
|
|
60
76
|
notification addressed to nobody is a caller bug, never a silent drop.
|
|
@@ -119,6 +135,24 @@ sweep — endpoints and screens included.
|
|
|
119
135
|
or call `registerGenerator` for a module imported later. The generator's
|
|
120
136
|
`category` is what the router gates fan-out on.
|
|
121
137
|
|
|
138
|
+
`generate` takes an optional second argument, `{ locale }` — the recipient's
|
|
139
|
+
language, from `getContact`. Reach for it when your readers do not share one:
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
const orderPaid = {
|
|
143
|
+
type: 'order.paid',
|
|
144
|
+
category: 'orders',
|
|
145
|
+
// Resolved HERE, per notice — never where the generator is built.
|
|
146
|
+
generate: (payload, context) => resolveCopy(ORDER_PAID_COPY, context ?? {}).of(payload),
|
|
147
|
+
};
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Resolving where the generator is BUILT is the mistake this parameter exists
|
|
151
|
+
to prevent: generators are registered once, at boot, so a table chosen there
|
|
152
|
+
words every notification the process will ever write in a single language —
|
|
153
|
+
and a host with one audience cannot tell the difference. A one-parameter
|
|
154
|
+
`generate` stays valid and unchanged.
|
|
155
|
+
|
|
122
156
|
7. **Billing stays outside, and its gate is `channelPolicy`.** The money logic
|
|
123
157
|
is the host's — this package never learns what a plan is. What it owns is
|
|
124
158
|
*where the answer is asked for*: `(clientId, channels) => channels`, awaited
|
|
@@ -307,6 +341,113 @@ There are **no foreign keys into host tables**: `user_id` and `client_id` are
|
|
|
307
341
|
by-value scalars. Add your own in a host migration — `ON DELETE CASCADE` on
|
|
308
342
|
both is the recommendation, so a deleted account takes its inbox with it.
|
|
309
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
|
+
|
|
310
451
|
## What does NOT come with it
|
|
311
452
|
|
|
312
453
|
- **The events.** Generators are host code (see rule 6).
|
|
@@ -314,3 +455,7 @@ both is the recommendation, so a deleted account takes its inbox with it.
|
|
|
314
455
|
- **A plan model.** `channelPolicy` answers; it does not decide.
|
|
315
456
|
- **An authorization engine.** `audience` answers; it does not decide.
|
|
316
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 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/types.ts"],"sourcesContent":["/**\n * Core types of the channel-agnostic notification system (12-15).\n *\n * Three decoupled layers, each open for extension without touching the others:\n * - GENERATORS map a typed domain event to agnostic content (title/body/…).\n * - The CHANNEL ROUTER always writes the notification-centre inbox record,\n * then fans out one delivery per enabled channel.\n * - TRANSPORTS format the agnostic content for one channel and send it.\n *\n * Nothing here knows about a concrete channel's wire format — that lives\n * entirely inside each transport adapter — and nothing here knows about a\n * concrete DOMAIN either: the event `type` set, the preference categories and\n * the channel list are all host config (see {@link NotificationTaxonomy}).\n */\n\n/** Transport channels a notification can fan out to (DB CHECK mirrors this). */\nexport const NOTIFICATION_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'WEB_PUSH'] as const;\nexport type NotificationChannel = (typeof NOTIFICATION_CHANNELS)[number];\n\n/**\n * The preference categories are the HOST's, and required.\n *\n * There used to be a `NOTIFICATION_CATEGORIES = ['orders','payments','stock',\n * 'system']` here — one product's set — and `taxonomyOf` fell back to it\n * whenever a host passed none. The docstring argued the case itself: \"it is\n * product vocabulary, not machinery\", and then shipped the vocabulary anyway as\n * the default, which is the only part a forgetful host would ever see.\n *\n * The consequence was quiet rather than loud: the settings screen renders four\n * rows a foreign host never chose, its own categories are absent, and every\n * preference a user sets is filed against a taxonomy nothing else in that\n * system uses. Nothing throws, because `category` is deliberately a free string\n * — the packaged migration puts **no CHECK** on it, precisely because a closed\n * set would be wrong for every host but the first. That freedom is what made\n * the default undetectable.\n *\n * `channel` and `status` are different and keep their CHECKs: those ARE this\n * library's own closed sets.\n */\nexport type NotificationCategory = string;\n\n/**\n * Per-channel delivery lifecycle (DB CHECK mirrors this).\n *\n * `SENDING` is the CLAIM: exactly one dispatcher moves a row out of `QUEUED`,\n * so two dispatchers can never both send the same delivery. A row left\n * `SENDING` is a dispatcher that died mid-send, and the sweep reclaims it once\n * it is older than the cutoff.\n *\n * `DEAD` is terminal: the attempt ceiling was reached (or the recipient no\n * longer exists), and no sweep will pick the row up again. Without it a\n * permanently invalid destination is a billed provider call on every sweep,\n * forever, and the sweep's working set only grows.\n */\nexport type DeliveryStatus = 'QUEUED' | 'SENDING' | 'SENT' | 'FAILED' | 'DEAD';\n\n/**\n * Channel-agnostic content a generator produces. This is what the inbox stores\n * verbatim and what every transport's formatter receives — no channel may leak\n * its wire format into it.\n */\nexport interface NotificationContent {\n title: string;\n body: string;\n /** In-app deep link (a same-origin path such as `/orders/123`). */\n link?: string;\n /** Structured extras for consumers that want more than text. */\n data?: Record<string, unknown>;\n}\n\n/** Who receives a notification. `clientId` scopes it to a tenant when set. */\nexport interface NotificationRecipient {\n userId: string;\n clientId?: string;\n}\n\n/**\n * A typed domain event handed to `notify`. `type` selects the registered\n * generator; `payload` is that generator's typed input. Callers never touch\n * channels, formatting, or preferences.\n */\nexport interface NotificationEvent<TPayload = unknown> {\n type: string;\n recipient: NotificationRecipient;\n payload: TPayload;\n}\n\n/**\n * Maps one domain event type to agnostic content. Registered through the\n * server config (or `registerGenerator` for a late arrival); adding a\n * generator never touches existing generators, the router, or any transport\n * (open/closed).\n */\n/**\n * Who the content is being rendered FOR — the reader, at the moment the\n * generator is asked.\n *\n * A notification is stored as rendered TEXT: title and body are columns, so\n * the language is chosen once, when the row is written, and never again. That\n * makes this the only honest place to ask. A generator is registered at BOOT —\n * a host that resolved its words there would pin every future reader to\n * whichever language the process happened to start in, invisibly, because a\n * single-locale host cannot tell the difference.\n *\n * The tag is the RECIPIENT's, never the request's. The person who triggers a\n * notification is routinely not the person who reads it: an invite is sent\n * because an administrator acted and is read by the invitee. Reading\n * `Accept-Language` here would be a bug that only ever surfaces as somebody\n * being told things in a language they do not speak.\n *\n * Absent means \"nobody said\" — a host with one audience, or one that stores no\n * per-user language, populates nothing and every generator answers with its\n * own default exactly as it did before this existed.\n */\nexport interface NotificationGenerateContext {\n readonly locale?: string | null;\n}\n\nexport interface NotificationGenerator<TPayload = unknown> {\n /** The event key, dot-namespaced (\"order.paid\"). One generator per type. */\n type: string;\n /** The preference category the router gates this type's fan-out on. */\n category: NotificationCategory;\n /**\n * Render this event's content for ONE recipient.\n *\n * `context` is OPTIONAL, and that is what keeps every generator written\n * before it working: a one-parameter `generate` is assignable to this\n * signature unchanged. A host that passes nothing is stating a fact — it has\n * no language for this reader — rather than asserting a default.\n */\n generate: (payload: TPayload, context?: NotificationGenerateContext) => NotificationContent;\n}\n\n/**\n * The recipient as a transport sees them: resolved destinations only. Built by\n * the router from the host's contact directory + the push subscriptions this\n * package owns; transports use it to answer\n * {@link NotificationTransport.supports}.\n */\nexport interface TransportRecipient {\n userId: string;\n email: string | null;\n /**\n * The recipient's own language, when the host's contact directory states\n * one. Absent means \"nobody said\" — see {@link NotificationGenerateContext}.\n */\n locale?: string | null;\n /** Phone as the host stores it (transports normalize per provider rules). */\n phone: string | null;\n /** How many active browser push subscriptions the user holds. */\n pushSubscriptionCount: number;\n}\n\n/**\n * One pluggable channel adapter: a FORMATTER (agnostic content → channel\n * message) plus a SENDER. Adding a channel = registering one of these; the\n * router dispatches through the registry and needs no change.\n *\n * `send` resolves on success and THROWS on failure — the router records the\n * error on the delivery row and isolates it from other channels. Sends must be\n * retry-safe: the router may re-dispatch a QUEUED/FAILED delivery.\n */\nexport interface NotificationTransport<TMessage = unknown> {\n channel: NotificationChannel;\n /**\n * Whether this recipient is addressable on this channel right now — the\n * destination exists (e-mail / phone / push subscription) AND the provider\n * is configured. `false` simply skips the channel (no delivery row).\n */\n supports(recipient: TransportRecipient): boolean;\n /** Transform the agnostic content into this channel's message shape. */\n format(content: NotificationContent): TMessage;\n /** Deliver the formatted message to the recipient. Throws on failure. */\n send(message: TMessage, recipient: TransportRecipient): Promise<void>;\n}\n\n/**\n * The host's product vocabulary. Everything below the surface (routing,\n * delivery rows, retries, the wire) is identical for every host; WHICH\n * categories exist and how they are labelled is not.\n */\nexport interface NotificationTaxonomy {\n /** The preference categories, in the order the settings screen lists them. */\n categories: readonly NotificationCategory[];\n}\n\n/**\n * The taxonomy in force. `categories` is REQUIRED — see above.\n *\n * The empty check was already here and stays: an empty list and a missing one\n * are the same mistake, and both now fail at assembly rather than rendering an\n * empty settings screen or somebody else's four rows.\n */\nexport function taxonomyOf(config: {\n categories: readonly NotificationCategory[];\n}): NotificationTaxonomy {\n const categories = config.categories;\n if (!categories || categories.length === 0) {\n throw new Error(\n '@12-apps/notifications: `categories` is required and must not be empty — ' +\n 'the preference categories are the host\\'s product vocabulary.',\n );\n }\n return { categories: [...categories] };\n}\n\n/** The host's logger. Defaults to the console (the @12-apps/jobs precedent). */\nexport interface NotificationLogger {\n info(message: string, ...meta: unknown[]): void;\n error(message: string, ...meta: unknown[]): void;\n}\n"],"mappings":";;;;;AAgBO,IAAM,wBAAwB,CAAC,SAAS,OAAO,YAAY,UAAU;AAkLrE,SAAS,WAAW,QAEF;AACvB,QAAM,aAAa,OAAO;AAC1B,MAAI,CAAC,cAAc,WAAW,WAAW,GAAG;AAC1C,UAAM,IAAI;AAAA,MACR;AAAA,IAEF;AAAA,EACF;AACA,SAAO,EAAE,YAAY,CAAC,GAAG,UAAU,EAAE;AACvC;AAXgB;","names":[]}
|
|
@@ -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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
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"> </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
|