@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 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.8.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.8.1",
34
- "@zerotal/orm": "1.8.1",
35
- "@zerotal/queue": "1.8.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.8.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 conn!: SmtpConnection;
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
- const socket = await Bun.connect<undefined>({
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) => conn._onData(data, 0),
224
- error: (_s, err) => conn._onError(err, 0),
225
- close: () => conn._onClose(0),
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
- /** Upgrade a plaintext connection to TLS after a 220 response to STARTTLS. */
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) => this._onError(err, generation),
251
- close: () => this._onClose(generation),
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
- /** Default webhook URL — can be overridden per-notification in toSlack(). */
122
- webhook?: string;
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
  }