@zerotal/notifications 1.11.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,38 @@ 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
+
11
43
  ## [1.11.0] — 2026-08-31
12
44
 
13
45
  ### Changed
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.11.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.11.0",
34
- "@zerotal/orm": "1.11.0",
35
- "@zerotal/queue": "1.11.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.11.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.",
@@ -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