@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 +54 -0
- package/api-surface.md +6 -0
- package/package.json +5 -5
- package/src/NotificationManager.ts +22 -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/src/provider/NotificationProvider.ts +2 -0
- package/src/tableDoctor.ts +92 -0
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.
|
|
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.
|
|
34
|
-
"@zerotal/orm": "1.
|
|
35
|
-
"@zerotal/queue": "1.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
260
|
+
this._dbChannel ??= new DatabaseChannel(this.config.database.table, _getConnection());
|
|
261
|
+
return this._dbChannel;
|
|
245
262
|
}
|
|
246
263
|
|
|
247
264
|
private async _dispatch(
|
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
|
|
|
@@ -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
|
+
};
|