@zerotal/notifications 1.7.5 → 1.8.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 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/notifications",
3
- "version": "1.7.5",
3
+ "version": "1.8.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.7.5",
34
- "@zerotal/orm": "1.7.5",
35
- "@zerotal/queue": "1.7.5"
33
+ "@zerotal/core": "1.8.1",
34
+ "@zerotal/orm": "1.8.1",
35
+ "@zerotal/queue": "1.8.1"
36
36
  },
37
37
  "devDependencies": {
38
- "@zerotal/broadcasting": "1.7.5",
38
+ "@zerotal/broadcasting": "1.8.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.",
@@ -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(