@zerotal/notifications 1.11.0 → 1.11.2
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 +32 -0
- package/api-surface.md +6 -0
- package/package.json +5 -5
- package/src/drivers/LogDriver.ts +4 -0
- package/src/drivers/MailDriver.ts +83 -0
- package/src/drivers/ResendDriver.ts +7 -0
- package/src/drivers/SmtpDriver.ts +10 -0
- package/src/index.ts +4 -0
- package/src/messages/MailMessage.ts +41 -0
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.
|
|
3
|
+
"version": "1.11.2",
|
|
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.
|
|
34
|
-
"@zerotal/orm": "1.11.
|
|
35
|
-
"@zerotal/queue": "1.11.
|
|
33
|
+
"@zerotal/core": "1.11.2",
|
|
34
|
+
"@zerotal/orm": "1.11.2",
|
|
35
|
+
"@zerotal/queue": "1.11.2"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"@zerotal/broadcasting": "1.11.
|
|
38
|
+
"@zerotal/broadcasting": "1.11.2",
|
|
39
39
|
"typescript": "^5.8.0"
|
|
40
40
|
},
|
|
41
41
|
"description": "Multi-channel notifications for Zerotal — mail (SMTP/Resend), database, broadcast, Slack, and SMS.",
|
package/src/drivers/LogDriver.ts
CHANGED
|
@@ -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
|
|