@zerotal/notifications 1.7.4 → 1.8.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,21 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.8.0] — 2026-08-24
12
+
13
+ ### Fixed
14
+
15
+ - **No mail could be sent over port 587.** A STARTTLS upgrade hands back a new socket and
16
+ leaves the old one attached, still firing its callbacks — and what the old one delivers from
17
+ then on is the undecrypted TLS stream. Both sets of handlers appended to one reply buffer,
18
+ so handshake records and ciphertext sat in the middle of the server's replies and no line
19
+ matched a reply any more: the driver waited out its timeout without ever parsing the `250`,
20
+ and the server logged a connection lost after STARTTLS. `close` and `error` were worse than
21
+ `data` — the plaintext socket ending is a normal part of handing over to TLS, and it marked
22
+ the live connection closed, rejecting whatever was waiting on the session that had just
23
+ replaced it. Callbacks now capture the generation they were installed for and an upgrade
24
+ bumps it, so anything from an older stream is ignored.
25
+
11
26
  ## [1.5.0] — 2026-08-15
12
27
 
13
28
  ### Added
package/api-surface.md CHANGED
@@ -18,21 +18,6 @@ class BroadcastMessage = {
18
18
  readonly data: Record<string, unknown>
19
19
  }
20
20
 
21
- class BroadcastNotificationJob = {
22
- new (_channel?: string, _event?: string, _data?: Record<string, unknown>, queue?: string): BroadcastNotificationJob
23
- static fromPayload: (data: Record<string, unknown>) => BroadcastNotificationJob
24
- _chain: SerializedJob[] | undefined
25
- batchId: string | undefined
26
- className: string
27
- debounceKey: () => string
28
- handle: () => Promise<void>
29
- payload: () => Record<string, unknown>
30
- readonly debounce?: number
31
- readonly maxAttempts: number
32
- readonly queue: string
33
- readonly retryDelay: number
34
- }
35
-
36
21
  class DatabaseChannel = {
37
22
  new (table: string, _sql: SQLInstance): DatabaseChannel
38
23
  _count: () => Promise<number>
@@ -236,62 +221,6 @@ class NotificationSent = {
236
221
  readonly ok: boolean
237
222
  }
238
223
 
239
- class NotificationsPruneCommand = {
240
- new (): NotificationsPruneCommand
241
- static args: ArgDef[]
242
- static commandName: string
243
- static description: string
244
- static flags: FlagDef[]
245
- static needsApp: boolean
246
- _readLine: () => Promise<string>
247
- _writer: OutputWriter
248
- app: unknown
249
- args: Record<string, string>
250
- ask: (question: string, defaultValue?: string) => Promise<string>
251
- choice: (question: string, options: string[]) => Promise<string>
252
- confirm: (question: string, defaultValue?: boolean) => Promise<boolean>
253
- dim: (msg: string) => void
254
- error: (msg: string) => void
255
- flags: Record<string, string | number | boolean>
256
- info: (msg: string) => void
257
- line: (msg: string) => void
258
- newLine: () => void
259
- run: () => Promise<void>
260
- secret: (question: string) => Promise<string>
261
- section: (title: string) => void
262
- table: (rows: [string, string][], indent?: number) => void
263
- warn: (msg: string) => void
264
- write: (msg: string) => void
265
- }
266
-
267
- class NotificationsTestCommand = {
268
- new (): NotificationsTestCommand
269
- static args: ArgDef[]
270
- static commandName: string
271
- static description: string
272
- static flags: FlagDef[]
273
- static needsApp: boolean
274
- _readLine: () => Promise<string>
275
- _writer: OutputWriter
276
- app: unknown
277
- args: Record<string, string>
278
- ask: (question: string, defaultValue?: string) => Promise<string>
279
- choice: (question: string, options: string[]) => Promise<string>
280
- confirm: (question: string, defaultValue?: boolean) => Promise<boolean>
281
- dim: (msg: string) => void
282
- error: (msg: string) => void
283
- flags: Record<string, string | number | boolean>
284
- info: (msg: string) => void
285
- line: (msg: string) => void
286
- newLine: () => void
287
- run: () => Promise<void>
288
- secret: (question: string) => Promise<string>
289
- section: (title: string) => void
290
- table: (rows: [string, string][], indent?: number) => void
291
- warn: (msg: string) => void
292
- write: (msg: string) => void
293
- }
294
-
295
224
  class OnDemandNotifiable = {
296
225
  new (_routes: OnDemandRoutes): OnDemandNotifiable
297
226
  readonly email?: string
@@ -317,21 +246,6 @@ class RichLine = {
317
246
  text: (content: string) => RichLine
318
247
  }
319
248
 
320
- class SendNotificationJob = {
321
- new (_notifiable?: Notifiable | undefined, _notification?: Notification | undefined, serialized?: SerializedNotification): SendNotificationJob
322
- static fromPayload: (data: Record<string, unknown>) => SendNotificationJob
323
- _chain: SerializedJob[] | undefined
324
- batchId: string | undefined
325
- className: string
326
- debounceKey: () => string
327
- handle: () => Promise<void>
328
- payload: () => Record<string, unknown>
329
- readonly debounce?: number
330
- readonly maxAttempts: number
331
- readonly queue: 'notifications'
332
- readonly retryDelay: number
333
- }
334
-
335
249
  class SlackChannel = {
336
250
  new (config?: { webhook?: string; }): SlackChannel
337
251
  send: (notifiable: Notifiable, notification: Notification) => Promise<void>
@@ -396,8 +310,6 @@ function NotificationConfig = (options?: DeepPartial<NotificationConfigShape>) =
396
310
 
397
311
  function recentDeliveries = () => RecentDelivery[]
398
312
 
399
- function validateNotificationConfig = (config: NotificationConfigShape) => void
400
-
401
313
  interface ChannelStat = {
402
314
  avgMs: number
403
315
  channel: string
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/notifications",
3
- "version": "1.7.4",
3
+ "version": "1.8.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.7.4",
34
- "@zerotal/orm": "1.7.4",
35
- "@zerotal/queue": "1.7.4"
33
+ "@zerotal/core": "1.8.0",
34
+ "@zerotal/orm": "1.8.0",
35
+ "@zerotal/queue": "1.8.0"
36
36
  },
37
37
  "devDependencies": {
38
- "@zerotal/broadcasting": "1.7.4",
38
+ "@zerotal/broadcasting": "1.8.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.",
@@ -151,8 +151,16 @@ interface SmtpReply {
151
151
  * coalesced with the next reply. Reading is therefore driven by the protocol's
152
152
  * own framing — a reply ends at the first line whose code is followed by a space
153
153
  * rather than a hyphen — not by packet boundaries.
154
+ *
155
+ * Exported for its tests only. The transport rules it enforces — a superseded
156
+ * socket is not this session's, a reply is framed by the protocol and not by
157
+ * packet boundaries — cannot be reached through `SmtpDriver.send()` without a
158
+ * STARTTLS-capable peer, and Bun cannot be one: server-side TLS upgrade is not
159
+ * supported, so no in-process fake can complete the handshake.
160
+ *
161
+ * @internal
154
162
  */
155
- class SmtpConnection {
163
+ export class SmtpConnection {
156
164
  private _buffer = "";
157
165
  private _replies: SmtpReply[] = [];
158
166
  private _waiters: Array<{
@@ -163,6 +171,25 @@ class SmtpConnection {
163
171
  private _closed = false;
164
172
  private _encrypted: boolean;
165
173
 
174
+ /**
175
+ * Which set of socket callbacks is the live one.
176
+ *
177
+ * `upgradeTLS()` does not detach the plaintext socket: its handlers go on
178
+ * firing, and what they deliver from then on is the *undecrypted* TLS stream —
179
+ * handshake records and ciphertext. Feeding that to `_onData` put binary in the
180
+ * middle of the reply buffer, so the `250` after STARTTLS was never parsed and
181
+ * every send died on the read timeout, with the server logging a connection
182
+ * lost after STARTTLS. `close` and `error` were worse: the superseded socket
183
+ * ending marked the live connection closed and rejected whatever was waiting on
184
+ * it.
185
+ *
186
+ * Each set of callbacks captures the generation it was installed for, and an
187
+ * upgrade bumps it. Anything from an older generation is somebody else's
188
+ * stream. Identity comparison against the current socket would nearly work,
189
+ * but a counter cannot be fooled by a runtime that hands the same object back.
190
+ */
191
+ private _generation = 0;
192
+
166
193
  private constructor(
167
194
  private _socket: import("bun").Socket<undefined>,
168
195
  private readonly _host: string,
@@ -192,9 +219,10 @@ class SmtpConnection {
192
219
  ? { tls: { rejectUnauthorized: options.rejectUnauthorized, serverName: host } }
193
220
  : {}),
194
221
  socket: {
195
- data: (_s, data) => conn._onData(data),
196
- error: (_s, err) => conn._onError(err),
197
- close: () => conn._onClose(),
222
+ // 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),
198
226
  open: () => {},
199
227
  },
200
228
  });
@@ -212,12 +240,15 @@ class SmtpConnection {
212
240
  /** Upgrade a plaintext connection to TLS after a 220 response to STARTTLS. */
213
241
  async upgradeTLS(host: string, rejectUnauthorized: boolean): Promise<void> {
214
242
  try {
243
+ // Bumped before the call, so the plaintext socket's callbacks are already
244
+ // stale by the time the handshake can deliver its first record.
245
+ const generation = ++this._generation;
215
246
  const [, tls] = this._socket.upgradeTLS<undefined>({
216
247
  tls: { rejectUnauthorized, serverName: host },
217
248
  socket: {
218
- data: (_s, data) => this._onData(data),
219
- error: (_s, err) => this._onError(err),
220
- close: () => this._onClose(),
249
+ data: (_s, data) => this._onData(data, generation),
250
+ error: (_s, err) => this._onError(err, generation),
251
+ close: () => this._onClose(generation),
221
252
  open: () => {},
222
253
  },
223
254
  });
@@ -305,7 +336,8 @@ class SmtpConnection {
305
336
  }
306
337
  }
307
338
 
308
- private _onData(data: Uint8Array): void {
339
+ private _onData(data: Uint8Array, generation: number): void {
340
+ if (generation !== this._generation) return; // ciphertext from a superseded socket
309
341
  this._buffer += new TextDecoder().decode(data);
310
342
  this._parse();
311
343
  }
@@ -350,14 +382,18 @@ class SmtpConnection {
350
382
  }
351
383
  }
352
384
 
353
- private _onError(error: Error): void {
385
+ private _onError(error: Error, generation: number): void {
386
+ if (generation !== this._generation) return;
354
387
  this._failure = new SmtpConnectionError(
355
388
  `SMTP socket error on ${this._host}:${this._port} — ${error.message}`,
356
389
  );
357
390
  this._rejectAll(this._failure);
358
391
  }
359
392
 
360
- private _onClose(): void {
393
+ private _onClose(generation: number): void {
394
+ // The plaintext socket ends as a matter of course once TLS takes over, and
395
+ // treating that as the connection dropping killed the session that replaced it.
396
+ if (generation !== this._generation) return;
361
397
  this._closed = true;
362
398
  if (this._waiters.length > 0) {
363
399
  this._rejectAll(