@zerotal/notifications 1.10.0 → 1.11.1

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 CHANGED
@@ -8,6 +8,60 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.11.1] — 2026-08-31
12
+
13
+ ### Added
14
+
15
+ - **`MailMessage.header()` and `MailPayload.headers`** — set a header the driver does
16
+ not build itself. `MailPayload` had `to`/`from`/`subject`/`text`/`html`/`cc`/`bcc`/
17
+ `replyTo`/`attachments` and no way to add one, so a team that wanted
18
+ `List-Unsubscribe` had to patch a vendored copy of this package and settled for a
19
+ footer link instead.
20
+
21
+ That is not an equivalent substitute. Gmail and Yahoo draw their native unsubscribe
22
+ control from the header, and a recipient who cannot find a control marks the message
23
+ as spam instead — a judgement that attaches to the sending domain and degrades
24
+ delivery of everything else it sends. Pair it with `List-Unsubscribe-Post` for the
25
+ one-click form both providers now expect.
26
+
27
+ Wired through all three drivers: SMTP writes them into the message, Resend sends
28
+ them as the API's `headers` object, and the log driver prints them, since the reason
29
+ to set one is that a client does something with it and the log driver is where that
30
+ gets checked before anything is sent for real.
31
+
32
+ Names the drivers build themselves are refused rather than sent twice — a second
33
+ `Subject` is an ambiguous message, not an override — and the error names the
34
+ `MailMessage` method to use instead. The list is exported as
35
+ `RESERVED_MAIL_HEADERS`, with `resolveHeaders()` beside it for anyone writing a
36
+ custom transport. Validation runs when the header is set, so the throw carries the
37
+ stack of the code that wrote it rather than of a queue worker three hops away, and
38
+ again in the driver, because a payload can reach one without passing through
39
+ `MailMessage`. CR and LF in a value are folded to a space: left raw they end the
40
+ header and let the remainder be read as further headers, which is how a `Bcc`
41
+ arrives courtesy of whoever supplied a tracking ID.
42
+
43
+ ## [1.11.0] — 2026-08-31
44
+
45
+ ### Changed
46
+
47
+ - **The database channel is built on first use, not at construction.** Registering the
48
+ provider used to open a `DatabaseChannel` against `database.table` — default
49
+ `notifications` — and create that table, whether or not any notification ever routed
50
+ there. An app that never uses the channel now never touches the table, and the
51
+ manager no longer resolves a database connection while it is being constructed.
52
+
53
+ ### Added
54
+
55
+ - **A doctor check for a `notifications` table that is not the framework's.**
56
+ `notifications` is a very ordinary name for a table an app already owns, and one
57
+ app's was a completely different shape — `household_id`, `title`, `body`,
58
+ `action_url`, with an in-app inbox reading it. The only thing between the
59
+ framework's channel and a wrong-shaped insert into that inbox was that no
60
+ notification's `channels()` happened to return `"database"`. A convention holding
61
+ back data corruption is a convention that will lose, so `zt doctor` now says so
62
+ before the first notification does. Silent when the table does not exist yet, which
63
+ is every fresh checkout.
64
+
11
65
  ## [1.10.0] — 2026-08-30
12
66
 
13
67
  ### Fixed
package/api-surface.md CHANGED
@@ -54,6 +54,7 @@ class MailMessage = {
54
54
  embed: (cid: string, attachment: Omit<MailAttachment, 'cid' | 'inline'>) => MailMessage
55
55
  from: (address: AddressInput) => MailMessage
56
56
  greeting: { (text: string): MailMessage; (text: string, style: TextStyle): MailMessage; (build: (t: RichLine) => void): MailMessage;}
57
+ header: (name: string, value: string) => MailMessage
57
58
  html: (content: string) => MailMessage
58
59
  line: { (text: string): MailMessage; (text: string, style: TextStyle): MailMessage; (build: (t: RichLine) => void): MailMessage;}
59
60
  replyTo: (address: AddressInput) => MailMessage
@@ -304,12 +305,16 @@ const NotificationRegistry = { _map: Map<string, NotificationClass>; regis
304
305
 
305
306
  const Notify = NotificationManager
306
307
 
308
+ const RESERVED_MAIL_HEADERS = readonly string[]
309
+
307
310
  function channelStats = () => ChannelStat[]
308
311
 
309
312
  function NotificationConfig = (options?: DeepPartial<NotificationConfigShape>) => NotificationConfigShape
310
313
 
311
314
  function recentDeliveries = () => RecentDelivery[]
312
315
 
316
+ function resolveHeaders = (headers: Record<string, string>) => Record<string, string>
317
+
313
318
  interface ChannelStat = {
314
319
  avgMs: number
315
320
  channel: string
@@ -352,6 +357,7 @@ interface MailPayload = {
352
357
  bcc?: MailAddress[]
353
358
  cc?: MailAddress[]
354
359
  from: MailAddress
360
+ headers?: Record<string, string>
355
361
  html?: string
356
362
  replyTo?: MailAddress
357
363
  subject: string
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/notifications",
3
- "version": "1.10.0",
3
+ "version": "1.11.1",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -30,12 +30,12 @@
30
30
  "typecheck": "tsc --noEmit"
31
31
  },
32
32
  "dependencies": {
33
- "@zerotal/core": "1.10.0",
34
- "@zerotal/orm": "1.10.0",
35
- "@zerotal/queue": "1.10.0"
33
+ "@zerotal/core": "1.11.1",
34
+ "@zerotal/orm": "1.11.1",
35
+ "@zerotal/queue": "1.11.1"
36
36
  },
37
37
  "devDependencies": {
38
- "@zerotal/broadcasting": "1.10.0",
38
+ "@zerotal/broadcasting": "1.11.1",
39
39
  "typescript": "^5.8.0"
40
40
  },
41
41
  "description": "Multi-channel notifications for Zerotal — mail (SMTP/Resend), database, broadcast, Slack, and SMS.",
@@ -23,13 +23,29 @@ export class NotificationManager {
23
23
  private readonly _resolvers = new Map<string, ChannelResolver>();
24
24
  /** Resolved channel instances, memoized per name. */
25
25
  private readonly _channels = new Map<string, NotificationChannel>();
26
- private readonly _db: DatabaseChannel;
26
+ /**
27
+ * The database channel, built on first use rather than at construction.
28
+ *
29
+ * It used to be built in the constructor, which meant every app that registered
30
+ * this provider opened a `DatabaseChannel` against `database.table` — default
31
+ * `notifications` — whether or not any notification ever routed there. `notifications`
32
+ * is a very ordinary name for a table an app already owns, and one app's is a
33
+ * completely different shape: `household_id`, `user_id`, `title`, `body`,
34
+ * `action_url`, with an in-app inbox reading it. The only thing standing between
35
+ * the framework's channel and a wrong-shaped insert into that inbox was that no
36
+ * notification's `channels()` happened to return `"database"`. A convention holding
37
+ * back data corruption is a convention that will lose.
38
+ *
39
+ * Building it lazily does not make the collision safe — `notificationsTableCheck`
40
+ * is what reports that — but it means an app that never uses the channel never
41
+ * touches the table, and it stops the manager resolving a database connection at
42
+ * construction time.
43
+ */
44
+ private _dbChannel: DatabaseChannel | undefined;
27
45
 
28
46
  constructor(private readonly config: NotificationConfigShape) {
29
- this._db = new DatabaseChannel(config.database.table, _getConnection());
30
-
31
47
  this._resolvers.set("mail", () => new MailChannel(config.mail));
32
- this._resolvers.set("database", () => this._db);
48
+ this._resolvers.set("database", () => this.database);
33
49
  this._resolvers.set("broadcast", () => new BroadcastChannel());
34
50
 
35
51
  this._resolvers.set("slack", () => {
@@ -241,7 +257,8 @@ export class NotificationManager {
241
257
 
242
258
  /** Expose the database channel for direct queries (unread, markAsRead). */
243
259
  get database(): DatabaseChannel {
244
- return this._db;
260
+ this._dbChannel ??= new DatabaseChannel(this.config.database.table, _getConnection());
261
+ return this._dbChannel;
245
262
  }
246
263
 
247
264
  private async _dispatch(
@@ -15,6 +15,10 @@ export class LogDriver implements MailDriver {
15
15
  `From: ${message.from.name ? `${message.from.name} <${message.from.address}>` : message.from.address}`,
16
16
  `Subject: ${message.subject}`,
17
17
  ...(message.cc?.length ? [`CC: ${message.cc.map((a) => a.address).join(", ")}`] : []),
18
+ // Printed because the reason to set one is that a mail client does
19
+ // something with it, and the log driver is where that gets checked before
20
+ // anything is sent for real.
21
+ ...Object.entries(message.headers ?? {}).map(([n, v]) => `${n}: ${v}`),
18
22
  ...(message.attachments?.length
19
23
  ? [
20
24
  `Files: ${message.attachments
@@ -36,6 +36,70 @@ export interface MailAttachment {
36
36
  cid?: string;
37
37
  }
38
38
 
39
+ /**
40
+ * Header names a driver builds itself, which a custom header may not set.
41
+ *
42
+ * Two reasons, and both are about a message that arrives wrong rather than one
43
+ * that fails to send. `Content-Type`, `MIME-Version` and
44
+ * `Content-Transfer-Encoding` describe the body a driver is assembling — a second
45
+ * copy makes the structure ambiguous and clients disagree about which to believe.
46
+ * The rest already have a dedicated method on {@link MailMessage}; setting one
47
+ * here would duplicate the header rather than replace it, because the driver has
48
+ * no way to know which was meant.
49
+ *
50
+ * Compared case-insensitively — header names are not case-sensitive, so
51
+ * `content-type` has to be refused as readily as `Content-Type`.
52
+ */
53
+ export const RESERVED_MAIL_HEADERS: readonly string[] = [
54
+ "bcc",
55
+ "cc",
56
+ "content-transfer-encoding",
57
+ "content-type",
58
+ "date",
59
+ "from",
60
+ "message-id",
61
+ "mime-version",
62
+ "reply-to",
63
+ "subject",
64
+ "to",
65
+ ];
66
+
67
+ /**
68
+ * Custom headers, sanitised and checked against {@link RESERVED_MAIL_HEADERS}.
69
+ *
70
+ * @param headers - Header names to values, as written by the caller.
71
+ * @returns The same headers with CR and LF removed from every value.
72
+ * @throws When a name is reserved, or a name is not a legal header token.
73
+ */
74
+ export function resolveHeaders(headers: Record<string, string>): Record<string, string> {
75
+ const out: Record<string, string> = {};
76
+ for (const [name, value] of Object.entries(headers)) {
77
+ const trimmed = name.trim();
78
+ if (RESERVED_MAIL_HEADERS.includes(trimmed.toLowerCase())) {
79
+ throw new Error(
80
+ `[Zerotal/notifications] "${trimmed}" is set by the mail driver and cannot be ` +
81
+ `passed as a custom header — it would be sent twice rather than replaced. ` +
82
+ `Use the MailMessage method for it (.subject(), .from(), .to(), .cc(), .bcc(), ` +
83
+ `.replyTo()) where there is one.`,
84
+ );
85
+ }
86
+ // RFC 5322 field names are printable ASCII except colon. A name carrying a
87
+ // colon, a space or a newline does not become a header — it becomes a way to
88
+ // write the rest of the message.
89
+ if (!/^[!-9;-~]+$/.test(trimmed)) {
90
+ throw new Error(
91
+ `[Zerotal/notifications] "${trimmed}" is not a valid header name — a header name is ` +
92
+ `printable ASCII with no spaces, colons or line breaks.`,
93
+ );
94
+ }
95
+ // CR/LF in a value ends the header and lets the remainder be read as further
96
+ // headers. Stripped rather than refused: unlike the name, a value is often
97
+ // built from content, and folding it to one line keeps the message intact.
98
+ out[trimmed] = value.replace(/[\r\n]+/g, " ").trim();
99
+ }
100
+ return out;
101
+ }
102
+
39
103
  /** The resolved, ready-to-send shape of a single email. */
40
104
  export interface MailPayload {
41
105
  to: MailAddress[];
@@ -47,6 +111,25 @@ export interface MailPayload {
47
111
  bcc?: MailAddress[];
48
112
  replyTo?: MailAddress;
49
113
  attachments?: MailAttachment[];
114
+ /**
115
+ * Extra headers to put on the message, beyond the ones the driver builds.
116
+ *
117
+ * The reason this exists is `List-Unsubscribe`: Gmail and Yahoo draw their
118
+ * native unsubscribe control beside the sender from that header, and there is
119
+ * no way to ask for it other than sending it. A footer link is a different
120
+ * thing in a different place, and bulk senders are required to send the header.
121
+ *
122
+ * Names in {@link RESERVED_MAIL_HEADERS} are refused — see there for why.
123
+ *
124
+ * @example
125
+ * ```ts
126
+ * new MailMessage()
127
+ * .subject("Your weekly digest")
128
+ * .header("List-Unsubscribe", `<https://app.test/unsubscribe/${token}>`)
129
+ * .header("List-Unsubscribe-Post", "List-Unsubscribe=One-Click");
130
+ * ```
131
+ */
132
+ headers?: Record<string, string>;
50
133
  }
51
134
 
52
135
  export interface MailDriver {
@@ -1,4 +1,5 @@
1
1
  import type { MailDriver, MailPayload } from "./MailDriver.ts";
2
+ import { resolveHeaders } from "./MailDriver.ts";
2
3
  import { NotificationDeliveryError } from "../errors.ts";
3
4
 
4
5
  /**
@@ -21,6 +22,12 @@ export class ResendDriver implements MailDriver {
21
22
  if (message.cc?.length) body["cc"] = message.cc.map((a) => a.address);
22
23
  if (message.bcc?.length) body["bcc"] = message.bcc.map((a) => a.address);
23
24
  if (message.replyTo) body["reply_to"] = message.replyTo.address;
25
+ // Resend takes custom headers as an object and sets the rest itself, so the
26
+ // reserved names are refused here too rather than handed to the API to
27
+ // silently drop or duplicate.
28
+ if (message.headers && Object.keys(message.headers).length > 0) {
29
+ body["headers"] = resolveHeaders(message.headers);
30
+ }
24
31
  if (message.html !== undefined) body["html"] = message.html;
25
32
  if (message.text !== undefined) body["text"] = message.text;
26
33
  if (message.attachments?.length) {
@@ -1,4 +1,5 @@
1
1
  import type { MailDriver, MailPayload, MailAddress } from "./MailDriver.ts";
2
+ import { resolveHeaders } from "./MailDriver.ts";
2
3
  import { SmtpResponseError, SmtpConnectionError } from "../errors.ts";
3
4
 
4
5
  /** How long to wait for any single SMTP reply before giving up. */
@@ -616,6 +617,15 @@ function buildRawMessage(msg: MailPayload): string {
616
617
  );
617
618
  lines.push("MIME-Version: 1.0");
618
619
 
620
+ // Re-validated here, not just in `MailMessage.header()`, because a payload can
621
+ // reach a driver without ever passing through the builder — a job deserialising
622
+ // one, or an app constructing a MailPayload literal. Placing them after the
623
+ // headers this function owns would not be enough on its own: a second `Subject`
624
+ // is still a second `Subject`, wherever it sits.
625
+ for (const [name, value] of Object.entries(resolveHeaders(msg.headers ?? {}))) {
626
+ lines.push(`${name}: ${encodeHeaderValue(value)}`);
627
+ }
628
+
619
629
  const attachments = msg.attachments ?? [];
620
630
  const body = renderBody(msg);
621
631
 
package/src/index.ts CHANGED
@@ -38,6 +38,10 @@ export type {
38
38
  MailAttachment,
39
39
  AddressInput,
40
40
  } from "./drivers/MailDriver.ts";
41
+ // Exported for the same reason the drivers above are: writing a custom transport
42
+ // is a supported thing to do, and a custom transport has to refuse the same header
43
+ // names the built-in ones refuse.
44
+ export { RESERVED_MAIL_HEADERS, resolveHeaders } from "./drivers/MailDriver.ts";
41
45
 
42
46
  export { SendNotificationJob } from "./SendNotificationJob.ts";
43
47
  export { BroadcastNotificationJob } from "./BroadcastNotificationJob.ts";
@@ -1,6 +1,7 @@
1
1
  import { tryCurrentApp } from "@zerotal/core";
2
2
  import {
3
3
  resolveAddress,
4
+ resolveHeaders,
4
5
  type AddressInput,
5
6
  type MailAddress,
6
7
  type MailAttachment,
@@ -165,6 +166,7 @@ export class MailMessage {
165
166
  private _from?: MailAddress;
166
167
  private _replyTo?: MailAddress;
167
168
  private _attachments: MailAttachment[] = [];
169
+ private _headers: Record<string, string> = {};
168
170
 
169
171
  subject(text: string): this {
170
172
  this._subject = text;
@@ -254,6 +256,44 @@ export class MailMessage {
254
256
  return this;
255
257
  }
256
258
 
259
+ /**
260
+ * Set a header on the message, beyond the ones the driver builds itself.
261
+ *
262
+ * The header this exists for is `List-Unsubscribe`. Gmail and Yahoo draw their
263
+ * native unsubscribe control — the one beside the sender's name, not the one in
264
+ * your footer — from that header, and there is no other way to ask for it. Bulk
265
+ * senders are required to send it; everyone else benefits from a recipient who
266
+ * unsubscribes instead of reporting spam, because the second one costs the
267
+ * sending domain its reputation and the first does not.
268
+ *
269
+ * Pair it with `List-Unsubscribe-Post` to get the one-click form, which is what
270
+ * both providers now expect: the header alone leaves the recipient a `mailto:`
271
+ * or a link to follow, and only the pair produces a button that resolves in one
272
+ * press.
273
+ *
274
+ * Header names the driver builds itself are refused rather than sent twice —
275
+ * see {@link RESERVED_MAIL_HEADERS}. Calling this twice with the same name
276
+ * replaces the value, since a driver cannot know which of two was meant.
277
+ *
278
+ * @param name - Header name. Printable ASCII, no spaces or colons.
279
+ * @param value - Header value. CR and LF are folded to a space.
280
+ * @throws When the name is reserved or malformed.
281
+ *
282
+ * @example
283
+ * ```ts
284
+ * new MailMessage()
285
+ * .subject("Your weekly digest")
286
+ * .header("List-Unsubscribe", `<https://app.test/unsubscribe/${token}>, <mailto:unsub@app.test>`)
287
+ * .header("List-Unsubscribe-Post", "List-Unsubscribe=One-Click");
288
+ * ```
289
+ */
290
+ header(name: string, value: string): this {
291
+ // Validated here rather than at send time so the throw carries the stack of
292
+ // the code that wrote the header, not of a queue worker three hops away.
293
+ Object.assign(this._headers, resolveHeaders({ [name]: value }));
294
+ return this;
295
+ }
296
+
257
297
  /**
258
298
  * Attach a file to the message.
259
299
  *
@@ -321,6 +361,7 @@ export class MailMessage {
321
361
  ...(this._bcc.length > 0 ? { bcc: this._bcc } : {}),
322
362
  ...(this._replyTo ? { replyTo: this._replyTo } : {}),
323
363
  ...(this._attachments.length > 0 ? { attachments: this._attachments } : {}),
364
+ ...(Object.keys(this._headers).length > 0 ? { headers: this._headers } : {}),
324
365
  };
325
366
  }
326
367
 
@@ -7,6 +7,7 @@ import { installNotificationsObservability } from "../observability.ts";
7
7
  import { installNotificationStats } from "../stats.ts";
8
8
  import { installNotificationsAdmin } from "../admin.ts";
9
9
  import { mailDriverCheck } from "../doctor.ts";
10
+ import { notificationsTableCheck } from "../tableDoctor.ts";
10
11
 
11
12
  declare module "@zerotal/core" {
12
13
  interface ContainerBindings {
@@ -40,6 +41,7 @@ export class NotificationProvider extends ServiceProvider {
40
41
  // `zt doctor`, and through it `zt deploy`: mail going to a log file in
41
42
  // production is the quietest failure this package has.
42
43
  this.app.registerDoctorCheck?.(mailDriverCheck);
44
+ this.app.registerDoctorCheck?.(notificationsTableCheck);
43
45
  }
44
46
 
45
47
  override async onBooted(): Promise<void> {
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The check for a `notifications` table that is not the framework's.
3
+ *
4
+ * `database.table` defaults to `notifications`, which is a very ordinary name for a
5
+ * table an app already owns. One app's was a completely different shape —
6
+ * `household_id`, `user_id`, `title`, `body`, `action_url`, with an in-app inbox
7
+ * reading it — and the only thing standing between the framework's channel and a
8
+ * wrong-shaped insert into that inbox was that no notification's `channels()`
9
+ * happened to return `"database"`.
10
+ *
11
+ * A convention holding back data corruption is a convention that will lose. This
12
+ * says so at `zt doctor` time, before the first notification does.
13
+ *
14
+ * @module
15
+ */
16
+ import type { Application } from "@zerotal/core";
17
+ import type { DoctorCheck, DoctorCheckResult } from "@zerotal/core";
18
+ import { NotificationConfig } from "./config.ts";
19
+ import type { NotificationConfigShape } from "./types.ts";
20
+
21
+ /** Columns `DatabaseChannel` writes on every insert. */
22
+ const REQUIRED = [
23
+ "id",
24
+ "notifiable_type",
25
+ "notifiable_id",
26
+ "type",
27
+ "data",
28
+ "read_at",
29
+ "created_at",
30
+ ] as const;
31
+
32
+ /** The table's column names, or `null` when it does not exist / cannot be read. */
33
+ async function _columnsOf(sql: unknown, table: string): Promise<Set<string> | null> {
34
+ const query = sql as (strings: TemplateStringsArray, ...v: unknown[]) => Promise<unknown[]>;
35
+ try {
36
+ // `pragma_table_info` is the table-valued form, so the name binds as a parameter
37
+ // rather than being concatenated into the statement.
38
+ const rows = (await query`SELECT name FROM pragma_table_info(${table})`) as {
39
+ name?: string;
40
+ }[];
41
+ if (rows.length === 0) return null;
42
+ return new Set(rows.map((r) => String(r.name)));
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Report a configured notifications table whose shape is not the one the channel
50
+ * writes.
51
+ *
52
+ * Silent when the table does not exist yet — that is the normal state before the
53
+ * migration runs, and warning about it would fire on every fresh checkout.
54
+ */
55
+ export const notificationsTableCheck: DoctorCheck = {
56
+ id: "notifications-table",
57
+ label: "Notifications table",
58
+ async run(app: Application): Promise<DoctorCheckResult> {
59
+ let table = "notifications";
60
+ let sql: unknown;
61
+ try {
62
+ const config = app.container.makeSync("config") as { get<T>(k: string, d: T): T };
63
+ table = config.get<NotificationConfigShape>("notifications", NotificationConfig()).database
64
+ .table;
65
+ sql = app.container.makeSync("db");
66
+ } catch {
67
+ return { status: "ok", message: "no database connection to check" };
68
+ }
69
+ if (!sql) return { status: "ok", message: "no database connection to check" };
70
+
71
+ const columns = await _columnsOf(sql, table);
72
+ if (columns === null) {
73
+ // Not created yet, or not SQLite. Either way there is nothing to compare.
74
+ return { status: "ok", message: `${table} not present yet` };
75
+ }
76
+
77
+ const missing = REQUIRED.filter((name) => !columns.has(name));
78
+ if (missing.length === 0) return { status: "ok", message: `${table} matches` };
79
+
80
+ return {
81
+ status: "fail",
82
+ message:
83
+ `The table "${table}" exists but is missing ${missing.join(", ")} — so it is not the ` +
84
+ `table @zerotal/notifications writes, and a notification routed to the "database" ` +
85
+ `channel would insert the wrong shape into it.`,
86
+ fix:
87
+ `Point the framework elsewhere with database.table in config/notifications.ts ` +
88
+ `(e.g. "framework_notifications"), or run the package's migration to create the ` +
89
+ `table it expects.`,
90
+ };
91
+ },
92
+ };