imapflow 1.2.14 → 1.2.15
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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/lib/imap-flow.js +20 -24
- package/package.json +1 -1
- package/test/connection-edge-cases-test.js +8 -16
- package/test/unhandled-rejection-test.js +150 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.2.15](https://github.com/postalsys/imapflow/compare/v1.2.14...v1.2.15) (2026-03-16)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* replace setImmediate with synchronous rejection in close() ([8807221](https://github.com/postalsys/imapflow/commit/8807221b2b3bae55848129b6a4b8d08880ae5de8))
|
|
9
|
+
|
|
3
10
|
## [1.2.14](https://github.com/postalsys/imapflow/compare/v1.2.13...v1.2.14) (2026-03-15)
|
|
4
11
|
|
|
5
12
|
|
package/lib/imap-flow.js
CHANGED
|
@@ -585,7 +585,7 @@ class ImapFlow extends EventEmitter {
|
|
|
585
585
|
});
|
|
586
586
|
|
|
587
587
|
// Prevent unhandled promise rejection if close() rejects this request
|
|
588
|
-
//
|
|
588
|
+
// synchronously before the caller's handler is attached. The rejection
|
|
589
589
|
// still propagates normally to the caller's await/.catch().
|
|
590
590
|
promise.catch(noop);
|
|
591
591
|
|
|
@@ -1728,7 +1728,7 @@ class ImapFlow extends EventEmitter {
|
|
|
1728
1728
|
});
|
|
1729
1729
|
|
|
1730
1730
|
// Prevent unhandled promise rejection if close() rejects the connect
|
|
1731
|
-
// promise
|
|
1731
|
+
// promise synchronously. The rejection still propagates to the caller.
|
|
1732
1732
|
connectPromise.catch(noop);
|
|
1733
1733
|
|
|
1734
1734
|
await connectPromise;
|
|
@@ -1784,9 +1784,10 @@ class ImapFlow extends EventEmitter {
|
|
|
1784
1784
|
this.initialReject = false;
|
|
1785
1785
|
let err = new Error('Unexpected close');
|
|
1786
1786
|
err.code = `ClosedAfterConnect${this.secureConnection ? 'TLS' : 'Text'}`;
|
|
1787
|
-
//
|
|
1788
|
-
//
|
|
1789
|
-
|
|
1787
|
+
// Synchronous rejection is safe: connectPromise.catch(noop) is already
|
|
1788
|
+
// attached, so the rejection is observed immediately. close() is synchronous,
|
|
1789
|
+
// so all cleanup completes before any microtask rejection handler runs.
|
|
1790
|
+
reject(err);
|
|
1790
1791
|
}
|
|
1791
1792
|
|
|
1792
1793
|
if (typeof this.preCheck === 'function') {
|
|
@@ -1828,32 +1829,27 @@ class ImapFlow extends EventEmitter {
|
|
|
1828
1829
|
return error;
|
|
1829
1830
|
};
|
|
1830
1831
|
|
|
1831
|
-
// Reject pending requests
|
|
1832
|
-
//
|
|
1833
|
-
//
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
});
|
|
1832
|
+
// Reject pending requests and locks synchronously. Each exec() and
|
|
1833
|
+
// getMailboxLock() promise already has .catch(noop) attached, so the
|
|
1834
|
+
// rejection is observed immediately and will not trigger
|
|
1835
|
+
// unhandledRejection. close() is synchronous, so all remaining cleanup
|
|
1836
|
+
// runs before any microtask rejection handler fires.
|
|
1837
|
+
let byeReason = this.byeReason;
|
|
1838
|
+
|
|
1839
|
+
for (let request of pendingRequests) {
|
|
1840
|
+
request.reject(createNoConnectionError(byeReason));
|
|
1841
1841
|
}
|
|
1842
1842
|
|
|
1843
1843
|
// Clear current lock - holder will see errors when they try operations
|
|
1844
1844
|
this.currentLock = false;
|
|
1845
1845
|
|
|
1846
|
-
// Reject pending mailbox locks via setImmediate for consistency
|
|
1847
1846
|
if (this.locks && this.locks.length) {
|
|
1848
|
-
let byeReason = this.byeReason;
|
|
1849
1847
|
let pendingLocks = this.locks.splice(0); // Take all locks and clear the array
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
lock.reject(createNoConnectionError(byeReason));
|
|
1854
|
-
}
|
|
1848
|
+
for (let lock of pendingLocks) {
|
|
1849
|
+
if (typeof lock.reject === 'function') {
|
|
1850
|
+
lock.reject(createNoConnectionError(byeReason));
|
|
1855
1851
|
}
|
|
1856
|
-
}
|
|
1852
|
+
}
|
|
1857
1853
|
}
|
|
1858
1854
|
|
|
1859
1855
|
// cleanup compression streams if they exist
|
|
@@ -3634,7 +3630,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3634
3630
|
});
|
|
3635
3631
|
|
|
3636
3632
|
// Prevent unhandled promise rejection if close() rejects this lock
|
|
3637
|
-
//
|
|
3633
|
+
// synchronously. The rejection still propagates to the caller.
|
|
3638
3634
|
lockPromise.catch(noop);
|
|
3639
3635
|
|
|
3640
3636
|
return lockPromise;
|
package/package.json
CHANGED
|
@@ -863,7 +863,7 @@ module.exports['Connection Edge: Close with empty locks array'] = test => {
|
|
|
863
863
|
test.done();
|
|
864
864
|
};
|
|
865
865
|
|
|
866
|
-
module.exports['Connection Edge: Lock rejection
|
|
866
|
+
module.exports['Connection Edge: Lock rejection happens synchronously during close'] = test => {
|
|
867
867
|
let client = new ImapFlow({
|
|
868
868
|
host: 'imap.example.com',
|
|
869
869
|
port: 993,
|
|
@@ -871,7 +871,6 @@ module.exports['Connection Edge: Lock rejection is deferred via setImmediate'] =
|
|
|
871
871
|
});
|
|
872
872
|
|
|
873
873
|
let rejectionTime = null;
|
|
874
|
-
let closeTime = null;
|
|
875
874
|
|
|
876
875
|
client.locks = [
|
|
877
876
|
{
|
|
@@ -888,16 +887,10 @@ module.exports['Connection Edge: Lock rejection is deferred via setImmediate'] =
|
|
|
888
887
|
client.usable = true;
|
|
889
888
|
|
|
890
889
|
client.close();
|
|
891
|
-
closeTime = Date.now();
|
|
892
890
|
|
|
893
|
-
// Rejection should
|
|
894
|
-
test.
|
|
895
|
-
|
|
896
|
-
setImmediate(() => {
|
|
897
|
-
test.ok(rejectionTime !== null, 'Rejection should happen after setImmediate');
|
|
898
|
-
test.ok(rejectionTime >= closeTime, 'Rejection should happen after close()');
|
|
899
|
-
test.done();
|
|
900
|
-
});
|
|
891
|
+
// Rejection should have happened synchronously during close()
|
|
892
|
+
test.ok(rejectionTime !== null, 'Rejection should happen synchronously during close()');
|
|
893
|
+
test.done();
|
|
901
894
|
};
|
|
902
895
|
|
|
903
896
|
module.exports['Connection Edge: Pending requests and locks both rejected on close'] = test => {
|
|
@@ -940,11 +933,10 @@ module.exports['Connection Edge: Pending requests and locks both rejected on clo
|
|
|
940
933
|
|
|
941
934
|
client.close();
|
|
942
935
|
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
});
|
|
936
|
+
// Rejections happen synchronously during close()
|
|
937
|
+
test.ok(requestRejected, 'Pending request should be rejected');
|
|
938
|
+
test.ok(lockRejected, 'Pending lock should be rejected');
|
|
939
|
+
test.done();
|
|
948
940
|
};
|
|
949
941
|
|
|
950
942
|
module.exports['Connection Edge: exec throws NoConnection when in LOGOUT state'] = async test => {
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Tests for unhandled rejection prevention.
|
|
5
5
|
*
|
|
6
|
-
* When close() runs, it rejects pending promises
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* rejections escape while the caller
|
|
6
|
+
* When close() runs, it rejects pending promises synchronously. exec() and
|
|
7
|
+
* getMailboxLock() attach .catch(noop) before returning, so the rejection is
|
|
8
|
+
* observed immediately and does not trigger Node.js unhandledRejection.
|
|
9
|
+
* These tests verify that no unhandled rejections escape while the caller
|
|
10
|
+
* still receives the expected error.
|
|
10
11
|
*
|
|
11
12
|
* Key fix: exec() and getMailboxLock() are non-async, returning the promise
|
|
12
13
|
* directly (with .catch(noop)), so the caller gets the same promise object
|
|
@@ -18,8 +19,13 @@
|
|
|
18
19
|
const net = require('net');
|
|
19
20
|
const { ImapFlow } = require('../lib/imap-flow');
|
|
20
21
|
|
|
21
|
-
// Create a
|
|
22
|
-
|
|
22
|
+
// Create a mock IMAP server with optional custom behavior.
|
|
23
|
+
// options.extraCapabilities - additional capabilities (e.g., 'IDLE')
|
|
24
|
+
// options.onCommand(socket, tag, command) - custom handler; return true if handled
|
|
25
|
+
function createMockServer(options) {
|
|
26
|
+
const extraCaps = options && options.extraCapabilities ? ' ' + options.extraCapabilities : '';
|
|
27
|
+
const onCommand = options && options.onCommand;
|
|
28
|
+
|
|
23
29
|
const server = net.createServer(socket => {
|
|
24
30
|
socket.write('* OK Mock IMAP Server ready\r\n');
|
|
25
31
|
|
|
@@ -34,8 +40,12 @@ function createMockServer() {
|
|
|
34
40
|
const tag = parts[0];
|
|
35
41
|
const command = parts[1] ? parts[1].toUpperCase() : '';
|
|
36
42
|
|
|
43
|
+
if (onCommand && onCommand(socket, tag, command, line)) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
37
47
|
if (command === 'CAPABILITY') {
|
|
38
|
-
socket.write(
|
|
48
|
+
socket.write(`* CAPABILITY IMAP4rev1 AUTH=PLAIN${extraCaps}\r\n`);
|
|
39
49
|
socket.write(`${tag} OK CAPABILITY completed\r\n`);
|
|
40
50
|
} else if (command === 'LOGIN') {
|
|
41
51
|
socket.write(`${tag} OK LOGIN completed\r\n`);
|
|
@@ -53,6 +63,12 @@ function createMockServer() {
|
|
|
53
63
|
} else if (command === 'ID') {
|
|
54
64
|
socket.write('* ID NIL\r\n');
|
|
55
65
|
socket.write(`${tag} OK ID completed\r\n`);
|
|
66
|
+
} else if (command === 'SELECT' || command === 'EXAMINE') {
|
|
67
|
+
socket.write('* 1 EXISTS\r\n');
|
|
68
|
+
socket.write('* 1 RECENT\r\n');
|
|
69
|
+
socket.write('* OK [UIDVALIDITY 1] UIDs valid\r\n');
|
|
70
|
+
socket.write('* OK [UIDNEXT 2] Predicted next UID\r\n');
|
|
71
|
+
socket.write(`${tag} OK SELECT completed\r\n`);
|
|
56
72
|
} else if (command === 'NOOP') {
|
|
57
73
|
socket.write(`${tag} OK NOOP completed\r\n`);
|
|
58
74
|
} else if (tag && command) {
|
|
@@ -103,9 +119,8 @@ exports['Unhandled Rejection Prevention'] = {
|
|
|
103
119
|
const detector = installRejectionDetector(test);
|
|
104
120
|
|
|
105
121
|
// Create a pending request, then immediately close.
|
|
106
|
-
// close() rejects the request
|
|
107
|
-
//
|
|
108
|
-
// before the caller's handler is attached.
|
|
122
|
+
// close() rejects the request synchronously. The .catch(noop) on the
|
|
123
|
+
// exec() promise ensures the rejection is observed immediately.
|
|
109
124
|
let promise = client.exec('NOOP');
|
|
110
125
|
client.close();
|
|
111
126
|
|
|
@@ -246,7 +261,7 @@ exports['Unhandled Rejection Prevention'] = {
|
|
|
246
261
|
await client.connect();
|
|
247
262
|
|
|
248
263
|
// Request a lock, then immediately close before it resolves.
|
|
249
|
-
// close() rejects pending locks
|
|
264
|
+
// close() rejects pending locks synchronously.
|
|
250
265
|
let lockPromise = client.getMailboxLock('INBOX');
|
|
251
266
|
client.close();
|
|
252
267
|
|
|
@@ -323,5 +338,129 @@ exports['Unhandled Rejection Prevention'] = {
|
|
|
323
338
|
}
|
|
324
339
|
);
|
|
325
340
|
});
|
|
341
|
+
},
|
|
342
|
+
|
|
343
|
+
'BYE during IDLE should not cause unhandled rejection (Death 1)'(test) {
|
|
344
|
+
test.expect(2);
|
|
345
|
+
|
|
346
|
+
const server = createMockServer({
|
|
347
|
+
extraCapabilities: 'IDLE',
|
|
348
|
+
onCommand(socket, tag, command) {
|
|
349
|
+
if (command === 'IDLE') {
|
|
350
|
+
socket.write('+ idling\r\n');
|
|
351
|
+
// After a short delay, send BYE and close (simulates token expiry)
|
|
352
|
+
setTimeout(() => {
|
|
353
|
+
try {
|
|
354
|
+
socket.write('* BYE Session invalidated - AccessTokenExpired\r\n');
|
|
355
|
+
socket.end();
|
|
356
|
+
} catch {
|
|
357
|
+
// socket may already be closed
|
|
358
|
+
}
|
|
359
|
+
}, 50);
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
server.listen(0, '127.0.0.1', async () => {
|
|
366
|
+
const port = server.address().port;
|
|
367
|
+
|
|
368
|
+
const client = new ImapFlow({
|
|
369
|
+
host: '127.0.0.1',
|
|
370
|
+
port,
|
|
371
|
+
secure: false,
|
|
372
|
+
logger: false,
|
|
373
|
+
auth: {
|
|
374
|
+
user: 'test',
|
|
375
|
+
pass: 'test'
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const detector = installRejectionDetector(test);
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
await client.connect();
|
|
383
|
+
await client.mailboxOpen('INBOX');
|
|
384
|
+
|
|
385
|
+
// Start IDLE and wait for the BYE-triggered close
|
|
386
|
+
await new Promise((resolve, reject) => {
|
|
387
|
+
client.idle().catch(() => {
|
|
388
|
+
// Expected: IDLE rejects when BYE arrives
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
client.on('close', () => {
|
|
392
|
+
// Wait for any deferred unhandled rejections to surface
|
|
393
|
+
setTimeout(resolve, 100);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// Safety timeout
|
|
397
|
+
setTimeout(() => reject(new Error('Timeout waiting for close')), 5000);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
detector.check();
|
|
401
|
+
test.ok(true, 'No unhandled rejection during IDLE + BYE');
|
|
402
|
+
} catch (err) {
|
|
403
|
+
detector.check();
|
|
404
|
+
test.ok(false, 'Unexpected error: ' + err.message);
|
|
405
|
+
} finally {
|
|
406
|
+
server.close(() => test.done());
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
},
|
|
410
|
+
|
|
411
|
+
'BAD response to FETCH should not cause unhandled rejection (Death 2)'(test) {
|
|
412
|
+
test.expect(3);
|
|
413
|
+
|
|
414
|
+
const server = createMockServer({
|
|
415
|
+
onCommand(socket, tag, command) {
|
|
416
|
+
if (command === 'UID' || command === 'FETCH') {
|
|
417
|
+
// Simulate "Server Unavailable" error for any FETCH variant
|
|
418
|
+
socket.write(`${tag} BAD Server Unavailable. 15\r\n`);
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
server.listen(0, '127.0.0.1', async () => {
|
|
425
|
+
const port = server.address().port;
|
|
426
|
+
|
|
427
|
+
const client = new ImapFlow({
|
|
428
|
+
host: '127.0.0.1',
|
|
429
|
+
port,
|
|
430
|
+
secure: false,
|
|
431
|
+
logger: false,
|
|
432
|
+
auth: {
|
|
433
|
+
user: 'test',
|
|
434
|
+
pass: 'test'
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
const detector = installRejectionDetector(test);
|
|
439
|
+
|
|
440
|
+
try {
|
|
441
|
+
await client.connect();
|
|
442
|
+
await client.mailboxOpen('INBOX');
|
|
443
|
+
|
|
444
|
+
// Attempt a FETCH that will get BAD response
|
|
445
|
+
try {
|
|
446
|
+
await client.fetchOne('*', { uid: true }, { uid: true });
|
|
447
|
+
test.ok(false, 'fetchOne should have rejected');
|
|
448
|
+
} catch (err) {
|
|
449
|
+
test.equal(err.message, 'Command failed', 'Should get Command failed error');
|
|
450
|
+
test.equal(err.responseStatus, 'BAD', 'Response status should be BAD');
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Wait for any deferred unhandled rejections
|
|
454
|
+
await new Promise(r => setTimeout(r, 100));
|
|
455
|
+
detector.check();
|
|
456
|
+
|
|
457
|
+
client.close();
|
|
458
|
+
} catch (err) {
|
|
459
|
+
detector.check();
|
|
460
|
+
test.ok(false, 'Unexpected error: ' + err.message);
|
|
461
|
+
} finally {
|
|
462
|
+
server.close(() => test.done());
|
|
463
|
+
}
|
|
464
|
+
});
|
|
326
465
|
}
|
|
327
466
|
};
|