@selvajs/notifications 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Selva VektorNode AG
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # @selvajs/notifications
2
+
3
+ Message templates for Selva's outbound mail. Pure render: data in,
4
+ `OutboundMessage` out. No I/O, no env, no transport.
5
+
6
+ ```ts
7
+ import { renderInviteEmail } from '@selvajs/notifications';
8
+
9
+ const message = renderInviteEmail({ to, acceptUrl, orgName, expiresAt });
10
+ await getNotificationProvider().send(message, log);
11
+ ```
12
+
13
+ ## Where this sits
14
+
15
+ Three pieces, deliberately kept apart:
16
+
17
+ | Piece | Owns | Lives |
18
+ | -------------------------- | ----------------------------- | ------------------- |
19
+ | `INotificationProvider` | putting a message on the wire | `@selvajs/platform` |
20
+ | `SmtpNotificationProvider` | doing that over SMTP | `@selvajs/server` |
21
+ | Templates (this package) | what the message says | here |
22
+
23
+ A template never fetches. It takes resolved values — `orgName`, not `orgId` —
24
+ because a template that can fetch is a template that can fail, and mail is
25
+ rendered on a path where failing is not an option.
26
+
27
+ ## Adding a template
28
+
29
+ Build on `renderLayout` rather than writing a fresh document. The wrapper owns the
30
+ card chrome, type stack, and colours, so mail written by different people at
31
+ different times still reads as coming from one product.
32
+
33
+ Two rules the invite template demonstrates and the tests enforce:
34
+
35
+ - **Both parts carry the payload.** A mail that degrades to plain text must still
36
+ contain the link. If the point of the mail is a URL, the text part needs it.
37
+ - **Everything interpolated goes through `escapeHtml`** — including URLs. An accept
38
+ URL carries a token, and a token can contain characters that close an `href`
39
+ early.
40
+
41
+ Tag the result with its `NotificationKind` (declared in
42
+ `@selvajs/platform/notifications`) so the dispatcher can route it and honour a
43
+ user's per-kind preferences.
44
+
45
+ ## Not published
46
+
47
+ Private on purpose. Everything here is Selva's own content — templates about Selva
48
+ orgs, Selva invites, Selva branding — with one consumer, the Selva app.
49
+
50
+ The extension point is already public and lives elsewhere: `OutboundMessage` and
51
+ `INotificationProvider` ship in `@selvajs/platform`, so a self-hoster can implement
52
+ a transport or hand-build a message without this package existing.
53
+
54
+ **Worth revisiting.** Publish this if someone outside the app needs to render Selva
55
+ mail — most plausibly a self-hoster customising invite or magic-link copy rather
56
+ than replacing it wholesale. Two things should settle first, because both are
57
+ likely to move the exported shape:
58
+
59
+ - `notify()` may change what a template receives (channel, preference metadata,
60
+ resolved-vs-id inputs).
61
+ - The magic-link template lands in the same pass and is the second data point on
62
+ what a template signature wants to look like.
63
+
64
+ Publishing makes every export semver-protected, and that is a bad trade while the
65
+ shape is still moving. Going private → public later is a version bump; going back
66
+ is not. See `plans/features/notifications.md`.
package/dist/html.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Escape text for interpolation into an HTML attribute or text node.
3
+ *
4
+ * One copy on purpose: per-template copies are how one of them ends up missing
5
+ * a case. Every value a template interpolates goes through this, including
6
+ * URLs — an `acceptUrl` carries a token, and a token can contain characters
7
+ * that would otherwise close the `href` early.
8
+ */
9
+ export declare function escapeHtml(s: string): string;
10
+ //# sourceMappingURL=html.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"html.d.ts","sourceRoot":"","sources":["../src/html.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAM5C"}
package/dist/html.js ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Escape text for interpolation into an HTML attribute or text node.
3
+ *
4
+ * One copy on purpose: per-template copies are how one of them ends up missing
5
+ * a case. Every value a template interpolates goes through this, including
6
+ * URLs — an `acceptUrl` carries a token, and a token can contain characters
7
+ * that would otherwise close the `href` early.
8
+ */
9
+ export function escapeHtml(s) {
10
+ return s
11
+ .replace(/&/g, '&')
12
+ .replace(/</g, '&lt;')
13
+ .replace(/>/g, '&gt;')
14
+ .replace(/"/g, '&quot;');
15
+ }
16
+ //# sourceMappingURL=html.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"html.js","sourceRoot":"","sources":["../src/html.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,UAAU,UAAU,CAAC,CAAS;IACnC,OAAO,CAAC;SACN,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC;SACtB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;AAC3B,CAAC"}
@@ -0,0 +1,4 @@
1
+ export { renderInviteEmail, type InviteMailInput } from './templates/invite.js';
2
+ export { escapeHtml } from './html.js';
3
+ export { renderLayout, renderButton, renderUrlFallback, type LayoutInput } from './layout.js';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,KAAK,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAChF,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AACvC,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,iBAAiB,EAAE,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { renderInviteEmail } from './templates/invite.js';
2
+ export { escapeHtml } from './html.js';
3
+ export { renderLayout, renderButton, renderUrlFallback } from './layout.js';
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAwB,MAAM,uBAAuB,CAAC;AAChF,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AACvC,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,iBAAiB,EAAoB,MAAM,aAAa,CAAC"}
@@ -0,0 +1,27 @@
1
+ export interface LayoutInput {
2
+ /** Shown as the mail's heading. Escaped here — pass plain text. */
3
+ heading: string;
4
+ /** Body HTML. Already-escaped markup; templates build this themselves. */
5
+ body: string;
6
+ /** Small print under the rule. Already-escaped markup, omitted when absent. */
7
+ footer?: string;
8
+ }
9
+ /**
10
+ * The frame every Selva mail shares: card chrome, type stack, colours.
11
+ * Templates supply a body; this supplies the look, so mail written by
12
+ * different people at different times still reads as coming from one product.
13
+ *
14
+ * Deliberately plain — no images, no tracking pixel, no external stylesheet.
15
+ * Inline styles with a table-free layout survive the common clients, and mail
16
+ * clients that strip the styling still get readable, ordered content.
17
+ */
18
+ export declare function renderLayout({ heading, body, footer }: LayoutInput): string;
19
+ /** The primary action button. One definition so every mail's call to action matches. */
20
+ export declare function renderButton(href: string, label: string): string;
21
+ /**
22
+ * Fallback for clients that do not make the button clickable. Every mail whose
23
+ * point is a link needs this: a button that does not render leaves the reader
24
+ * with no way to continue.
25
+ */
26
+ export declare function renderUrlFallback(href: string): string;
27
+ //# sourceMappingURL=layout.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"layout.d.ts","sourceRoot":"","sources":["../src/layout.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,WAAW;IAC3B,mEAAmE;IACnE,OAAO,EAAE,MAAM,CAAC;IAChB,0EAA0E;IAC1E,IAAI,EAAE,MAAM,CAAC;IACb,+EAA+E;IAC/E,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,WAAW,GAAG,MAAM,CAkB3E;AAED,wFAAwF;AACxF,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAEhE;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAKtD"}
package/dist/layout.js ADDED
@@ -0,0 +1,43 @@
1
+ import { escapeHtml } from './html.js';
2
+ /**
3
+ * The frame every Selva mail shares: card chrome, type stack, colours.
4
+ * Templates supply a body; this supplies the look, so mail written by
5
+ * different people at different times still reads as coming from one product.
6
+ *
7
+ * Deliberately plain — no images, no tracking pixel, no external stylesheet.
8
+ * Inline styles with a table-free layout survive the common clients, and mail
9
+ * clients that strip the styling still get readable, ordered content.
10
+ */
11
+ export function renderLayout({ heading, body, footer }) {
12
+ return `<!doctype html>
13
+ <html>
14
+ <body style="margin:0;padding:24px;background:#f6f7f9;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#1a1a1a;">
15
+ <div style="max-width:480px;margin:0 auto;background:#ffffff;border:1px solid #e4e6eb;border-radius:12px;padding:32px;">
16
+ <h1 style="margin:0 0 16px;font-size:20px;font-weight:600;">${escapeHtml(heading)}</h1>
17
+ ${body}${footer
18
+ ? `
19
+ <hr style="border:none;border-top:1px solid #e4e6eb;margin:24px 0;" />
20
+ <p style="margin:0;font-size:12px;line-height:1.5;color:#8a8a8a;">
21
+ ${footer}
22
+ </p>`
23
+ : ''}
24
+ </div>
25
+ </body>
26
+ </html>`;
27
+ }
28
+ /** The primary action button. One definition so every mail's call to action matches. */
29
+ export function renderButton(href, label) {
30
+ return `<a href="${escapeHtml(href)}" style="display:inline-block;padding:11px 20px;background:#1a1a1a;color:#ffffff;text-decoration:none;border-radius:8px;font-size:15px;font-weight:500;">${escapeHtml(label)}</a>`;
31
+ }
32
+ /**
33
+ * Fallback for clients that do not make the button clickable. Every mail whose
34
+ * point is a link needs this: a button that does not render leaves the reader
35
+ * with no way to continue.
36
+ */
37
+ export function renderUrlFallback(href) {
38
+ return `<p style="margin:24px 0 0;font-size:13px;line-height:1.5;color:#6b6b6b;">
39
+ Or paste this into your browser:<br />
40
+ <span style="word-break:break-all;color:#4a4a4a;">${escapeHtml(href)}</span>
41
+ </p>`;
42
+ }
43
+ //# sourceMappingURL=layout.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"layout.js","sourceRoot":"","sources":["../src/layout.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAWvC;;;;;;;;GAQG;AACH,MAAM,UAAU,YAAY,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAe;IAClE,OAAO;;;;gEAIwD,UAAU,CAAC,OAAO,CAAC;EACjF,IAAI,GACJ,MAAM;QACL,CAAC,CAAC;;;KAGA,MAAM;OACJ;QACJ,CAAC,CAAC,EACJ;;;QAGO,CAAC;AACT,CAAC;AAED,wFAAwF;AACxF,MAAM,UAAU,YAAY,CAAC,IAAY,EAAE,KAAa;IACvD,OAAO,YAAY,UAAU,CAAC,IAAI,CAAC,4JAA4J,UAAU,CAAC,KAAK,CAAC,MAAM,CAAC;AACxN,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAAC,IAAY;IAC7C,OAAO;;uDAE+C,UAAU,CAAC,IAAI,CAAC;OAChE,CAAC;AACR,CAAC"}
@@ -0,0 +1,17 @@
1
+ import type { OutboundMessage } from '@selvajs/platform/notifications';
2
+ export interface InviteMailInput {
3
+ to: string;
4
+ acceptUrl: string;
5
+ orgName: string;
6
+ /** Display name or email of the admin who minted the invite, when known. */
7
+ invitedBy?: string;
8
+ expiresAt: string;
9
+ }
10
+ /**
11
+ * "An admin invited you to an org" — the mail carrying an invite's accept link.
12
+ *
13
+ * The text part must always carry the URL: a mail that degrades to plain text
14
+ * is still the only way the invitee can accept.
15
+ */
16
+ export declare function renderInviteEmail(input: InviteMailInput): OutboundMessage;
17
+ //# sourceMappingURL=invite.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"invite.d.ts","sourceRoot":"","sources":["../../src/templates/invite.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AAIvE,MAAM,WAAW,eAAe;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;CAClB;AASD;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,eAAe,GAAG,eAAe,CA8BzE"}
@@ -0,0 +1,42 @@
1
+ import { escapeHtml } from '../html.js';
2
+ import { renderButton, renderLayout, renderUrlFallback } from '../layout.js';
3
+ function formatExpiry(iso) {
4
+ const d = new Date(iso);
5
+ return Number.isNaN(d.getTime())
6
+ ? 'soon'
7
+ : d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
8
+ }
9
+ /**
10
+ * "An admin invited you to an org" — the mail carrying an invite's accept link.
11
+ *
12
+ * The text part must always carry the URL: a mail that degrades to plain text
13
+ * is still the only way the invitee can accept.
14
+ */
15
+ export function renderInviteEmail(input) {
16
+ const { to, acceptUrl, orgName, invitedBy } = input;
17
+ const expiry = formatExpiry(input.expiresAt);
18
+ const inviter = invitedBy ? `${invitedBy} invited you` : 'You have been invited';
19
+ const subject = `${inviter} to join ${orgName} on Selva`;
20
+ const text = [
21
+ `${inviter} to join ${orgName} on Selva.`,
22
+ '',
23
+ 'Open this link to accept and set up your account:',
24
+ acceptUrl,
25
+ '',
26
+ `The link expires on ${expiry} and can only be used once.`,
27
+ '',
28
+ "If you weren't expecting this invitation, you can ignore this email."
29
+ ].join('\n');
30
+ const html = renderLayout({
31
+ heading: `Join ${orgName} on Selva`,
32
+ body: ` <p style="margin:0 0 24px;font-size:15px;line-height:1.5;color:#4a4a4a;">
33
+ ${escapeHtml(inviter)} to join <strong>${escapeHtml(orgName)}</strong>. Open the link below to accept and set up your account.
34
+ </p>
35
+ ${renderButton(acceptUrl, 'Accept invitation')}
36
+ ${renderUrlFallback(acceptUrl)}`,
37
+ footer: `This link expires on ${escapeHtml(expiry)} and can only be used once.
38
+ If you weren't expecting this invitation, you can ignore this email.`
39
+ });
40
+ return { kind: 'org.invite', to, subject, text, html };
41
+ }
42
+ //# sourceMappingURL=invite.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"invite.js","sourceRoot":"","sources":["../../src/templates/invite.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AACxC,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAW7E,SAAS,YAAY,CAAC,GAAW;IAChC,MAAM,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,CAAC,CAAC;IACxB,OAAO,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC;QAC/B,CAAC,CAAC,MAAM;QACR,CAAC,CAAC,CAAC,CAAC,kBAAkB,CAAC,OAAO,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,SAAS,EAAE,CAAC,CAAC;AACtF,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,iBAAiB,CAAC,KAAsB;IACvD,MAAM,EAAE,EAAE,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,EAAE,GAAG,KAAK,CAAC;IACpD,MAAM,MAAM,GAAG,YAAY,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;IAC7C,MAAM,OAAO,GAAG,SAAS,CAAC,CAAC,CAAC,GAAG,SAAS,cAAc,CAAC,CAAC,CAAC,uBAAuB,CAAC;IAEjF,MAAM,OAAO,GAAG,GAAG,OAAO,YAAY,OAAO,WAAW,CAAC;IAEzD,MAAM,IAAI,GAAG;QACZ,GAAG,OAAO,YAAY,OAAO,YAAY;QACzC,EAAE;QACF,mDAAmD;QACnD,SAAS;QACT,EAAE;QACF,uBAAuB,MAAM,6BAA6B;QAC1D,EAAE;QACF,sEAAsE;KACtE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAEb,MAAM,IAAI,GAAG,YAAY,CAAC;QACzB,OAAO,EAAE,QAAQ,OAAO,WAAW;QACnC,IAAI,EAAE;KACH,UAAU,CAAC,OAAO,CAAC,oBAAoB,UAAU,CAAC,OAAO,CAAC;;IAE3D,YAAY,CAAC,SAAS,EAAE,mBAAmB,CAAC;IAC5C,iBAAiB,CAAC,SAAS,CAAC,EAAE;QAChC,MAAM,EAAE,wBAAwB,UAAU,CAAC,MAAM,CAAC;wEACoB;KACtE,CAAC,CAAC;IAEH,OAAO,EAAE,IAAI,EAAE,YAAY,EAAE,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;AACxD,CAAC"}
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@selvajs/notifications",
3
+ "version": "0.1.0",
4
+ "description": "Message templates for Selva's outbound notifications — pure render, no transport",
5
+ "license": "MIT",
6
+ "author": "VektorNode",
7
+ "homepage": "https://selva.dev",
8
+ "bugs": "https://github.com/VektorNode/selva/issues",
9
+ "keywords": [
10
+ "selva",
11
+ "notifications",
12
+ "email",
13
+ "templates"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/VektorNode/selva.git",
18
+ "directory": "packages/notifications"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "engines": {
24
+ "node": ">=24.0.0"
25
+ },
26
+ "type": "module",
27
+ "main": "./dist/index.js",
28
+ "types": "./dist/index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "selva-source": "./src/index.ts",
32
+ "types": "./dist/index.d.ts",
33
+ "import": "./dist/index.js"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "!**/__tests__/**",
39
+ "!**/*.test.*",
40
+ "!**/*.spec.*"
41
+ ],
42
+ "dependencies": {
43
+ "@selvajs/platform": "0.19.1"
44
+ },
45
+ "devDependencies": {
46
+ "typescript": "~6.0.3",
47
+ "vitest": "^4.1.10",
48
+ "@selvajs/config": "0.0.4"
49
+ },
50
+ "scripts": {
51
+ "build": "tsc",
52
+ "dev": "tsc --watch",
53
+ "type-check": "tsc --noEmit",
54
+ "test": "vitest run",
55
+ "test:watch": "vitest",
56
+ "lint": "eslint .",
57
+ "lint:types": "eslint . --config eslint.typed.config.js"
58
+ }
59
+ }