@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 +15 -0
- package/package.json +5 -5
- package/src/drivers/SmtpDriver.ts +46 -10
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.
|
|
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.
|
|
34
|
-
"@zerotal/orm": "1.
|
|
35
|
-
"@zerotal/queue": "1.
|
|
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.
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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(
|