@zerotal/notifications 1.8.1 → 1.10.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.
- package/CHANGELOG.md +65 -0
- package/api-surface.md +4 -4
- package/package.json +5 -5
- package/src/doctor.ts +74 -0
- package/src/drivers/SmtpDriver.ts +157 -10
- package/src/provider/NotificationProvider.ts +5 -0
- package/src/types.ts +9 -7
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,71 @@ follows the Zerotal monorepo's unified versioning.
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [1.10.0] — 2026-08-30
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **SMTP submission on port 587 works.** The STARTTLS upgrade completed its handshake
|
|
16
|
+
and then sent nothing: `upgradeTLS()` returns the new socket while the handshake is
|
|
17
|
+
still in flight, and a write issued in that window is **dropped** — not buffered,
|
|
18
|
+
not an error, gone. The `EHLO` that has to follow STARTTLS went into that gap on
|
|
19
|
+
every send, so the server waited for a command that never came while the client
|
|
20
|
+
waited for a reply that never came, until the read timed out.
|
|
21
|
+
|
|
22
|
+
Everything either side looked healthy, which is what made it expensive: the server
|
|
23
|
+
logged a good TLS session and then a connection lost. Port 465 was unaffected,
|
|
24
|
+
because implicit TLS finishes before `Bun.connect()` resolves and there is no window
|
|
25
|
+
to write into — so mail worked on the port nobody documents, and configuring the 587
|
|
26
|
+
that every provider _does_ document produced silence. No error, no bounce, no log
|
|
27
|
+
line, and password resets that never arrived. `upgradeTLS()` now resolves only once
|
|
28
|
+
the handshake has completed, bounded by the reply timeout.
|
|
29
|
+
|
|
30
|
+
- **TLS certificates are actually verified, on both transports.** `rejectUnauthorized`
|
|
31
|
+
is not enforced by the runtime: Bun reports `authorized: true` for a self-signed
|
|
32
|
+
certificate on `Bun.connect()` and on `upgradeTLS()` alike, whether the flag is set
|
|
33
|
+
or not, and puts the real reason in `authorizationError` beside it. Trusting the
|
|
34
|
+
flag made TLS decorative — the connection was encrypted, and would have accepted
|
|
35
|
+
that encryption from anyone in the network path, which is the one property TLS
|
|
36
|
+
exists to provide. The driver now reads the handshake result itself and fails
|
|
37
|
+
closed; `rejectUnauthorized: false` still accepts a self-signed relay, deliberately.
|
|
38
|
+
|
|
39
|
+
- **The server greeting cannot be lost to a race.** `Bun.connect()` resolves after the
|
|
40
|
+
socket opens, and the 220 can arrive before the `await` returns — the data callback
|
|
41
|
+
reached for a connection object that did not exist yet. Early bytes are now buffered
|
|
42
|
+
and replayed.
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- **A doctor check for a production `mail.driver` of `"log"`.** It is the default, it
|
|
47
|
+
is right in development, and it is the quietest possible production failure: every
|
|
48
|
+
message is written to a log file, nothing reports a problem, and password resets
|
|
49
|
+
stop arriving until someone asks why. `zt doctor` reports it and `zt deploy` refuses
|
|
50
|
+
on it.
|
|
51
|
+
|
|
52
|
+
Two tiers, because the same value means two things. An app with a real
|
|
53
|
+
`mail.from.address` configured `log` by accident, or had it knocked back, and its
|
|
54
|
+
mail is going nowhere — that fails. An app still on the placeholder address probably
|
|
55
|
+
sends no mail at all, and failing its deploy over a setting it never touched would be
|
|
56
|
+
the framework inventing a problem — that warns.
|
|
57
|
+
|
|
58
|
+
### Testing
|
|
59
|
+
|
|
60
|
+
- `SmtpSubmission.test.ts` runs the whole 587 flow against a real STARTTLS peer.
|
|
61
|
+
The existing tests could not: they upgrade into a peer that is already speaking TLS,
|
|
62
|
+
because Bun cannot be a STARTTLS _server_ — and the bug lived in the one step that
|
|
63
|
+
arrangement skips. The fixture peer runs under Node, which can wrap a connected
|
|
64
|
+
socket as a TLS server. The limitation was the harness's, never the client's.
|
|
65
|
+
|
|
66
|
+
## [1.9.0] — 2026-08-29
|
|
67
|
+
|
|
68
|
+
### Documented
|
|
69
|
+
|
|
70
|
+
- **Every promised export is documented.** The `docs-coverage` gate reads `maturity: stable` as a
|
|
71
|
+
promise about a package's exports, and measures how much of that promise is written down. It
|
|
72
|
+
was 798 gaps across the suite; it is now zero. This package's share is covered on its own
|
|
73
|
+
pages — types named, options shapes described, and the decisions behind them recorded where
|
|
74
|
+
somebody looking for them will find them.
|
|
75
|
+
|
|
11
76
|
## [1.8.0] — 2026-08-24
|
|
12
77
|
|
|
13
78
|
### Fixed
|
package/api-surface.md
CHANGED
|
@@ -375,8 +375,8 @@ interface NotificationChannel = {
|
|
|
375
375
|
interface NotificationConfigShape = {
|
|
376
376
|
database: { table: string;}
|
|
377
377
|
mail: MailConfigShape
|
|
378
|
-
slack?: { webhook?: string;}
|
|
379
|
-
sms?: SmsConfigShape
|
|
378
|
+
slack?: { webhook?: string;} | undefined
|
|
379
|
+
sms?: SmsConfigShape | undefined
|
|
380
380
|
}
|
|
381
381
|
|
|
382
382
|
interface NotificationRecord = {
|
|
@@ -415,8 +415,8 @@ interface SlackMessage = {
|
|
|
415
415
|
|
|
416
416
|
interface SmsConfigShape = {
|
|
417
417
|
driver: 'twilio' | 'vonage'
|
|
418
|
-
twilio?: TwilioConfigShape
|
|
419
|
-
vonage?: VonageConfigShape
|
|
418
|
+
twilio?: TwilioConfigShape | undefined
|
|
419
|
+
vonage?: VonageConfigShape | undefined
|
|
420
420
|
}
|
|
421
421
|
|
|
422
422
|
interface SmsMessage = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/notifications",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
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.10.0",
|
|
34
|
+
"@zerotal/orm": "1.10.0",
|
|
35
|
+
"@zerotal/queue": "1.10.0"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"@zerotal/broadcasting": "1.
|
|
38
|
+
"@zerotal/broadcasting": "1.10.0",
|
|
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/doctor.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The deployment check `zt doctor` and `zt deploy` run against the mail config.
|
|
3
|
+
*
|
|
4
|
+
* `mail.driver` defaults to `"log"`, which is right in development and is the
|
|
5
|
+
* quietest possible production failure: every message is written to a log file and
|
|
6
|
+
* nothing anywhere reports a problem. No error, no bounce, no send — just password
|
|
7
|
+
* resets that never arrive, found days later by someone asking why.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
import { deployEnv, isProdLike } from "@zerotal/core";
|
|
12
|
+
import type { Application } from "@zerotal/core";
|
|
13
|
+
import type { DoctorCheck, DoctorCheckResult } from "@zerotal/core";
|
|
14
|
+
import { NotificationConfig } from "./config.ts";
|
|
15
|
+
import type { NotificationConfigShape } from "./types.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The from-address a project that never configured mail still has.
|
|
19
|
+
*
|
|
20
|
+
* It is what tells "this app does not send mail" apart from "this app sends mail
|
|
21
|
+
* and its driver got knocked back to the default", which are the same setting and
|
|
22
|
+
* very different problems. Anyone who set up mail changed this.
|
|
23
|
+
*/
|
|
24
|
+
const PLACEHOLDER_FROM = "hello@example.com";
|
|
25
|
+
|
|
26
|
+
/** The loaded notification config, or the defaults when the app declares none. */
|
|
27
|
+
function _mailConfig(app: Application): NotificationConfigShape["mail"] | undefined {
|
|
28
|
+
try {
|
|
29
|
+
const config = app.container.makeSync("config") as {
|
|
30
|
+
get<T>(key: string, fallback: T): T;
|
|
31
|
+
};
|
|
32
|
+
return config.get<NotificationConfigShape>("notifications", NotificationConfig()).mail;
|
|
33
|
+
} catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Refuse a production release whose mail goes to a log file.
|
|
40
|
+
*
|
|
41
|
+
* Two tiers, because the same value means two things. An app with a real
|
|
42
|
+
* from-address configured `log` by accident — or had it knocked back — and its mail
|
|
43
|
+
* is silently going nowhere: that fails. An app still on the placeholder address
|
|
44
|
+
* probably sends no mail at all, and failing its deploy over a setting it never
|
|
45
|
+
* touched would be the framework inventing a problem: that warns.
|
|
46
|
+
*/
|
|
47
|
+
export const mailDriverCheck: DoctorCheck = {
|
|
48
|
+
id: "mail-driver",
|
|
49
|
+
label: "Mail driver",
|
|
50
|
+
run(app: Application): DoctorCheckResult {
|
|
51
|
+
const mail = _mailConfig(app);
|
|
52
|
+
if (!mail) return { status: "ok", message: "no mail config to check" };
|
|
53
|
+
|
|
54
|
+
if (mail.driver !== "log") {
|
|
55
|
+
return { status: "ok", message: `${mail.driver}` };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!isProdLike(deployEnv())) {
|
|
59
|
+
return { status: "ok", message: "log (fine outside production)" };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const configured = mail.from.address !== PLACEHOLDER_FROM;
|
|
63
|
+
return {
|
|
64
|
+
status: configured ? "fail" : "warn",
|
|
65
|
+
message: configured
|
|
66
|
+
? `mail.driver is "log" in production, but mail.from.address is set to ` +
|
|
67
|
+
`${mail.from.address} — every message this app sends is being written to a ` +
|
|
68
|
+
`log file and delivered to nobody.`
|
|
69
|
+
: `mail.driver is "log" in production. Nothing is delivered. ` +
|
|
70
|
+
`(mail.from.address is still the default, so this app may not send mail at all.)`,
|
|
71
|
+
fix: 'Set mail.driver to "smtp" or "resend" in config/notifications.ts, and its credentials with it.',
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
};
|
|
@@ -19,6 +19,12 @@ const DEFAULT_TIMEOUT_MS = 30_000;
|
|
|
19
19
|
* Every reply is parsed and its status code checked, so a rejected recipient or a
|
|
20
20
|
* failed authentication raises {@link SmtpResponseError} instead of being
|
|
21
21
|
* mistaken for a successful send.
|
|
22
|
+
*
|
|
23
|
+
* Certificate verification is enforced here rather than by the runtime. Bun does
|
|
24
|
+
* not honour `rejectUnauthorized` on either `Bun.connect()` or `upgradeTLS()` — it
|
|
25
|
+
* reports the peer as authorized and puts the real reason beside it — so both
|
|
26
|
+
* transports check the handshake result themselves and fail closed. See
|
|
27
|
+
* `_certificateFailure`.
|
|
22
28
|
*/
|
|
23
29
|
export class SmtpDriver implements MailDriver {
|
|
24
30
|
constructor(
|
|
@@ -142,6 +148,34 @@ interface SmtpReply {
|
|
|
142
148
|
text: string;
|
|
143
149
|
}
|
|
144
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Whether a completed handshake should be treated as a failure.
|
|
153
|
+
*
|
|
154
|
+
* **`rejectUnauthorized` is not enforced by the runtime.** Bun reports
|
|
155
|
+
* `authorized: true` for a self-signed certificate whether the flag is on or off,
|
|
156
|
+
* on `Bun.connect()` and on `upgradeTLS()` alike, and hands back the real reason in
|
|
157
|
+
* `authorizationError` beside it. Trusting the flag made TLS decorative on both
|
|
158
|
+
* paths: the connection was encrypted and would have accepted that encryption from
|
|
159
|
+
* anyone in the network path, which is the property TLS exists to provide.
|
|
160
|
+
*
|
|
161
|
+
* So the error is the signal and the flag is not. Verification is the framework's
|
|
162
|
+
* to enforce, and it fails closed.
|
|
163
|
+
*
|
|
164
|
+
* @param authorized - What the runtime claims about the peer.
|
|
165
|
+
* @param authorizationError - Why it would not have been authorized, if anything.
|
|
166
|
+
* @param rejectUnauthorized - Whether this connection asked for verification.
|
|
167
|
+
* @returns The error to fail with, or `undefined` to accept the peer.
|
|
168
|
+
*/
|
|
169
|
+
function _certificateFailure(
|
|
170
|
+
authorized: boolean,
|
|
171
|
+
authorizationError: Error | null | undefined,
|
|
172
|
+
rejectUnauthorized: boolean,
|
|
173
|
+
): Error | undefined {
|
|
174
|
+
if (!rejectUnauthorized) return undefined;
|
|
175
|
+
if (authorized && !authorizationError) return undefined;
|
|
176
|
+
return authorizationError ?? new Error("the server's certificate could not be verified");
|
|
177
|
+
}
|
|
178
|
+
|
|
145
179
|
/**
|
|
146
180
|
* A single SMTP session — owns the socket, buffers incoming bytes, and hands
|
|
147
181
|
* back one complete reply at a time.
|
|
@@ -210,9 +244,26 @@ export class SmtpConnection {
|
|
|
210
244
|
port: number,
|
|
211
245
|
options: { tls: boolean; rejectUnauthorized: boolean; timeoutMs: number },
|
|
212
246
|
): Promise<SmtpConnection> {
|
|
213
|
-
let
|
|
247
|
+
// The session, once there is one. A box rather than a bare `let`, because the
|
|
248
|
+
// socket callbacks below are installed before it exists and have to see it
|
|
249
|
+
// appear — `Bun.connect()` resolves after the socket is open, so the server's
|
|
250
|
+
// 220 greeting can beat the `await` that produces it.
|
|
251
|
+
const session: { conn?: SmtpConnection } = {};
|
|
252
|
+
|
|
253
|
+
// Bytes that arrive in that window. Dropping the greeting deadlocks the session
|
|
254
|
+
// on its own first read.
|
|
255
|
+
const early: Uint8Array[] = [];
|
|
256
|
+
|
|
257
|
+
let settle: (error?: Error) => void = () => {};
|
|
258
|
+
const verified = options.tls
|
|
259
|
+
? new Promise<void>((resolve, reject) => {
|
|
260
|
+
settle = (error) => (error ? reject(error) : resolve());
|
|
261
|
+
})
|
|
262
|
+
: undefined;
|
|
263
|
+
|
|
264
|
+
let socket: import("bun").Socket<undefined>;
|
|
214
265
|
try {
|
|
215
|
-
|
|
266
|
+
socket = await Bun.connect<undefined>({
|
|
216
267
|
hostname: host,
|
|
217
268
|
port,
|
|
218
269
|
...(options.tls
|
|
@@ -220,14 +271,23 @@ export class SmtpConnection {
|
|
|
220
271
|
: {}),
|
|
221
272
|
socket: {
|
|
222
273
|
// Generation 0: the connection as opened. An upgrade supersedes it.
|
|
223
|
-
data: (_s, data) =>
|
|
224
|
-
|
|
225
|
-
|
|
274
|
+
data: (_s, data) => {
|
|
275
|
+
if (session.conn) session.conn._onData(data, 0);
|
|
276
|
+
else early.push(data);
|
|
277
|
+
},
|
|
278
|
+
error: (_s, err) => {
|
|
279
|
+
settle(err);
|
|
280
|
+
session.conn?._onError(err, 0);
|
|
281
|
+
},
|
|
282
|
+
close: () => {
|
|
283
|
+
settle(new Error("the server closed the connection during the TLS handshake"));
|
|
284
|
+
session.conn?._onClose(0);
|
|
285
|
+
},
|
|
286
|
+
handshake: (_s, authorized, authorizationError) =>
|
|
287
|
+
settle(_certificateFailure(authorized, authorizationError, options.rejectUnauthorized)),
|
|
226
288
|
open: () => {},
|
|
227
289
|
},
|
|
228
290
|
});
|
|
229
|
-
conn = new SmtpConnection(socket, host, port, options.timeoutMs, options.tls);
|
|
230
|
-
return conn;
|
|
231
291
|
} catch (error) {
|
|
232
292
|
throw new SmtpConnectionError(
|
|
233
293
|
`Could not connect to SMTP server ${host}:${port} — ${
|
|
@@ -235,20 +295,78 @@ export class SmtpConnection {
|
|
|
235
295
|
}`,
|
|
236
296
|
);
|
|
237
297
|
}
|
|
298
|
+
|
|
299
|
+
const conn = new SmtpConnection(socket, host, port, options.timeoutMs, options.tls);
|
|
300
|
+
session.conn = conn;
|
|
301
|
+
for (const chunk of early) conn._onData(chunk, 0);
|
|
302
|
+
|
|
303
|
+
// Implicit TLS (465) verifies here, for the same reason STARTTLS verifies in
|
|
304
|
+
// `upgradeTLS`: `rejectUnauthorized` is not enforced by the runtime, so a
|
|
305
|
+
// connection that asked for a verified certificate has to check that it got one.
|
|
306
|
+
if (verified) {
|
|
307
|
+
try {
|
|
308
|
+
await conn._withTimeout(verified, `completing the TLS handshake with ${host}`);
|
|
309
|
+
} catch (error) {
|
|
310
|
+
conn.close();
|
|
311
|
+
throw new SmtpConnectionError(
|
|
312
|
+
`TLS connection to ${host}:${port} failed — ${
|
|
313
|
+
error instanceof Error ? error.message : String(error)
|
|
314
|
+
}`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return conn;
|
|
238
320
|
}
|
|
239
321
|
|
|
240
|
-
/**
|
|
322
|
+
/**
|
|
323
|
+
* Upgrade a plaintext connection to TLS after a 220 response to STARTTLS.
|
|
324
|
+
*
|
|
325
|
+
* Resolves once the handshake has actually completed, and that is the whole
|
|
326
|
+
* point of it being async. `upgradeTLS()` returns the new socket immediately,
|
|
327
|
+
* while the handshake is still in flight, and **a write issued in that window is
|
|
328
|
+
* dropped** — not buffered and flushed, not an error, just gone. The `EHLO` that
|
|
329
|
+
* has to follow STARTTLS was written into that gap, so the server sat waiting
|
|
330
|
+
* for a command that was never sent while the client sat waiting for a reply
|
|
331
|
+
* that was never coming, until the read timed out thirty seconds later.
|
|
332
|
+
*
|
|
333
|
+
* What made it expensive to place is that everything either side looks right.
|
|
334
|
+
* The handshake completes; the server logs a healthy TLS session and then a
|
|
335
|
+
* connection lost. Port 465 is unaffected, because implicit TLS finishes its
|
|
336
|
+
* handshake before `Bun.connect()` resolves and there is no window to write
|
|
337
|
+
* into. So mail worked, on the port that is not the one every provider
|
|
338
|
+
* documents, and configuring the documented 587 produced silence — no error, no
|
|
339
|
+
* bounce, no log line, just password resets that never arrived.
|
|
340
|
+
*
|
|
341
|
+
* @param host - Hostname, used as the TLS server name.
|
|
342
|
+
* @param rejectUnauthorized - Refuse a certificate that does not verify.
|
|
343
|
+
* @throws {@link SmtpConnectionError} When the upgrade or the handshake fails.
|
|
344
|
+
*/
|
|
241
345
|
async upgradeTLS(host: string, rejectUnauthorized: boolean): Promise<void> {
|
|
242
346
|
try {
|
|
243
347
|
// Bumped before the call, so the plaintext socket's callbacks are already
|
|
244
348
|
// stale by the time the handshake can deliver its first record.
|
|
245
349
|
const generation = ++this._generation;
|
|
350
|
+
|
|
351
|
+
let settle!: (error?: Error) => void;
|
|
352
|
+
const handshake = new Promise<void>((resolve, reject) => {
|
|
353
|
+
settle = (error) => (error ? reject(error) : resolve());
|
|
354
|
+
});
|
|
355
|
+
|
|
246
356
|
const [, tls] = this._socket.upgradeTLS<undefined>({
|
|
247
357
|
tls: { rejectUnauthorized, serverName: host },
|
|
248
358
|
socket: {
|
|
249
359
|
data: (_s, data) => this._onData(data, generation),
|
|
250
|
-
error: (_s, err) =>
|
|
251
|
-
|
|
360
|
+
error: (_s, err) => {
|
|
361
|
+
settle(err);
|
|
362
|
+
this._onError(err, generation);
|
|
363
|
+
},
|
|
364
|
+
close: () => {
|
|
365
|
+
settle(new Error("the server closed the connection during the TLS handshake"));
|
|
366
|
+
this._onClose(generation);
|
|
367
|
+
},
|
|
368
|
+
handshake: (_s, authorized, authorizationError) =>
|
|
369
|
+
settle(_certificateFailure(authorized, authorizationError, rejectUnauthorized)),
|
|
252
370
|
open: () => {},
|
|
253
371
|
},
|
|
254
372
|
});
|
|
@@ -258,6 +376,8 @@ export class SmtpConnection {
|
|
|
258
376
|
// belongs to the discarded plaintext stream.
|
|
259
377
|
this._buffer = "";
|
|
260
378
|
this._replies = [];
|
|
379
|
+
|
|
380
|
+
await this._withTimeout(handshake, `completing the TLS handshake with ${host}`);
|
|
261
381
|
} catch (error) {
|
|
262
382
|
throw new SmtpConnectionError(
|
|
263
383
|
`STARTTLS upgrade failed for ${host} — ${
|
|
@@ -267,6 +387,33 @@ export class SmtpConnection {
|
|
|
267
387
|
}
|
|
268
388
|
}
|
|
269
389
|
|
|
390
|
+
/**
|
|
391
|
+
* Reject `work` if it has not settled within the session's reply timeout.
|
|
392
|
+
*
|
|
393
|
+
* A handshake that never completes and never errors is a hang with no reply to
|
|
394
|
+
* time out on, so the read timeout that covers every other step does not cover
|
|
395
|
+
* this one.
|
|
396
|
+
*
|
|
397
|
+
* @param work - The promise to bound.
|
|
398
|
+
* @param what - Named in the timeout message.
|
|
399
|
+
*/
|
|
400
|
+
private async _withTimeout<T>(work: Promise<T>, what: string): Promise<T> {
|
|
401
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
402
|
+
try {
|
|
403
|
+
return await Promise.race([
|
|
404
|
+
work,
|
|
405
|
+
new Promise<never>((_resolve, reject) => {
|
|
406
|
+
timer = setTimeout(
|
|
407
|
+
() => reject(new Error(`timed out after ${this._timeoutMs}ms ${what}`)),
|
|
408
|
+
this._timeoutMs,
|
|
409
|
+
);
|
|
410
|
+
}),
|
|
411
|
+
]);
|
|
412
|
+
} finally {
|
|
413
|
+
if (timer) clearTimeout(timer);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
270
417
|
/** Write a command plus its CRLF terminator. */
|
|
271
418
|
send(command: string): void {
|
|
272
419
|
this.write(`${command}\r\n`);
|
|
@@ -6,6 +6,7 @@ import { NotificationConfig } from "../config.ts";
|
|
|
6
6
|
import { installNotificationsObservability } from "../observability.ts";
|
|
7
7
|
import { installNotificationStats } from "../stats.ts";
|
|
8
8
|
import { installNotificationsAdmin } from "../admin.ts";
|
|
9
|
+
import { mailDriverCheck } from "../doctor.ts";
|
|
9
10
|
|
|
10
11
|
declare module "@zerotal/core" {
|
|
11
12
|
interface ContainerBindings {
|
|
@@ -35,6 +36,10 @@ export class NotificationProvider extends ServiceProvider {
|
|
|
35
36
|
// The admin panel binds its contribution surface during registration, so the
|
|
36
37
|
// booting phase reaches it regardless of provider order.
|
|
37
38
|
installNotificationsAdmin(this.app);
|
|
39
|
+
|
|
40
|
+
// `zt doctor`, and through it `zt deploy`: mail going to a log file in
|
|
41
|
+
// production is the quietest failure this package has.
|
|
42
|
+
this.app.registerDoctorCheck?.(mailDriverCheck);
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
override async onBooted(): Promise<void> {
|
package/src/types.ts
CHANGED
|
@@ -98,8 +98,8 @@ export interface VonageConfigShape {
|
|
|
98
98
|
|
|
99
99
|
export interface SmsConfigShape {
|
|
100
100
|
driver: "twilio" | "vonage";
|
|
101
|
-
twilio?: TwilioConfigShape;
|
|
102
|
-
vonage?: VonageConfigShape;
|
|
101
|
+
twilio?: TwilioConfigShape | undefined;
|
|
102
|
+
vonage?: VonageConfigShape | undefined;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
export interface NotificationConfigShape {
|
|
@@ -117,13 +117,15 @@ export interface NotificationConfigShape {
|
|
|
117
117
|
* The per-notification toSlack() method provides the webhookUrl, so this is
|
|
118
118
|
* only needed if you want a global fallback URL.
|
|
119
119
|
*/
|
|
120
|
-
slack?:
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
120
|
+
slack?:
|
|
121
|
+
| {
|
|
122
|
+
/** Default webhook URL — can be overridden per-notification in toSlack(). */
|
|
123
|
+
webhook?: string;
|
|
124
|
+
}
|
|
125
|
+
| undefined;
|
|
124
126
|
/**
|
|
125
127
|
* SMS provider settings (Twilio or Vonage).
|
|
126
128
|
* Required when any notification uses the 'sms' channel.
|
|
127
129
|
*/
|
|
128
|
-
sms?: SmsConfigShape;
|
|
130
|
+
sms?: SmsConfigShape | undefined;
|
|
129
131
|
}
|