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.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "1.2.14"
2
+ ".": "1.2.15"
3
3
  }
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
- // via setImmediate before the caller's handler is attached. The rejection
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 via setImmediate. The rejection still propagates to the caller.
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
- // Reject via setImmediate so the rest of close() cleanup runs first.
1788
- // The caller's connect() promise will reject after sockets are torn down.
1789
- setImmediate(() => reject(err));
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 via setImmediate so this synchronous close()
1832
- // method finishes first. This prevents unhandled promise rejections that
1833
- // would occur if we rejected inline before callers set up .catch() handlers.
1834
- if (pendingRequests.length) {
1835
- let byeReason = this.byeReason;
1836
- setImmediate(() => {
1837
- for (let request of pendingRequests) {
1838
- request.reject(createNoConnectionError(byeReason));
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
- setImmediate(() => {
1851
- for (let lock of pendingLocks) {
1852
- if (typeof lock.reject === 'function') {
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
- // via setImmediate. The rejection still propagates to the caller.
3633
+ // synchronously. The rejection still propagates to the caller.
3638
3634
  lockPromise.catch(noop);
3639
3635
 
3640
3636
  return lockPromise;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "1.2.14",
3
+ "version": "1.2.15",
4
4
  "description": "IMAP Client for Node",
5
5
  "main": "lib/imap-flow.js",
6
6
  "types": "lib/imap-flow.d.ts",
@@ -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 is deferred via setImmediate'] = test => {
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 not have happened yet (synchronously)
894
- test.equal(rejectionTime, null, 'Rejection should be deferred');
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
- setImmediate(() => {
944
- test.ok(requestRejected, 'Pending request should be rejected');
945
- test.ok(lockRejected, 'Pending lock should be rejected');
946
- test.done();
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 via setImmediate. Without
7
- * guards, the rejection can fire before the caller's handler is attached,
8
- * causing Node.js unhandledRejection. These tests verify that no unhandled
9
- * rejections escape while the caller still receives the expected error.
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 simple mock IMAP server
22
- function createMockServer() {
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('* CAPABILITY IMAP4rev1 AUTH=PLAIN\r\n');
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 via setImmediate. Without the fix,
107
- // trySend()'s async chain rejects the promise through a microtask
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 via setImmediate.
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
  };