@stone-js/notifications 0.8.17

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 © 2026 Stone Foundation
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,318 @@
1
+ # Stone.js - Notifications
2
+
3
+ [![npm license](https://img.shields.io/npm/l/@stone-js/notifications)](https://opensource.org/licenses/MIT)
4
+ [![npm version](https://img.shields.io/npm/v/@stone-js/notifications)](https://www.npmjs.com/package/@stone-js/notifications)
5
+ [![npm downloads](https://img.shields.io/npm/dm/@stone-js/notifications)](https://www.npmjs.com/package/@stone-js/notifications)
6
+ ![Maintenance](https://img.shields.io/maintenance/yes/2026)
7
+ [![CI](https://github.com/stone-foundation/stone-js-framework/actions/workflows/ci.yml/badge.svg)](https://github.com/stone-foundation/stone-js-framework/actions/workflows/ci.yml)
8
+ [![Release](https://github.com/stone-foundation/stone-js-framework/actions/workflows/release.yml/badge.svg)](https://github.com/stone-foundation/stone-js-framework/actions/workflows/release.yml)
9
+ [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=stone-foundation_stone-js-framework&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=stone-foundation_stone-js-framework)
10
+ [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=stone-foundation_stone-js-framework&metric=coverage)](https://sonarcloud.io/summary/new_code?id=stone-foundation_stone-js-framework)
11
+ [![Security Policy](https://img.shields.io/badge/Security-Policy-blue.svg)](https://github.com/stone-foundation/stone-js-framework/blob/main/SECURITY.md)
12
+ [![CodeQL](https://github.com/stone-foundation/stone-js-framework/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/stone-foundation/stone-js-framework/security/code-scanning)
13
+ [![Dependabot Status](https://img.shields.io/badge/Dependabot-enabled-brightgreen.svg)](https://github.com/stone-foundation/stone-js-framework/network/updates)
14
+ [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org)
15
+
16
+ **Notifications for Stone.js.** One declaration reaches a person wherever they are: the mailbox, the
17
+ phone, and the tab they already have open. Delivered out of band, in their own language.
18
+
19
+ **Nobody has to call it.** A module emits what happened; the notice that named that event decides
20
+ who learns about it, through which channels, and in what words. The emitting module imports nothing
21
+ and is never reopened when a channel is added.
22
+
23
+ **Decide now, deliver later.** A request resolves who the person is and hands the delivery to a
24
+ queue. Reaching a mail provider takes as long as it takes, and a request that waits for one is a
25
+ request that times out on the endpoint the user is watching.
26
+
27
+ ---
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ npm install @stone-js/notifications
33
+
34
+ # optional, and each earns its keep:
35
+ npm install @stone-js/queue # deliver out of band rather than in the request
36
+ npm install @stone-js/realtime # reach the tab someone already has open
37
+ npm install @stone-js/i18n # write in the recipient's own language
38
+ npm install nodemailer # the SMTP channel (9.0.1 or later)
39
+ ```
40
+
41
+ > Peer dependency: `@stone-js/core`. Everything else is optional, and the module degrades to
42
+ > something honest without each of them.
43
+
44
+ ## Enable it
45
+
46
+ Declarative:
47
+
48
+ ```ts
49
+ import { StoneApp } from '@stone-js/core'
50
+ import { Notifications } from '@stone-js/notifications'
51
+
52
+ @Notifications({
53
+ default: ['smtp', 'in-app'],
54
+ channels: [{ name: 'smtp', driver: 'smtp', from: 'App <no-reply@example.test>' }]
55
+ })
56
+ @StoneApp({ name: 'app' })
57
+ export class Application {}
58
+ ```
59
+
60
+ Imperative, through `stone.notifications`:
61
+
62
+ ```ts
63
+ import { defineConfig, defineStoneApp } from '@stone-js/core'
64
+ import { notificationsBlueprint } from '@stone-js/notifications'
65
+
66
+ export const App = defineStoneApp({ name: 'app' }, [notificationsBlueprint])
67
+
68
+ export const AppConfig = defineConfig((blueprint) => blueprint.set('stone.notifications', {
69
+ default: ['smtp', 'in-app'],
70
+ channels: [{ name: 'smtp', driver: 'smtp', from: 'App <no-reply@example.test>' }],
71
+ recipients: async (id) => await accounts.contactFor(id)
72
+ }))
73
+ ```
74
+
75
+ With nothing configured, notifications go to the log and say so on first use. Reaching real people
76
+ is a decision, so it is written down: a default that quietly sent real mail would send it from the
77
+ first test run.
78
+
79
+ ## A notice: what someone receives
80
+
81
+ ```ts
82
+ @Notice({
83
+ name: 'guardianship.consent_needed',
84
+ on: 'identity.guardian.invited.v1',
85
+ channels: ['smtp', 'in-app']
86
+ })
87
+ export class ConsentNeeded {
88
+ constructor ({ i18n }) { this.i18n = i18n }
89
+
90
+ // Who learns about it. Required when the notice reacts to an event: the event carries the
91
+ // account, and only the notice knows which field that is.
92
+ recipients (event) { return event.guardianId }
93
+
94
+ // What it says, per channel. Asked once per recipient, so a name in the body is rendered once.
95
+ notify (event, { locale, recipient }) {
96
+ return {
97
+ smtp: {
98
+ subject: this.i18n.t('consent.subject', { lng: locale }),
99
+ body: this.i18n.t('consent.body', { lng: locale, child: event.childHandle })
100
+ },
101
+ 'in-app': { body: this.i18n.t('consent.short', { lng: locale }) }
102
+ }
103
+ }
104
+
105
+ // Optional: the same fact, told once, however many times it arrives.
106
+ dedupe (event) { return event.eventId }
107
+ }
108
+ ```
109
+
110
+ **The decorator carries metadata; the class carries content.** There is no `content` option, and
111
+ that is deliberate: text in a decorator is text that cannot be translated, formatted, or read off
112
+ the event. The class is built through the container, so it asks for whatever it needs.
113
+
114
+ The imperative form says the same thing:
115
+
116
+ ```ts
117
+ blueprint.set('stone.notifications.notices', [
118
+ defineNotice(ConsentNeeded, { name: 'guardianship.consent_needed', on: 'identity.guardian.invited.v1' })
119
+ ])
120
+ ```
121
+
122
+ ### Nobody calls the notifier
123
+
124
+ ```
125
+ identity emits -> identity.guardian.invited.v1 -> the notice that named it
126
+ renders, chooses its channels, delivers
127
+ ```
128
+
129
+ The notice subscribes through the light key router, the same one `@stone-js/event-bus` routes domain
130
+ events through, so `identity` imports nothing. Add a channel later and only the notice changes.
131
+
132
+ **A body per channel matters.** A text message is not an email: one has a subject and room to
133
+ explain, the other has a hundred and sixty characters. A channel the notice says nothing about falls
134
+ back to the declared template rather than sending an empty body.
135
+
136
+ **The nature of the message decides its channels**, not the caller. A service inviting a guardian has
137
+ no reason to know whether that goes by mail or by text.
138
+
139
+ ## Tell someone something
140
+
141
+ ```ts
142
+ export class GuardianshipService {
143
+ constructor (private readonly notifier) {}
144
+
145
+ async invite (guardianId: string, child: string) {
146
+ await this.notifier.notify(guardianId, 'guardianship.consent_needed', { child })
147
+ }
148
+ }
149
+ ```
150
+
151
+ A **template key**, never a rendered body. What is not copied does not have to be erased, and a
152
+ message queued before a translation was fixed goes out fixed.
153
+
154
+ The recipient can be the person, or their id when a directory can resolve one:
155
+
156
+ ```ts
157
+ await notifier.notify({ id: 'u1', email: 'a@example.test', locale: 'fr' }, 'welcome')
158
+ await notifier.notify(['u1', 'u2'], 'edition.opened', { edition: 'Spring' })
159
+ await notifier.notify(user, 'welcome', {}, { channels: ['in-app'], inline: true })
160
+ ```
161
+
162
+ ## What this module decides, and what it does not
163
+
164
+ It decides **who learns what, through which channel, and in which language**.
165
+
166
+ It never decides **whether** to send. Consent, preferences, quiet hours and audiences are yours,
167
+ because the rules that matter there are about your own people: a framework imposing them would be
168
+ wrong for the first application whose rules differ. Make that call, then call here.
169
+
170
+ ## Channels
171
+
172
+ | Channel | What it is |
173
+ |---|---|
174
+ | `log` | The zero-config default. Writes the message where you write everything else and **reaches nobody**. Right in development and in tests, and not a channel anywhere else. |
175
+ | `in-app` | Broadcasts on the recipient's own realtime channel, so an open tab receives it. Needs `@stone-js/realtime`. |
176
+ | `smtp` | Email over SMTP, through `nodemailer`. SMTP rather than a provider's API, so one channel reaches all of them without this package choosing a vendor for you. |
177
+
178
+ `sms` and `push` are not shipped, deliberately: a channel that picked a vendor would be wrong for
179
+ everyone who chose a different one. Register yours, and it is a channel like any other:
180
+
181
+ ```ts
182
+ channels: [{ name: 'sms', factory: () => new TwilioChannel(twilio) }]
183
+ ```
184
+
185
+ Or as a class the container builds, which is how it gets its dependencies:
186
+
187
+ ```ts
188
+ @NotificationChannel('sms')
189
+ export class TwilioChannel {
190
+ readonly name = 'sms'
191
+
192
+ constructor ({ twilio }) { this.twilio = twilio }
193
+
194
+ async send (message, recipient) {
195
+ if (recipient.phone === undefined) {
196
+ return { status: 'unreachable', retryable: false, reason: 'No phone number.' }
197
+ }
198
+ await this.twilio.messages.create({ to: recipient.phone, body: message.body })
199
+ return { status: 'sent' }
200
+ }
201
+ }
202
+ ```
203
+
204
+ ### The port a channel implements
205
+
206
+ `send(message, recipient)` **returns** an outcome and does not throw, for everything it can foresee.
207
+ The outcome says whether another attempt could work: a provider being down is retryable, an address
208
+ that does not exist never will be, and retrying that forever is how a queue fills with work that
209
+ cannot succeed.
210
+
211
+ A channel that throws anyway is treated as retryable, because a throw is an adapter bug rather than
212
+ a verdict.
213
+
214
+ ## The language a message is written in
215
+
216
+ The **recipient's**, never the request's. A French-speaking guardian invited by an English-speaking
217
+ member of staff reads French.
218
+
219
+ Keys are looked up in `@stone-js/i18n` under `<key>.subject` and `<key>.body`, in that person's
220
+ locale. Without a catalogue, declare templates in configuration:
221
+
222
+ ```ts
223
+ templates: {
224
+ 'guardianship.consent_needed': {
225
+ subject: 'Your consent is needed',
226
+ body: 'Please confirm for {{ child }}.'
227
+ }
228
+ }
229
+ ```
230
+
231
+ A missing translation renders its **key**, never an empty string: an empty subject looks like a
232
+ broken mail client and gets ignored for months, while `guardianship.consent_needed` is visibly ours
233
+ and gets reported the same day.
234
+
235
+ ## Delivery is out of band
236
+
237
+ With `@stone-js/queue` enabled, `notify()` resolves the recipient, decides the channels and dispatches
238
+ a job. A worker performs the delivery, running the same code the inline path runs, so a retry means
239
+ exactly what the first attempt meant.
240
+
241
+ Without a queue, delivery happens in the request, which is right for development and says so when it
242
+ was not what you asked for.
243
+
244
+ `notify()` never throws at its caller. A notification is almost always a side effect of something
245
+ that already succeeded, and failing that operation because a mail provider was down would undo work
246
+ that was correct.
247
+
248
+ ## Configuration
249
+
250
+ | Key | Meaning |
251
+ |---|---|
252
+ | `channels` | The channels this application configures. |
253
+ | `default` | The channels a notification uses when it names none. Defaults to `['log']`. |
254
+ | `recipients` | How to turn an id into a person. The one thing this module cannot ship. |
255
+ | `templates` | Templates, for an application with no translation catalogue. |
256
+ | `dispatch` | `queue` or `inline`. Defaults to `queue` when a queue is enabled. |
257
+ | `queue` / `attempts` | Which queue, and how many retries. |
258
+ | `notices` | Notices declared in configuration rather than with `@Notice`. Both are read. |
259
+ | `dedupe` | `{ ttl, store }`. Keys live in `@stone-js/cache`, so this module stores nothing of its own. |
260
+ | `announce` | Emit `notification.delivered` and `notification.failed` on the bus. On when a bus is enabled. |
261
+
262
+ ## The same message twice
263
+
264
+ The most common production failure of any notification system: a queue is at-least-once, a retry
265
+ half succeeded, or two events describe one fact. Name the occurrence and the repeat is dropped:
266
+
267
+ ```ts
268
+ await notifier.notify(user, 'welcome', {}, { dedupe: `welcome:${user.id}` })
269
+ ```
270
+
271
+ A notice states its own through `dedupe(event)`. Keys are claimed atomically in the cache store the
272
+ application already chose. Without the cache module, deduplication does not happen and says so once:
273
+ sending twice in silence is the failure it exists to prevent.
274
+
275
+ ## Who received what
276
+
277
+ This module keeps no delivery ledger, because the answer to "why did they never receive it" belongs
278
+ in whatever the application already queries. It announces instead:
279
+
280
+ ```ts
281
+ @BusHandler()
282
+ export class NotificationLedger {
283
+ @OnBusEvent('notification.failed')
284
+ onFailed (event) { /* ... write your own row */ }
285
+ }
286
+ ```
287
+
288
+ The event carries the notice, the channel, the outcome, whether it is worth retrying, and the
289
+ recipient's **id**, never their address: an address in an event is an address in every log that
290
+ event passes through.
291
+
292
+ ## Seeing it before sending it
293
+
294
+ ```ts
295
+ const previewed = await notifier.preview(guardian, 'guardianship.consent_needed', { child: 'Lea' })
296
+ // [{ recipient, channel: 'smtp', message: { subject, body, locale } }, ...]
297
+ ```
298
+
299
+ Exactly what delivery would render, per channel, with nothing sent. For the screen that shows a
300
+ member of staff what a guardian is about to receive, and for a test that checks a notice without a
301
+ channel.
302
+
303
+ ## Later rather than now
304
+
305
+ ```ts
306
+ await notifier.notify(user, 'trial.ending', {}, { delay: 86_400 })
307
+ ```
308
+
309
+ Deferred by the queue, because a timer held in a process a cold start can end is not a reminder.
310
+
311
+ ## Documentation
312
+
313
+ See the [official documentation](https://stonejs.dev/docs/extensions/notifications) for the full
314
+ guide.
315
+
316
+ ## License
317
+
318
+ [MIT](./LICENSE)
@@ -0,0 +1,44 @@
1
+ import { IBlueprint, IContainer } from '@stone-js/core';
2
+ import { NoticeDeclaration, NoticeInstance } from './declarations.js';
3
+ /**
4
+ * Finds the notice a name or an event refers to, and builds it.
5
+ *
6
+ * Built for one event, like everything else in the container: it is a lookup over what the blueprint
7
+ * declared, and holds nothing between events. A notice class is built through the container, so it
8
+ * gets its services, and it is the container that decides whether that instance is shared.
9
+ */
10
+ export declare class NoticeRegistry {
11
+ private readonly blueprint;
12
+ private readonly container?;
13
+ /**
14
+ * @param dependencies - Auto-wired services.
15
+ */
16
+ constructor({ blueprint, container }: {
17
+ blueprint: IBlueprint;
18
+ container?: IContainer;
19
+ });
20
+ /** Every notice this application declared, from `@Notice` and from configuration alike. */
21
+ all(): NoticeDeclaration[];
22
+ /**
23
+ * What was declared under this name.
24
+ *
25
+ * @param name - The notice's name.
26
+ * @returns The declaration, or nothing.
27
+ */
28
+ declaration(name: string): NoticeDeclaration | undefined;
29
+ /**
30
+ * What reacts to this event.
31
+ *
32
+ * @param event - The domain event's name.
33
+ * @returns The declaration, or nothing.
34
+ */
35
+ forEvent(event: string): NoticeDeclaration | undefined;
36
+ /**
37
+ * The notice itself, built.
38
+ *
39
+ * @param declaration - What was declared.
40
+ * @returns The notice.
41
+ * @throws {NotificationConfigurationError} When it cannot be built, or does not answer `notify`.
42
+ */
43
+ build(declaration: NoticeDeclaration): NoticeInstance;
44
+ }
@@ -0,0 +1,49 @@
1
+ import { NotificationChannel } from './declarations.js';
2
+ /**
3
+ * Holds the configured channels and hands one out by name.
4
+ *
5
+ * The same shape every driver-based module in the framework uses, and the same lifetime: built for
6
+ * one event, like the container it belongs to. It is a registry of factories, so rebuilding it costs
7
+ * nothing. Nothing here holds state between events; a channel that needs a connection holds it
8
+ * itself, because a connection is a resource and the channel is the boundary that owns it.
9
+ */
10
+ export declare class NotificationManager {
11
+ private static current?;
12
+ private readonly channels;
13
+ private readonly factories;
14
+ /**
15
+ * @returns A manager.
16
+ */
17
+ static create(): NotificationManager;
18
+ /** Publish the manager, so code outside the container can reach it. */
19
+ static setInstance(manager?: NotificationManager): void;
20
+ /** The published manager, if there is one. */
21
+ static getInstance(): NotificationManager | undefined;
22
+ /**
23
+ * Register a built channel.
24
+ *
25
+ * @param channel - The channel.
26
+ * @returns This manager.
27
+ */
28
+ register(channel: NotificationChannel): this;
29
+ /**
30
+ * Register a channel to be built on first use.
31
+ *
32
+ * @param name - The name notifications refer to it by.
33
+ * @param factory - How to build it.
34
+ * @returns This manager.
35
+ */
36
+ registerFactory(name: string, factory: () => NotificationChannel): this;
37
+ /** Whether a channel is registered under this name. */
38
+ has(name: string): boolean;
39
+ /** The names of every registered channel. */
40
+ names(): string[];
41
+ /**
42
+ * The channel a notification named.
43
+ *
44
+ * @param name - The channel's name.
45
+ * @returns The channel.
46
+ * @throws {NotificationConfigurationError} When nothing is registered under that name.
47
+ */
48
+ channel(name: string): NotificationChannel;
49
+ }
@@ -0,0 +1,40 @@
1
+ import { IContainer, IServiceProvider, Promiseable } from '@stone-js/core';
2
+ /**
3
+ * Binds the channels and the notifier.
4
+ *
5
+ * Everything is built for this event, like the rest of the container. A channel holding a connection
6
+ * holds it itself, because a connection is a resource and the channel is the boundary that owns it;
7
+ * nothing here keeps state between events.
8
+ */
9
+ export declare class NotificationServiceProvider implements IServiceProvider {
10
+ private readonly container;
11
+ constructor(container: IContainer);
12
+ register(): Promiseable<void>;
13
+ /**
14
+ * Register one configured channel, lazily: a transport is built when first used, not at boot, so an
15
+ * application configured for production does not need a mail server to start locally.
16
+ *
17
+ * @param manager - The registry to register into.
18
+ * @param config - What the application declared.
19
+ */
20
+ private registerChannel;
21
+ /**
22
+ * A declared channel class, built.
23
+ *
24
+ * @param config - What the application declared.
25
+ * @returns The channel.
26
+ * @throws {NotificationConfigurationError} When the class cannot be built.
27
+ */
28
+ private build;
29
+ /**
30
+ * The builder for a driver this package ships.
31
+ *
32
+ * @param driver - The driver's name.
33
+ * @returns The factory, or nothing when the name is not one of ours.
34
+ */
35
+ private driverFor;
36
+ /** The realtime broadcaster, when one is bound. */
37
+ private broadcaster;
38
+ /** The logger, when one is bound. */
39
+ private logger;
40
+ }