@zerotal/notifications 1.0.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/CHANGELOG.md ADDED
@@ -0,0 +1,69 @@
1
+ # Changelog — @zerotal/notifications
2
+
3
+ All notable changes to this package are documented here. The format is
4
+ based on [Keep a Changelog](https://keepachangelog.com/); this package
5
+ follows the Zerotal monorepo's unified versioning.
6
+
7
+ **Maturity: `beta`**
8
+
9
+ ## [Unreleased]
10
+
11
+ ### Fixed
12
+
13
+ - Queued notifications survive a persistent queue driver. `SendNotificationJob`
14
+ serializes its notifiable and notification, and rebuilds both on the worker
15
+ side; previously only the sync driver worked, and SQLite/Redis delivered a job
16
+ whose recipient and notification were `undefined`.
17
+ - The SMTP driver honours `secure`. It opens a TLS connection for `secure: true`,
18
+ upgrades via STARTTLS when a server offers it, and refuses to send credentials
19
+ over an unencrypted connection unless `allowInsecureAuth` says otherwise.
20
+ - SMTP replies are parsed and their status codes checked, so a rejected
21
+ recipient or failed authentication raises instead of reporting a successful
22
+ send. The reader frames multi-line replies rather than assuming one per packet.
23
+ - Header values are stripped of CR/LF before being written, closing a header
24
+ injection through a notification's subject or a display name. Non-ASCII header
25
+ values are encoded per RFC 2047, and body lines beginning with `.` are
26
+ dot-stuffed so a message is not truncated at its first such line.
27
+ - The database channel records the recipient's own class as `notifiable_type` and
28
+ scopes every read by type and id together, so models sharing an id no longer
29
+ share an inbox.
30
+ - A failing channel no longer cancels the others. Every declared channel is
31
+ attempted and the failures are reported together as `NotificationDispatchError`.
32
+
33
+ ### Added
34
+
35
+ - Custom channels via `NotificationManager.extend(name, factory)`, resolved
36
+ lazily and able to replace a built-in.
37
+ - `channels(notifiable)` receives the recipient, so one notification can route
38
+ per person's preferences, and `routeNotificationFor(channel)` lets a notifiable
39
+ redirect an individual channel.
40
+ - `sendMany` / `queueMany` for many recipients, and `route()` for a destination
41
+ with no model behind it.
42
+ - Mail attachments — `MailMessage.attach()`, `attachFile()`, and `embed()` for
43
+ inline images — carried by the SMTP, Resend, and log drivers.
44
+ - `to*()` methods may return a promise, so building a message can do I/O.
45
+ - Inbox operations: paging on `all()`/`unread()`, `unreadCount()`,
46
+ `markAsUnread()`, `delete()`, `clear()`, `recent()`, and `prune()`. The table
47
+ gains indexes on the recipient lookups.
48
+ - `notifications:prune` and `notifications:test` console commands.
49
+ - An admin panel console showing recent deliveries, per-channel totals, and the
50
+ stored inbox, gated on the `notifications.view` ability.
51
+ - Config validation at boot through `NotificationConfig()` /
52
+ `validateNotificationConfig()`.
53
+ - `NotificationFake` gains `assertSentOn`, `assertQueued`, `assertSentTimes`,
54
+ `sentTo`, an inert `database` accessor, and failure messages listing what was
55
+ actually captured.
56
+
57
+ - `SlackMessage.webhookUrl` is optional; the URL resolves from the message, then
58
+ the notifiable's route, then `slack.webhook` in config.
59
+ - `SmsMessage.to` is optional and defaults to the notifiable's `phone`.
60
+ - `BroadcastMessage.onQueue()` delivers through the queue. `onConnection()` is
61
+ removed — the queue has no connection concept for it to name.
62
+
63
+ ## [1.0.0] — 2026-08-05
64
+
65
+ _First public release._
66
+
67
+ ### Changed
68
+
69
+ - Added a typed error vocabulary (`NotificationError` + `E_NOTIFICATION_*` codes).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zerotal
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,138 @@
1
+ # @zerotal/notifications
2
+
3
+ > Multi-channel notifications — mail, database, broadcast, Slack, and SMS — from a single class.
4
+
5
+ Send the same notification across multiple delivery channels from one class: a `Notification` describes _what_ to send and which `channels()` to deliver on, while the `NotificationManager` routes it to each channel. Includes a `Notifiable` mixin for an object-oriented API and a `NotificationFake` for tests.
6
+
7
+ Part of the [Zerotal](../../README.md) framework. Requires **Bun ≥ 1.3.14**.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ bun add @zerotal/notifications
13
+ ```
14
+
15
+ ## Setup
16
+
17
+ Register the provider in `bootstrap/providers.ts`:
18
+
19
+ ```ts
20
+ import { NotificationProvider } from "@zerotal/notifications";
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ Write a notification by extending `Notification`, declaring `channels()`, then implementing a `to*()` method per channel:
26
+
27
+ ```ts
28
+ import { Notification, MailMessage } from "@zerotal/notifications";
29
+ import type { Notifiable } from "@zerotal/notifications";
30
+
31
+ export class OrderShippedNotification extends Notification {
32
+ constructor(private order: Order) {
33
+ super();
34
+ }
35
+
36
+ channels() {
37
+ return ["mail", "database", "slack"];
38
+ }
39
+
40
+ toMail(_notifiable: Notifiable) {
41
+ return new MailMessage()
42
+ .subject(`Order #${this.order.id} shipped`)
43
+ .line("Your order is on its way.")
44
+ .action("Track package", `https://app.test/orders/${this.order.id}`);
45
+ }
46
+
47
+ toDatabase(_notifiable: Notifiable) {
48
+ return { orderId: this.order.id, status: "shipped" };
49
+ }
50
+
51
+ toSlack(_notifiable: Notifiable) {
52
+ return { text: `Order #${this.order.id} shipped` };
53
+ }
54
+ }
55
+ ```
56
+
57
+ The recipient is passed to `channels()`, so one notification can follow each person's preferences:
58
+
59
+ ```ts
60
+ channels(user: Notifiable) {
61
+ return user.wantsSms ? ["database", "sms"] : ["database", "mail"];
62
+ }
63
+ ```
64
+
65
+ Send via the `Notify` facade or the `Notifiable` mixin:
66
+
67
+ ```ts
68
+ import { Notify } from "@zerotal/notifications";
69
+
70
+ await Notify.send(user, new OrderShippedNotification(order));
71
+ await Notify.sendMany(admins, new LowStockNotification(product));
72
+ await Notify.route({ mail: "ops@acme.test" }).notify(new DeployFinished(build));
73
+ ```
74
+
75
+ ```ts
76
+ import { Notifiable } from "@zerotal/notifications";
77
+ import { AuthUser } from "@zerotal/auth";
78
+
79
+ export class User extends Notifiable(AuthUser) {}
80
+
81
+ await user.notify(new OrderShippedNotification(order)); // send now
82
+ await user.notifyLater(new OrderShippedNotification(order)); // queue
83
+ const unread = await user.unreadNotifications();
84
+ const badge = await user.unreadNotificationCount();
85
+ ```
86
+
87
+ Every declared channel is attempted even if one fails, so a broken Slack webhook never costs the recipient their email. The failures are reported together afterwards as a `NotificationDispatchError`.
88
+
89
+ Real-time delivery: add `"broadcast"` to `channels()` and return a `BroadcastMessage` from `toBroadcast()` (requires `BroadcastProvider`).
90
+
91
+ Add a channel of your own with `extend()`:
92
+
93
+ ```ts
94
+ const notifications = app.container.makeSync("notifications");
95
+ notifications.extend("discord", () => new DiscordChannel(config));
96
+ ```
97
+
98
+ Testing with `NotificationFake`:
99
+
100
+ ```ts
101
+ import { NotificationFake } from "@zerotal/notifications";
102
+
103
+ const notify = NotificationFake.install();
104
+ await triggerShipment(order);
105
+ notify.assertSentTo(user, OrderShippedNotification);
106
+ notify.assertSentOn(user, OrderShippedNotification, "mail");
107
+ notify.assertSentCount(1);
108
+ notify.restore();
109
+ ```
110
+
111
+ ## Console commands
112
+
113
+ - `bun zt notifications:prune --days 30` — delete stored notifications that have been read and are older than the threshold. Pass `--all` to include unread.
114
+ - `bun zt notifications:test you@example.com` — send one real email through the configured mail driver, to check a transport end to end.
115
+
116
+ ## Exports
117
+
118
+ - `Notification` — base class for notifications; declare `channels()` and `to*()` methods.
119
+ - `Notify` — facade for `send` / `sendMany` / `queue` / `route` / `extend`.
120
+ - `Notifiable` — mixin adding `notify`, `notifyLater`, and database-inbox helpers to a model (also the recipient contract type).
121
+ - `NotificationManager` — the manager that routes notifications to channels.
122
+ - `NotificationFake` — in-memory test double with `assertSentTo` / `assertSentOn` / `assertQueued` / `assertSentTimes` / `assertSentCount` / `assertNothingSent`.
123
+ - `NotificationProvider` — service provider registering the manager.
124
+ - `NotificationConfig` / `validateNotificationConfig` — config factory and its boot-time checks.
125
+ - `NotificationRegistry` — registers a notification class for queue rebuilding when it lives outside `app/notifications/`.
126
+ - Channels: `MailChannel`, `DatabaseChannel`, `SlackChannel`, `SmsChannel`, `BroadcastChannel`, plus `BROADCAST_NOTIFICATION_EVENT`.
127
+ - `MailMessage` — fluent email builder with styled lines, a call-to-action, and attachments. `RichLine` builds mixed-style lines.
128
+ - `BroadcastMessage` — payload wrapper for the broadcast channel (supports `.onQueue()`).
129
+ - `OnDemandNotifiable` — recipient addressed directly rather than looked up.
130
+ - Mail drivers: `LogDriver`, `SmtpDriver`, `ResendDriver`, and the `MailDriver` contract.
131
+ - `SendNotificationJob` / `BroadcastNotificationJob` — queued jobs backing `notifyLater` and `onQueue`.
132
+ - `recentDeliveries` / `channelStats` — in-process delivery counters behind the admin console.
133
+ - Types: `Notifiable`, `NotificationChannel`, `NotificationConfigShape`, `SmsConfigShape`, `TwilioConfigShape`, `VonageConfigShape`, `NotificationRecord`, `InboxQuery`, `SlackMessage`, `SmsMessage`, `MailAttachment`, `TextStyle`.
134
+ - Typed error vocabulary re-exported from `./errors`.
135
+
136
+ ## Documentation
137
+
138
+ - [Notifications](../../docs/notifications.md)
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@zerotal/notifications",
3
+ "version": "1.0.0",
4
+ "license": "MIT",
5
+ "maturity": "beta",
6
+ "private": false,
7
+ "type": "module",
8
+ "main": "./src/index.ts",
9
+ "types": "./src/index.ts",
10
+ "exports": {
11
+ ".": "./src/index.ts"
12
+ },
13
+ "files": [
14
+ "CHANGELOG.md",
15
+ "src",
16
+ "!src/**/*.test.ts",
17
+ "!src/**/*.test.tsx",
18
+ "!src/**/*.spec.ts",
19
+ "!src/**/__fixtures__/**"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "engines": {
25
+ "bun": ">=1.3.14"
26
+ },
27
+ "scripts": {
28
+ "test": "bun test",
29
+ "typecheck": "tsc --noEmit"
30
+ },
31
+ "dependencies": {
32
+ "@zerotal/core": "1.0.0",
33
+ "@zerotal/orm": "1.0.0",
34
+ "@zerotal/queue": "1.0.0"
35
+ },
36
+ "devDependencies": {
37
+ "@zerotal/broadcasting": "1.0.0",
38
+ "typescript": "^5.8.0"
39
+ },
40
+ "description": "Multi-channel notifications for Zerotal — mail (SMTP/Resend), database, broadcast, Slack, and SMS.",
41
+ "keywords": [
42
+ "zerotal",
43
+ "bun",
44
+ "typescript",
45
+ "framework",
46
+ "notifications",
47
+ "mail",
48
+ "slack",
49
+ "sms"
50
+ ],
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "git+https://github.com/zerotaldev/zerotal.git",
54
+ "directory": "packages/notifications"
55
+ },
56
+ "homepage": "https://github.com/zerotaldev/zerotal/tree/main/packages/notifications#readme",
57
+ "bugs": "https://github.com/zerotaldev/zerotal/issues"
58
+ }
@@ -0,0 +1,80 @@
1
+ import type { Notifiable } from "./types.ts";
2
+ import type { Notification } from "./Notification.ts";
3
+ import { BroadcastMessage } from "./BroadcastMessage.ts";
4
+ import { NotificationChannelUnavailableError } from "./errors.ts";
5
+
6
+ /** The wire event name used for every broadcast notification; the payload `type` distinguishes them. */
7
+ export const BROADCAST_NOTIFICATION_EVENT = "notification";
8
+
9
+ /**
10
+ * Notification channel that pushes a notification to a connected client in real time via
11
+ * `@zerotal/broadcasting`. Broadcasts on the notifiable's private channel
12
+ * (`private-notifications.{id}` by default) with the event name `"notification"`; the payload
13
+ * carries `{ ...data, id, type, readAt, createdAt }`.
14
+ */
15
+ export class BroadcastChannel {
16
+ async send(notifiable: Notifiable, notification: Notification): Promise<void> {
17
+ // Lazy import so @zerotal/notifications has no hard dependency on @zerotal/broadcasting.
18
+ interface BroadcastApi {
19
+ Broadcast: {
20
+ to(channel: string, event: string, data: unknown): void;
21
+ later?(
22
+ channel: string,
23
+ event: string,
24
+ data: unknown,
25
+ options?: { queue?: string; connection?: string },
26
+ ): Promise<void> | void;
27
+ };
28
+ }
29
+ let broadcasting: BroadcastApi;
30
+ try {
31
+ broadcasting = (await import("@zerotal/broadcasting" as string)) as BroadcastApi;
32
+ } catch (error) {
33
+ // Distinguish "not installed" from "installed but threw on import" — the
34
+ // second reported as the first sends people to fix the wrong thing.
35
+ const detail = error instanceof Error ? error.message : String(error);
36
+ const missing = /cannot find (module|package)|module not found/i.test(detail);
37
+ throw new NotificationChannelUnavailableError(
38
+ missing
39
+ ? "[Zerotal] Notifications broadcast channel requires @zerotal/broadcasting. " +
40
+ "Register BroadcastProvider in bootstrap/providers.ts."
41
+ : `[Zerotal] Notifications broadcast channel could not load @zerotal/broadcasting — ${detail}`,
42
+ );
43
+ }
44
+
45
+ const result = await notification.toBroadcast(notifiable);
46
+ const message = result instanceof BroadcastMessage ? result : new BroadcastMessage(result);
47
+
48
+ const channel = this.channelFor(notifiable);
49
+ const type = notification.broadcastType();
50
+ const payload = {
51
+ ...message.data,
52
+ id: crypto.randomUUID(),
53
+ type,
54
+ readAt: null,
55
+ createdAt: new Date().toISOString(),
56
+ };
57
+
58
+ // A message routed with onQueue() is handed to the queue; anything else goes
59
+ // out inline, which is the point of the channel.
60
+ if (message.queue !== undefined) {
61
+ const { BroadcastNotificationJob } = await import("./BroadcastNotificationJob.ts");
62
+ const { Queue } = await import("@zerotal/queue");
63
+ await Queue.dispatch(
64
+ new BroadcastNotificationJob(channel, BROADCAST_NOTIFICATION_EVENT, payload, message.queue),
65
+ );
66
+ return;
67
+ }
68
+
69
+ broadcasting.Broadcast.to(channel, BROADCAST_NOTIFICATION_EVENT, payload);
70
+ }
71
+
72
+ /** The (prefixed) private channel a notifiable receives broadcast notifications on. */
73
+ channelFor(notifiable: Notifiable): string {
74
+ const custom = (
75
+ notifiable as { receivesBroadcastNotificationsOn?(): string }
76
+ ).receivesBroadcastNotificationsOn?.();
77
+ const name = custom ?? `notifications.${notifiable.id}`;
78
+ return name.startsWith("private-") || name.startsWith("presence-") ? name : `private-${name}`;
79
+ }
80
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The broadcastable representation of a notification, returned from `Notification.toBroadcast()`.
3
+ * Wraps the data payload plus optional queue routing.
4
+ *
5
+ * @example
6
+ * toBroadcast() {
7
+ * return new BroadcastMessage({ invoiceId: this.invoice.id, amount: this.invoice.amount });
8
+ * }
9
+ */
10
+ export class BroadcastMessage {
11
+ /** Queue to deliver this broadcast on. Unset means deliver inline. */
12
+ queue: string | undefined = undefined;
13
+
14
+ constructor(public readonly data: Record<string, unknown>) {}
15
+
16
+ /**
17
+ * Deliver this broadcast from a queue rather than inline.
18
+ *
19
+ * Worth doing when the broadcast fans out to many connections and the
20
+ * request should not wait for it. The trade is latency: a queued broadcast
21
+ * arrives whenever a worker picks it up.
22
+ *
23
+ * @example
24
+ * toBroadcast() {
25
+ * return new BroadcastMessage({ id: this.report.id }).onQueue("broadcasts");
26
+ * }
27
+ */
28
+ onQueue(queue: string): this {
29
+ this.queue = queue;
30
+ return this;
31
+ }
32
+ }
@@ -0,0 +1,51 @@
1
+ import { Job, JobRegistry } from "@zerotal/queue";
2
+
3
+ /**
4
+ * Delivers one broadcast notification from a queue.
5
+ *
6
+ * Dispatched only when a `BroadcastMessage` names a queue or connection via
7
+ * `onQueue()` / `onConnection()`; an unrouted broadcast goes out inline, since
8
+ * the whole point of the channel is immediacy. Its payload is the resolved wire
9
+ * data, so it round-trips through a persistent driver without needing the
10
+ * notification class.
11
+ */
12
+ export class BroadcastNotificationJob extends Job {
13
+ override readonly queue: string;
14
+
15
+ constructor(
16
+ private readonly _channel: string = "",
17
+ private readonly _event: string = "",
18
+ private readonly _data: Record<string, unknown> = {},
19
+ queue = "broadcast",
20
+ ) {
21
+ super();
22
+ this.queue = queue;
23
+ }
24
+
25
+ override payload(): Record<string, unknown> {
26
+ return {
27
+ channel: this._channel,
28
+ event: this._event,
29
+ data: this._data,
30
+ queue: this.queue,
31
+ };
32
+ }
33
+
34
+ static fromPayload(data: Record<string, unknown>): BroadcastNotificationJob {
35
+ return new BroadcastNotificationJob(
36
+ data["channel"] as string,
37
+ data["event"] as string,
38
+ data["data"] as Record<string, unknown>,
39
+ (data["queue"] as string) ?? "broadcast",
40
+ );
41
+ }
42
+
43
+ async handle(): Promise<void> {
44
+ const { Broadcast } = (await import("@zerotal/broadcasting" as string)) as {
45
+ Broadcast: { to(channel: string, event: string, data: unknown): void };
46
+ };
47
+ Broadcast.to(this._channel, this._event, this._data);
48
+ }
49
+ }
50
+
51
+ JobRegistry.register(BroadcastNotificationJob);