@shaferllc/keel 0.59.0 → 0.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/AGENTS.md +167 -0
  2. package/README.md +30 -1
  3. package/bin/keel-mcp.mjs +9 -0
  4. package/dist/core/application.d.ts +5 -5
  5. package/dist/core/application.js +14 -2
  6. package/dist/core/auth.d.ts +47 -0
  7. package/dist/core/auth.js +77 -0
  8. package/dist/core/authorization.d.ts +9 -1
  9. package/dist/core/authorization.js +22 -2
  10. package/dist/core/cache.d.ts +82 -5
  11. package/dist/core/cache.js +181 -23
  12. package/dist/core/cli/stubs.d.ts +12 -0
  13. package/dist/core/cli/stubs.js +120 -0
  14. package/dist/core/container.d.ts +20 -0
  15. package/dist/core/container.js +52 -0
  16. package/dist/core/cors.d.ts +29 -0
  17. package/dist/core/cors.js +72 -0
  18. package/dist/core/crypto.d.ts +40 -4
  19. package/dist/core/crypto.js +66 -6
  20. package/dist/core/csrf.d.ts +25 -0
  21. package/dist/core/csrf.js +78 -0
  22. package/dist/core/database.d.ts +49 -4
  23. package/dist/core/database.js +89 -21
  24. package/dist/core/events.d.ts +129 -5
  25. package/dist/core/events.js +165 -7
  26. package/dist/core/health.d.ts +141 -0
  27. package/dist/core/health.js +226 -0
  28. package/dist/core/helpers.d.ts +15 -3
  29. package/dist/core/helpers.js +23 -3
  30. package/dist/core/index.d.ts +33 -18
  31. package/dist/core/index.js +16 -8
  32. package/dist/core/lock.d.ts +139 -0
  33. package/dist/core/lock.js +215 -0
  34. package/dist/core/logger.d.ts +82 -4
  35. package/dist/core/logger.js +141 -23
  36. package/dist/core/mail.d.ts +128 -7
  37. package/dist/core/mail.js +264 -16
  38. package/dist/core/model.d.ts +2 -0
  39. package/dist/core/model.js +16 -14
  40. package/dist/core/provider.d.ts +7 -0
  41. package/dist/core/provider.js +7 -0
  42. package/dist/core/queue.d.ts +134 -9
  43. package/dist/core/queue.js +304 -14
  44. package/dist/core/rate-limit.js +3 -0
  45. package/dist/core/relations.js +13 -13
  46. package/dist/core/request.d.ts +26 -0
  47. package/dist/core/request.js +77 -0
  48. package/dist/core/shield.d.ts +39 -0
  49. package/dist/core/shield.js +60 -0
  50. package/dist/core/social.d.ts +173 -0
  51. package/dist/core/social.js +337 -0
  52. package/dist/core/storage.d.ts +159 -6
  53. package/dist/core/storage.js +287 -7
  54. package/dist/core/tokens.d.ts +74 -0
  55. package/dist/core/tokens.js +155 -0
  56. package/dist/db/d1.d.ts +32 -0
  57. package/dist/db/d1.js +26 -0
  58. package/dist/db/libsql.d.ts +29 -0
  59. package/dist/db/libsql.js +32 -0
  60. package/dist/db/pg.d.ts +29 -0
  61. package/dist/db/pg.js +33 -0
  62. package/dist/mcp/server.d.ts +19 -0
  63. package/dist/mcp/server.js +355 -0
  64. package/docs/ai-manifest.json +2472 -0
  65. package/docs/ai.md +128 -0
  66. package/docs/architecture.md +331 -0
  67. package/docs/authentication.md +453 -0
  68. package/docs/authorization.md +167 -0
  69. package/docs/broadcasting.md +137 -0
  70. package/docs/broker.md +500 -0
  71. package/docs/cache.md +558 -0
  72. package/docs/configuration.md +311 -0
  73. package/docs/console.md +356 -0
  74. package/docs/container.md +467 -0
  75. package/docs/controllers.md +265 -0
  76. package/docs/cors.md +51 -0
  77. package/docs/database.md +530 -0
  78. package/docs/debugging.md +129 -0
  79. package/docs/decorators.md +127 -0
  80. package/docs/errors.md +395 -0
  81. package/docs/events.md +496 -0
  82. package/docs/examples/architecture-app.ts +27 -0
  83. package/docs/examples/authentication.ts +61 -0
  84. package/docs/examples/authorization.ts +79 -0
  85. package/docs/examples/broadcasting.ts +60 -0
  86. package/docs/examples/broker-cache-validate.ts +34 -0
  87. package/docs/examples/broker-fault-tolerance.ts +29 -0
  88. package/docs/examples/broker-middleware.ts +27 -0
  89. package/docs/examples/broker.ts +203 -0
  90. package/docs/examples/cache.ts +222 -0
  91. package/docs/examples/configuration.ts +81 -0
  92. package/docs/examples/container.ts +134 -0
  93. package/docs/examples/controllers.ts +86 -0
  94. package/docs/examples/database.ts +118 -0
  95. package/docs/examples/debugging.ts +41 -0
  96. package/docs/examples/decorators.ts +40 -0
  97. package/docs/examples/errors.ts +121 -0
  98. package/docs/examples/events.ts +204 -0
  99. package/docs/examples/factories.ts +84 -0
  100. package/docs/examples/hashing.ts +71 -0
  101. package/docs/examples/health.ts +94 -0
  102. package/docs/examples/helpers.ts +171 -0
  103. package/docs/examples/hooks.ts +54 -0
  104. package/docs/examples/inertia.ts +81 -0
  105. package/docs/examples/locks.ts +120 -0
  106. package/docs/examples/logger.ts +92 -0
  107. package/docs/examples/mail.ts +160 -0
  108. package/docs/examples/middleware.ts +119 -0
  109. package/docs/examples/migrations.ts +126 -0
  110. package/docs/examples/models.ts +239 -0
  111. package/docs/examples/notification.ts +124 -0
  112. package/docs/examples/providers.ts +123 -0
  113. package/docs/examples/queues.ts +254 -0
  114. package/docs/examples/rate-limiting.ts +42 -0
  115. package/docs/examples/redis.ts +99 -0
  116. package/docs/examples/request-response.ts +197 -0
  117. package/docs/examples/routing.ts +186 -0
  118. package/docs/examples/scheduling.ts +62 -0
  119. package/docs/examples/sessions.ts +102 -0
  120. package/docs/examples/static-files.ts +63 -0
  121. package/docs/examples/storage.ts +132 -0
  122. package/docs/examples/templates.ts +58 -0
  123. package/docs/examples/testing.ts +66 -0
  124. package/docs/examples/transformer.ts +141 -0
  125. package/docs/examples/transformers.ts +49 -0
  126. package/docs/examples/url-builder.ts +86 -0
  127. package/docs/examples/validation.ts +102 -0
  128. package/docs/examples/views.tsx +62 -0
  129. package/docs/examples/vite.ts +106 -0
  130. package/docs/factories.md +166 -0
  131. package/docs/getting-started.md +290 -0
  132. package/docs/hashing.md +259 -0
  133. package/docs/health.md +225 -0
  134. package/docs/helpers.md +347 -0
  135. package/docs/hono.md +186 -0
  136. package/docs/hooks.md +118 -0
  137. package/docs/inertia.md +241 -0
  138. package/docs/locks.md +323 -0
  139. package/docs/logger.md +290 -0
  140. package/docs/mail.md +678 -0
  141. package/docs/middleware.md +425 -0
  142. package/docs/migrations.md +476 -0
  143. package/docs/models.md +810 -0
  144. package/docs/notifications.md +474 -0
  145. package/docs/providers.md +363 -0
  146. package/docs/queues.md +679 -0
  147. package/docs/rate-limiting.md +155 -0
  148. package/docs/redis.md +178 -0
  149. package/docs/request-response.md +953 -0
  150. package/docs/routing.md +804 -0
  151. package/docs/scheduling.md +110 -0
  152. package/docs/security.md +85 -0
  153. package/docs/sessions.md +354 -0
  154. package/docs/social-auth.md +174 -0
  155. package/docs/static-files.md +211 -0
  156. package/docs/storage.md +450 -0
  157. package/docs/templates.md +315 -0
  158. package/docs/testing.md +125 -0
  159. package/docs/transformers.md +381 -0
  160. package/docs/url-builder.md +295 -0
  161. package/docs/validation.md +288 -0
  162. package/docs/views.md +267 -0
  163. package/docs/vite.md +434 -0
  164. package/llms-full.txt +17694 -0
  165. package/llms.txt +116 -0
  166. package/package.json +38 -7
@@ -0,0 +1,474 @@
1
+ # Notifications
2
+
3
+ Send a message to a recipient over one or more **channels** — mail, database, or
4
+ your own — inline or through the queue. This is where the mail and queue layers
5
+ compose: a notification declares *what* to say and *which channels* carry it,
6
+ and each channel decides *how*. Edge-safe, like everything under it.
7
+
8
+ ## Defining a notification
9
+
10
+ Subclass `Notification`. `via()` lists the channels; each channel reads from a
11
+ matching method (`toMail`, `toArray`):
12
+
13
+ ```ts
14
+ import { Notification, type Notifiable, type MailContent } from "@shaferllc/keel/core";
15
+
16
+ export class InvoicePaid extends Notification {
17
+ constructor(private amount: number) {
18
+ super();
19
+ }
20
+ via(_notifiable: Notifiable) {
21
+ return ["mail", "database"];
22
+ }
23
+ toMail(): MailContent {
24
+ return { subject: "Payment received", text: `Thanks for $${this.amount}.` };
25
+ }
26
+ toArray() {
27
+ return { amount: this.amount };
28
+ }
29
+ }
30
+ ```
31
+
32
+ Generate one with `keel make:notification InvoicePaid` (→
33
+ `app/Notifications/InvoicePaidNotification.ts`).
34
+
35
+ ## Sending
36
+
37
+ ```ts
38
+ import { notify } from "@shaferllc/keel/core";
39
+
40
+ await notify(user, new InvoicePaid(4200)); // one recipient
41
+ await notify([alice, bob], new InvoicePaid(4200)); // many
42
+ ```
43
+
44
+ A recipient is any object with routing info — usually a `User` model. The mail
45
+ channel routes to `notifiable.email`; override per channel with
46
+ `routeNotificationFor`:
47
+
48
+ ```ts
49
+ class User extends Model {
50
+ static table = "users";
51
+ routeNotificationFor(channel: string) {
52
+ return channel === "mail" ? this.billing_email : undefined;
53
+ }
54
+ }
55
+ ```
56
+
57
+ ## Routing
58
+
59
+ Each channel needs to know *where* a recipient receives it. `routeFor` resolves
60
+ that: it calls the notifiable's `routeNotificationFor(channel)` first, and if
61
+ that returns nothing it falls back to `notifiable.email` for the `mail` channel
62
+ or `notifiable.id` for everything else.
63
+
64
+ ```ts
65
+ import { routeFor } from "@shaferllc/keel/core";
66
+
67
+ routeFor(user, "mail"); // user.email, unless routeNotificationFor overrides it
68
+ routeFor(user, "database"); // user.id
69
+ ```
70
+
71
+ So the common case needs no routing method at all — a `User` with `email` and
72
+ `id` columns just works. Override `routeNotificationFor` only when a channel
73
+ addresses the recipient differently (a billing address, a Slack id, a phone
74
+ number). Return `undefined` from it to fall back to the default.
75
+
76
+ The mail channel throws `Notification: no mail route …` if it can't resolve an
77
+ address — set `email`, implement `routeNotificationFor`, or put a `to` on the
78
+ `MailContent`.
79
+
80
+ ## Channels
81
+
82
+ Register channels on the notifier (typically in a service provider). The `mail`
83
+ channel is registered by default:
84
+
85
+ ```ts
86
+ import { setNotifier, Notifier, DatabaseChannel } from "@shaferllc/keel/core";
87
+
88
+ setNotifier(new Notifier().channel("database", new DatabaseChannel()));
89
+ ```
90
+
91
+ | Channel | Delivers by |
92
+ |---------|-------------|
93
+ | `MailChannel` (`mail`, default) | The mailer, using the notification's `toMail`. Routes to `email`. |
94
+ | `DatabaseChannel` (`database`) | Inserting `toArray` into a table (`type`, `notifiable_id`, `data`). |
95
+ | `ArrayChannel` (`array`) | Collecting deliveries in `.sent` — for tests. |
96
+
97
+ The database channel expects a table (default `notifications`) with `type`,
98
+ `notifiable_id`, and a `data` (JSON) column — create it in a migration. Point it
99
+ at another table by passing the name: `new DatabaseChannel("alerts")`.
100
+
101
+ Delivery walks the channels named by `via()` in order, and each is looked up by
102
+ name. If `via()` names a channel that was never registered, the notifier throws
103
+ `No notification channel "…" registered.` — so register a channel before a
104
+ notification routes to it. Likewise the mail channel throws if the notification
105
+ has no `toMail()`.
106
+
107
+ ## Queued notifications
108
+
109
+ Set `shouldQueue = true` and delivery happens from a queued job instead of on
110
+ the request path — every channel runs inside the job:
111
+
112
+ ```ts
113
+ export class InvoicePaid extends Notification {
114
+ shouldQueue = true;
115
+ // …
116
+ }
117
+
118
+ await notify(user, new InvoicePaid(4200)); // returns immediately; runs on the worker
119
+ ```
120
+
121
+ With the `SyncDriver` (the default queue) it still runs immediately; with a
122
+ `MemoryDriver` or a real broker it's deferred until a worker drains it.
123
+
124
+ ## A custom channel
125
+
126
+ A channel is one method — `send`. That's the seam for SMS, Slack, push, or any
127
+ provider:
128
+
129
+ ```ts
130
+ import type { Channel, Notifiable, Notification } from "@shaferllc/keel/core";
131
+
132
+ const slack: Channel = {
133
+ async send(notifiable, notification) {
134
+ const payload = notification.toArray?.(notifiable) ?? {};
135
+ // POST payload to a Slack webhook via fetch…
136
+ },
137
+ };
138
+ setNotifier(new Notifier().channel("slack", slack));
139
+ ```
140
+
141
+ ## In tests
142
+
143
+ Register an `ArrayChannel` (or assert on the mail `ArrayTransport`) and check
144
+ what was delivered — no network:
145
+
146
+ ```ts
147
+ import { setNotifier, Notifier, ArrayChannel, notify } from "@shaferllc/keel/core";
148
+
149
+ const array = new ArrayChannel();
150
+ setNotifier(new Notifier().channel("array", array));
151
+
152
+ await notify(user, new InvoicePaid(4200)); // a notification whose via() returns ["array"]
153
+
154
+ assert.equal(array.sent.length, 1);
155
+ assert.ok(array.sent[0].notification instanceof InvoicePaid);
156
+ ```
157
+
158
+ ## Related
159
+
160
+ Notifications compose the [mail](./mail.md) and [queue](./queues.md) layers, and
161
+ the database channel writes through the [query builder](./database.md). Reach for
162
+ those directly when you need a one-off email or an ad-hoc queued job.
163
+
164
+ ---
165
+
166
+ ## API reference
167
+
168
+ ### `notify(notifiables, notification)`
169
+
170
+ `notify(notifiables: Notifiable | Notifiable[], notification: Notification): Promise<void>`
171
+
172
+ Sends a notification to one or many recipients through the default notifier.
173
+
174
+ ```ts
175
+ await notify(user, new InvoicePaid(4200));
176
+ await notify([alice, bob], new InvoicePaid(4200));
177
+ ```
178
+
179
+ **Notes:** a thin wrapper over `getNotifier().send(...)`. If the notification's
180
+ `shouldQueue` is `true`, it resolves once the job is *dispatched*, not once
181
+ delivery finishes; otherwise it awaits every channel inline. A single recipient
182
+ is normalized to a one-element list.
183
+
184
+ ### `setNotifier(instance)`
185
+
186
+ `setNotifier(instance: Notifier): Notifier`
187
+
188
+ Replaces the global notifier used by `notify()` and returns it.
189
+
190
+ ```ts
191
+ setNotifier(new Notifier().channel("database", new DatabaseChannel()));
192
+ ```
193
+
194
+ **Notes:** global — the last call wins. Because the default notifier only has the
195
+ `mail` channel, this is how you register `database`, `array`, or custom channels
196
+ app-wide (usually in a service provider).
197
+
198
+ ### `getNotifier()`
199
+
200
+ `getNotifier(): Notifier`
201
+
202
+ Returns the current global notifier — handy for registering a channel without
203
+ swapping the instance.
204
+
205
+ ```ts
206
+ getNotifier().channel("slack", slack);
207
+ ```
208
+
209
+ **Notes:** returns the same instance every call until `setNotifier` replaces it.
210
+
211
+ ### `routeFor(notifiable, channel)`
212
+
213
+ `routeFor(notifiable: Notifiable, channel: string): string | number | undefined`
214
+
215
+ Resolves the address/id a notifiable receives a given channel at.
216
+
217
+ ```ts
218
+ routeFor(user, "mail"); // user.email (string)
219
+ routeFor(user, "database"); // user.id (number)
220
+ ```
221
+
222
+ **Notes:** tries `notifiable.routeNotificationFor(channel)` first (skipped if it
223
+ returns `null`/`undefined`), then falls back to `email` for `"mail"` and `id` for
224
+ any other channel. Returns `undefined` when nothing resolves — channels decide
225
+ whether that's an error.
226
+
227
+ ### `Notification`
228
+
229
+ The abstract base for a notification. Subclass it, list channels from `via()`,
230
+ and add a `to<Channel>()` builder per channel.
231
+
232
+ #### `shouldQueue`
233
+
234
+ `shouldQueue: boolean`
235
+
236
+ Instance flag — set `true` to deliver from a queued job instead of inline.
237
+
238
+ ```ts
239
+ class InvoicePaid extends Notification {
240
+ shouldQueue = true;
241
+ }
242
+ ```
243
+
244
+ **Notes:** defaults to `false`. Read by `Notifier.send`; when `true`, all
245
+ channels run inside the dispatched job.
246
+
247
+ #### `via(notifiable)`
248
+
249
+ `via(notifiable: Notifiable): string[]`
250
+
251
+ Returns the channel names to deliver on for this recipient.
252
+
253
+ ```ts
254
+ via(notifiable: Notifiable) {
255
+ return notifiable.email ? ["mail", "database"] : ["database"];
256
+ }
257
+ ```
258
+
259
+ **Notes:** defaults to `["mail"]`. Called once per recipient, so you can branch
260
+ on the notifiable. Every name it returns must be a registered channel or delivery
261
+ throws.
262
+
263
+ #### `toMail(notifiable)`
264
+
265
+ `toMail?(notifiable: Notifiable): MailContent`
266
+
267
+ Optional. Builds the content the `mail` channel sends. Required if `via()`
268
+ includes `"mail"`.
269
+
270
+ ```ts
271
+ toMail(): MailContent {
272
+ return { subject: "Payment received", text: "Thanks!" };
273
+ }
274
+ ```
275
+
276
+ **Notes:** the mail channel throws if `via()` names `"mail"` but this is
277
+ undefined. Set `to` on the returned `MailContent` to override the resolved
278
+ recipient address.
279
+
280
+ #### `toArray(notifiable)`
281
+
282
+ `toArray?(notifiable: Notifiable): Record<string, unknown>`
283
+
284
+ Optional. Builds the payload the `database` and `array` channels serialize/store.
285
+
286
+ ```ts
287
+ toArray() {
288
+ return { amount: this.amount };
289
+ }
290
+ ```
291
+
292
+ **Notes:** the database channel stores `{}` when it's undefined; the array
293
+ channel keeps the whole notification, not this payload, so a missing `toArray`
294
+ still works in tests.
295
+
296
+ ### `Notifier`
297
+
298
+ Holds the channel registry and drives delivery. `notify()` uses a global one, but
299
+ you can construct your own.
300
+
301
+ #### `channel(name, channel)`
302
+
303
+ `channel(name: string, channel: Channel): this`
304
+
305
+ Registers (or replaces) a channel under a name; returns `this` to chain.
306
+
307
+ ```ts
308
+ new Notifier()
309
+ .channel("database", new DatabaseChannel())
310
+ .channel("array", new ArrayChannel());
311
+ ```
312
+
313
+ **Notes:** a fresh `Notifier` already has `mail` → `MailChannel`. Registering the
314
+ same name again replaces it.
315
+
316
+ #### `send(notifiables, notification)`
317
+
318
+ `send(notifiables: Notifiable | Notifiable[], notification: Notification): Promise<void>`
319
+
320
+ Delivers a notification to one or many recipients across the channels its `via()`
321
+ returns.
322
+
323
+ ```ts
324
+ await new Notifier().send(user, new InvoicePaid(4200));
325
+ ```
326
+
327
+ **Notes:** normalizes a single recipient to a list, then delivers to each in
328
+ order. Honors `notification.shouldQueue` (dispatches to the queue when set).
329
+ Throws on the first unregistered channel name.
330
+
331
+ ### `MailChannel`
332
+
333
+ The default `mail` channel. Registered on every `Notifier`; you rarely construct
334
+ it yourself.
335
+
336
+ #### `send(notifiable, notification)`
337
+
338
+ `send(notifiable: Notifiable, notification: Notification): Promise<void>`
339
+
340
+ Builds a message from `notification.toMail()` and sends it through the mailer.
341
+
342
+ ```ts
343
+ await new MailChannel().send(user, new InvoicePaid(4200));
344
+ ```
345
+
346
+ **Notes:** throws `… has no toMail()` if the notification lacks one, and
347
+ `Notification: no mail route …` if it can't resolve an address (from
348
+ `MailContent.to` or `routeFor(notifiable, "mail")`). Applies `from`, `text`, and
349
+ `html` only when present.
350
+
351
+ ### `DatabaseChannel`
352
+
353
+ The `database` channel. Persists the `toArray` payload through the query builder.
354
+
355
+ #### `new DatabaseChannel(table?)`
356
+
357
+ `new DatabaseChannel(table?: string)`
358
+
359
+ Creates a channel that writes to `table`.
360
+
361
+ ```ts
362
+ new DatabaseChannel(); // → "notifications"
363
+ new DatabaseChannel("alerts"); // → "alerts"
364
+ ```
365
+
366
+ **Notes:** defaults to the `notifications` table.
367
+
368
+ #### `send(notifiable, notification)`
369
+
370
+ `send(notifiable: Notifiable, notification: Notification): Promise<void>`
371
+
372
+ Inserts one row: `type` (the notification's class name), `notifiable_id`
373
+ (`routeFor(notifiable, "database")`, or `null`), and `data` (JSON of `toArray`).
374
+
375
+ ```ts
376
+ await new DatabaseChannel().send(user, new InvoicePaid(4200));
377
+ ```
378
+
379
+ **Notes:** stores `"{}"` for `data` when the notification has no `toArray`. The
380
+ target table must exist — create it in a migration.
381
+
382
+ ### `ArrayChannel`
383
+
384
+ An in-memory channel for tests — records deliveries instead of sending them.
385
+
386
+ #### `sent`
387
+
388
+ `readonly sent: { notifiable: Notifiable; notification: Notification }[]`
389
+
390
+ The log of everything this channel received, in delivery order.
391
+
392
+ ```ts
393
+ const array = new ArrayChannel();
394
+ // … after notify …
395
+ array.sent[0].notification; // the Notification instance
396
+ ```
397
+
398
+ **Notes:** it keeps the notification *instance*, so you can `instanceof`-check it
399
+ or read its fields — no serialization through `toArray`.
400
+
401
+ #### `send(notifiable, notification)`
402
+
403
+ `send(notifiable: Notifiable, notification: Notification): Promise<void>`
404
+
405
+ Pushes `{ notifiable, notification }` onto `sent`. Never touches the network.
406
+
407
+ ```ts
408
+ new Notifier().channel("array", new ArrayChannel());
409
+ ```
410
+
411
+ ### Interfaces & types
412
+
413
+ #### `Notifiable`
414
+
415
+ ```ts
416
+ interface Notifiable {
417
+ routeNotificationFor?(channel: string): string | number | undefined;
418
+ [key: string]: unknown;
419
+ }
420
+ ```
421
+
422
+ A recipient — anything with routing info, most often a `User` model. Implement
423
+ `routeNotificationFor` to steer specific channels; otherwise `routeFor` reads
424
+ `email`/`id` off the index signature.
425
+
426
+ ```ts
427
+ class User extends Model {
428
+ routeNotificationFor(channel: string) {
429
+ return channel === "mail" ? this.billing_email : undefined;
430
+ }
431
+ }
432
+ ```
433
+
434
+ #### `MailContent`
435
+
436
+ ```ts
437
+ interface MailContent {
438
+ subject: string;
439
+ text?: string;
440
+ html?: string;
441
+ from?: string;
442
+ to?: string;
443
+ }
444
+ ```
445
+
446
+ What `toMail()` returns and the `mail` channel consumes. `subject` is required;
447
+ supply `text`, `html`, or both. `to` overrides the resolved recipient; `from`
448
+ overrides the mailer default.
449
+
450
+ ```ts
451
+ toMail(): MailContent {
452
+ return { subject: "Welcome", html: "<h1>Hi</h1>", to: "override@app.com" };
453
+ }
454
+ ```
455
+
456
+ #### `Channel`
457
+
458
+ ```ts
459
+ interface Channel {
460
+ send(notifiable: Notifiable, notification: Notification): Promise<void>;
461
+ }
462
+ ```
463
+
464
+ The seam a custom transport implements — SMS, Slack, push, anything. One method:
465
+ `send`. Register your implementation with `Notifier.channel(name, channel)`.
466
+
467
+ ```ts
468
+ const slack: Channel = {
469
+ async send(notifiable, notification) {
470
+ const payload = notification.toArray?.(notifiable) ?? {};
471
+ // POST payload to a Slack webhook via fetch…
472
+ },
473
+ };
474
+ ```