imapflow 1.7.4 → 1.7.6

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.7.4"
2
+ ".": "1.7.6"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.7.6](https://github.com/postalsys/imapflow/compare/v1.7.5...v1.7.6) (2026-08-24)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **errors:** stop a re-stamped rejection carrying the previous site's markers ([ffd266c](https://github.com/postalsys/imapflow/commit/ffd266c1459c6f29c5cc2286592a2b2ce88c2706))
9
+
10
+ ## [1.7.5](https://github.com/postalsys/imapflow/compare/v1.7.4...v1.7.5) (2026-08-24)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **errors:** give an IDLE-break waiter its own rejection site ([9534551](https://github.com/postalsys/imapflow/commit/95345519fa32ee26bff239a2ac90f6b8ddccb559))
16
+
3
17
  ## [1.7.4](https://github.com/postalsys/imapflow/compare/v1.7.3...v1.7.4) (2026-08-24)
4
18
 
5
19
 
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { guardedPromise, hasCapability, logConnectionError, unrefTimer } = require('../tools.js');
3
+ const { guardedPromise, hasCapability, logConnectionError, restampConnectionError, unrefTimer } = require('../tools.js');
4
4
 
5
5
  const NOOP_INTERVAL = 2 * 60 * 1000;
6
6
 
@@ -138,9 +138,15 @@ async function runIdle(connection) {
138
138
  return;
139
139
  } catch (err) {
140
140
  logConnectionError(connection, 'IDLE session failed', err);
141
- while (preCheckWaitQueue.length) {
142
- let { reject } = preCheckWaitQueue.shift();
143
- reject(err);
141
+ if (preCheckWaitQueue.length) {
142
+ // One error for the whole queue: every waiter failed at the same site, for the same
143
+ // reason. Built inside the guard so a teardown with nothing queued - the common case -
144
+ // does not pay for an Error and its stack capture.
145
+ let waiterError = restampConnectionError(err, { rejectedFrom: 'preCheckWaiter' });
146
+ while (preCheckWaitQueue.length) {
147
+ let { reject } = preCheckWaitQueue.shift();
148
+ reject(waiterError);
149
+ }
144
150
  }
145
151
  return false;
146
152
  } finally {
package/lib/imap-flow.js CHANGED
@@ -43,6 +43,7 @@ const {
43
43
  parseUintValue,
44
44
  isUnsafeKey,
45
45
  getStringList,
46
+ buildConnectionError,
46
47
  guardedPromise,
47
48
  guardedReject,
48
49
  MAX_UINT32_DIGITS
@@ -2539,23 +2540,9 @@ class ImapFlow extends EventEmitter {
2539
2540
  setImmediate(() => this.close());
2540
2541
  }
2541
2542
 
2542
- // Builds an error describing a connection that is gone. Every such error is stamped here so
2543
- // the stamping cannot drift between sites: the connection id always travels on it, and each
2544
- // site names itself through `meta` (`rejectedFrom`, plus the command or mailbox path it
2545
- // belongs to).
2546
- //
2547
- // A stack trace only records where an error was built, and close() hands a rejection to every
2548
- // pending request and every queued lock in the same tick, so without these an error that
2549
- // reaches a global unhandledRejection handler arrives with nothing that identifies the
2550
- // connection it came from, let alone which of the rejected promises carried it.
2543
+ // Connection-scoped wrapper around the shared stamping helper; see buildConnectionError().
2551
2544
  createConnectionError(code, message, meta) {
2552
- const error = new Error(message);
2553
- error.code = code;
2554
- error.cid = this.id;
2555
- if (meta) {
2556
- Object.assign(error, meta);
2557
- }
2558
- return error;
2545
+ return buildConnectionError(this.id, code, message, meta);
2559
2546
  }
2560
2547
 
2561
2548
  // The standard "connection not available" error, optionally annotated with the server's BYE
package/lib/tools.js CHANGED
@@ -72,9 +72,74 @@ class AuthenticationFailure extends Error {
72
72
  // Deliberate no-op, used as the observer a guarded promise attaches to its own rejection.
73
73
  const noop = () => {};
74
74
 
75
+ // The fields buildConnectionError() stamps to say *where* a connection error was rejected, as
76
+ // opposed to what went wrong. restampConnectionError() clears them, because they belong to the
77
+ // site that built the error rather than to the failure it describes.
78
+ const CONNECTION_ERROR_SITE_KEYS = ['rejectedFrom', 'command', 'path'];
79
+
75
80
  const tools = {
76
81
  noop,
77
82
 
83
+ /**
84
+ * Builds an error describing a connection that is gone, stamped so it can be traced back to
85
+ * where it came from: the connection id always travels on it, and each site names itself
86
+ * through `meta` (`rejectedFrom`, plus the command or mailbox path it belongs to).
87
+ *
88
+ * A stack trace only records where an error was built, and close() hands a rejection to every
89
+ * pending request and every queued lock in the same tick, so without these an error that
90
+ * reaches a global unhandledRejection handler arrives with nothing that identifies the
91
+ * connection it came from, let alone which of the rejected promises carried it.
92
+ *
93
+ * Takes the connection id rather than the connection, so the stamping stays in one place
94
+ * without every caller having to be a full ImapFlow instance.
95
+ *
96
+ * @param {String} cid - Connection id
97
+ * @param {String} code - Error code, e.g. 'NoConnection'
98
+ * @param {String} message - Error message
99
+ * @param {Object} [meta] - Fields to stamp on the error
100
+ * @returns {Error} The stamped error
101
+ */
102
+ buildConnectionError(cid, code, message, meta) {
103
+ const error = new Error(message);
104
+ error.code = code;
105
+ error.cid = cid;
106
+ if (meta) {
107
+ Object.assign(error, meta);
108
+ }
109
+ return error;
110
+ },
111
+
112
+ /**
113
+ * Re-stamps an existing connection error for a different rejection site.
114
+ *
115
+ * The same failure can be handed to more than one promise - close() rejects the in-flight
116
+ * command, and runIdle() then rejects everything queued behind it - and each of those is a
117
+ * separate promise with a separate consumer. Sharing one error object reports whichever of
118
+ * them escapes under the first site's marker, which is the attribution these markers exist to
119
+ * give.
120
+ *
121
+ * Everything describing *what went wrong* is carried over, because a re-stamped error reaches
122
+ * user code through run() and callers branch on `responseStatus`, `serverResponseCode` and
123
+ * friends. Everything describing *where it was rejected* is dropped, because the new site owns
124
+ * those and a leftover `command` from the previous site is exactly as misleading as a leftover
125
+ * `rejectedFrom`. The original travels on as `cause`.
126
+ *
127
+ * @param {Error} err - The error being re-stamped
128
+ * @param {Object} meta - Fields for the new site, e.g. { rejectedFrom: 'preCheckWaiter' }
129
+ * @returns {Error} A separate error describing the same failure at the new site
130
+ */
131
+ restampConnectionError(err, meta) {
132
+ let error = tools.buildConnectionError(err.cid, err.code, err.message, err);
133
+ for (let key of CONNECTION_ERROR_SITE_KEYS) {
134
+ delete error[key];
135
+ }
136
+ if (meta) {
137
+ Object.assign(error, meta);
138
+ }
139
+ error.cause = err;
140
+ return error;
141
+ },
142
+
78
143
  /**
79
144
  * Creates a promise whose rejection is observed as soon as it exists.
80
145
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "1.7.4",
3
+ "version": "1.7.6",
4
4
  "description": "IMAP Client for Node",
5
5
  "main": "lib/imap-flow.js",
6
6
  "types": "lib/imap-flow.d.ts",
@@ -101,6 +101,59 @@ function installRejectionDetector(test) {
101
101
  };
102
102
  }
103
103
 
104
+ // Drives a connection to the one state both IDLE-waiter tests need: IDLE issued but never
105
+ // acknowledged with a "+", so preCheck() cannot send DONE and anything it queues stays queued
106
+ // until close() tears the IDLE command down. Hands the connected client to `run`, and always
107
+ // closes the server so the test ends.
108
+ function withQueuedIdleWaiter(test, options, run, onError) {
109
+ const server = createMockServer({
110
+ extraCapabilities: 'IDLE',
111
+ onCommand(socket, tag, command) {
112
+ if (command === 'IDLE') {
113
+ return true;
114
+ }
115
+ }
116
+ });
117
+
118
+ server.listen(0, '127.0.0.1', async () => {
119
+ const client = new ImapFlow(
120
+ Object.assign(
121
+ {
122
+ host: '127.0.0.1',
123
+ port: server.address().port,
124
+ secure: false,
125
+ logger: false,
126
+ disableAutoIdle: true,
127
+ auth: { user: 'test', pass: 'test' }
128
+ },
129
+ options
130
+ )
131
+ );
132
+
133
+ try {
134
+ await client.connect();
135
+ await client.mailboxOpen('INBOX');
136
+
137
+ client.idle().catch(() => {
138
+ // Expected: IDLE rejects when the connection is closed
139
+ });
140
+
141
+ // Let the IDLE command reach the server and install preCheck()
142
+ await new Promise(r => setTimeout(r, 100));
143
+ test.equal(typeof client.preCheck, 'function', 'IDLE should have installed preCheck()');
144
+
145
+ await run(client);
146
+ } catch (err) {
147
+ if (onError) {
148
+ onError(err);
149
+ }
150
+ test.ok(false, 'Unexpected error: ' + err.message);
151
+ } finally {
152
+ server.close(() => test.done());
153
+ }
154
+ });
155
+ }
156
+
104
157
  exports['Unhandled Rejection Prevention'] = {
105
158
  'exec() + close() race should not cause unhandled rejection'(test) {
106
159
  test.expect(2);
@@ -439,65 +492,47 @@ exports['Unhandled Rejection Prevention'] = {
439
492
  }, 100);
440
493
  },
441
494
 
442
- 'preCheck() waiter rejected by close() should not cause unhandled rejection'(test) {
443
- test.expect(2);
444
-
445
- // Never acknowledge IDLE with a "+" continuation. preCheck() can only send DONE once
446
- // the server has acknowledged, so its waiter stays queued until close() tears the
447
- // IDLE command down and runIdle() rejects everything still waiting.
448
- const server = createMockServer({
449
- extraCapabilities: 'IDLE',
450
- onCommand(socket, tag, command) {
451
- if (command === 'IDLE') {
452
- return true;
453
- }
454
- }
455
- });
456
-
457
- server.listen(0, '127.0.0.1', async () => {
458
- const port = server.address().port;
459
-
460
- const client = new ImapFlow({
461
- host: '127.0.0.1',
462
- port,
463
- secure: false,
464
- logger: false,
465
- disableAutoIdle: true,
466
- auth: {
467
- user: 'test',
468
- pass: 'test'
469
- }
470
- });
495
+ 'a preCheck() waiter rejection names its own site, not the IDLE command'(test) {
496
+ test.expect(8);
471
497
 
472
- const detector = installRejectionDetector(test);
498
+ withQueuedIdleWaiter(test, { id: 'waiter-cid' }, async client => {
499
+ let waiter = client.preCheck();
500
+ client.close();
473
501
 
474
502
  try {
475
- await client.connect();
476
- await client.mailboxOpen('INBOX');
503
+ await waiter;
504
+ test.ok(false, 'the waiter should have rejected');
505
+ } catch (err) {
506
+ test.equal(err.rejectedFrom, 'preCheckWaiter', 'the waiter names its own rejection site');
507
+ test.equal(err.cid, 'waiter-cid', 'the waiter error carries the connection id');
508
+ test.equal(err.code, 'NoConnection', 'the original code is preserved for callers that branch on it');
509
+ test.strictEqual(err.command, undefined, 'the command belongs to the site the error came from, not to the waiter');
510
+ test.ok(err.cause, 'the command failure travels on as the cause');
511
+ test.equal(err.cause.rejectedFrom, 'pendingRequest', 'the cause still names the command site');
512
+ test.equal(err.cause.command, 'IDLE', 'the cause still names the command');
513
+ }
514
+ });
515
+ },
477
516
 
478
- client.idle().catch(() => {
479
- // Expected: IDLE rejects when the connection is closed
480
- });
517
+ 'preCheck() waiter rejected by close() should not cause unhandled rejection'(test) {
518
+ test.expect(2);
481
519
 
482
- // Let the IDLE command reach the server and install preCheck()
483
- await new Promise(r => setTimeout(r, 100));
484
- test.equal(typeof client.preCheck, 'function', 'IDLE should have installed preCheck()');
520
+ const detector = installRejectionDetector(test);
485
521
 
486
- // Request an IDLE break and drop the returned promise on the floor, so the
487
- // queued waiter has no handler of its own when close() rejects it
522
+ withQueuedIdleWaiter(
523
+ test,
524
+ {},
525
+ async client => {
526
+ // Request an IDLE break and drop the returned promise on the floor, so the queued
527
+ // waiter has no handler of its own when close() rejects it
488
528
  client.preCheck();
489
-
490
529
  client.close();
491
530
 
492
531
  await new Promise(r => setTimeout(r, 100));
493
532
  detector.check();
494
- } catch (err) {
495
- detector.check();
496
- test.ok(false, 'Unexpected error: ' + err.message);
497
- } finally {
498
- server.close(() => test.done());
499
- }
500
- });
533
+ },
534
+ () => detector.check()
535
+ );
501
536
  },
502
537
 
503
538
  'BAD response to FETCH should not cause unhandled rejection (Death 2)'(test) {