imapflow 1.7.5 → 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.5"
2
+ ".": "1.7.6"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
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
+
3
10
  ## [1.7.5](https://github.com/postalsys/imapflow/compare/v1.7.4...v1.7.5) (2026-08-24)
4
11
 
5
12
 
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { buildConnectionError, 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
 
@@ -27,27 +27,6 @@ function claimIdling(connection) {
27
27
  };
28
28
  }
29
29
 
30
- /**
31
- * Builds the rejection a queued IDLE-break waiter receives when the IDLE command itself fails.
32
- *
33
- * The waiters are separate promises with separate consumers, so handing them the command's own
34
- * error would report a waiter that escapes as `rejectedFrom: 'pendingRequest'` - the site where
35
- * the IDLE command was rejected, not the site where the waiter was. That is precisely the
36
- * attribution the markers exist to give. Every own field of the original is carried over, because
37
- * a waiter rejection reaches callers through run() and they branch on `responseStatus`,
38
- * `serverResponseCode` and friends; only the site marker differs, and the original travels on as
39
- * `cause` so the command failure behind it stays visible.
40
- *
41
- * @param {Object} connection - IMAP connection instance
42
- * @param {Error} err - The error that failed the IDLE command
43
- * @returns {Error} Error stamped for the waiter site
44
- */
45
- function preCheckWaiterError(connection, err) {
46
- let error = buildConnectionError(connection.id, err.code, err.message, Object.assign({}, err, { rejectedFrom: 'preCheckWaiter' }));
47
- error.cause = err;
48
- return error;
49
- }
50
-
51
30
  /**
52
31
  * Runs a single IDLE session on the connection.
53
32
  *
@@ -159,9 +138,15 @@ async function runIdle(connection) {
159
138
  return;
160
139
  } catch (err) {
161
140
  logConnectionError(connection, 'IDLE session failed', err);
162
- while (preCheckWaitQueue.length) {
163
- let { reject } = preCheckWaitQueue.shift();
164
- reject(preCheckWaiterError(connection, 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
+ }
165
150
  }
166
151
  return false;
167
152
  } finally {
package/lib/tools.js CHANGED
@@ -72,6 +72,11 @@ 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
 
@@ -104,6 +109,37 @@ const tools = {
104
109
  return error;
105
110
  },
106
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
+
107
143
  /**
108
144
  * Creates a promise whose rejection is observed as soon as it exists.
109
145
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "1.7.5",
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);
@@ -440,63 +493,23 @@ exports['Unhandled Rejection Prevention'] = {
440
493
  },
441
494
 
442
495
  'a preCheck() waiter rejection names its own site, not the IDLE command'(test) {
443
- test.expect(6);
496
+ test.expect(8);
444
497
 
445
- // IDLE is never acknowledged with a "+", so preCheck() cannot send DONE and its waiter
446
- // stays queued until close() tears the IDLE command down.
447
- const server = createMockServer({
448
- extraCapabilities: 'IDLE',
449
- onCommand(socket, tag, command) {
450
- if (command === 'IDLE') {
451
- return true;
452
- }
453
- }
454
- });
455
-
456
- server.listen(0, '127.0.0.1', async () => {
457
- const port = server.address().port;
458
-
459
- const client = new ImapFlow({
460
- host: '127.0.0.1',
461
- port,
462
- secure: false,
463
- logger: false,
464
- disableAutoIdle: true,
465
- id: 'waiter-cid',
466
- auth: {
467
- user: 'test',
468
- pass: 'test'
469
- }
470
- });
498
+ withQueuedIdleWaiter(test, { id: 'waiter-cid' }, async client => {
499
+ let waiter = client.preCheck();
500
+ client.close();
471
501
 
472
502
  try {
473
- await client.connect();
474
- await client.mailboxOpen('INBOX');
475
-
476
- client.idle().catch(() => {
477
- // Expected: IDLE rejects when the connection is closed
478
- });
479
-
480
- await new Promise(r => setTimeout(r, 100));
481
-
482
- let waiter = client.preCheck();
483
- client.close();
484
-
485
- try {
486
- await waiter;
487
- test.ok(false, 'the waiter should have rejected');
488
- } catch (err) {
489
- test.equal(err.rejectedFrom, 'preCheckWaiter', 'the waiter names its own rejection site');
490
- test.equal(err.cid, 'waiter-cid', 'the waiter error carries the connection id');
491
- test.equal(err.code, 'NoConnection', 'the original code is preserved for callers that branch on it');
492
- test.ok(err.cause, 'the command failure travels on as the cause');
493
- test.equal(err.cause.rejectedFrom, 'pendingRequest', 'the cause still names the command site');
494
- test.equal(err.cause.command, 'IDLE', 'the cause still names the command');
495
- }
503
+ await waiter;
504
+ test.ok(false, 'the waiter should have rejected');
496
505
  } catch (err) {
497
- test.ok(false, 'Unexpected error: ' + err.message);
498
- } finally {
499
- server.close(() => test.done());
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');
500
513
  }
501
514
  });
502
515
  },
@@ -504,62 +517,22 @@ exports['Unhandled Rejection Prevention'] = {
504
517
  'preCheck() waiter rejected by close() should not cause unhandled rejection'(test) {
505
518
  test.expect(2);
506
519
 
507
- // Never acknowledge IDLE with a "+" continuation. preCheck() can only send DONE once
508
- // the server has acknowledged, so its waiter stays queued until close() tears the
509
- // IDLE command down and runIdle() rejects everything still waiting.
510
- const server = createMockServer({
511
- extraCapabilities: 'IDLE',
512
- onCommand(socket, tag, command) {
513
- if (command === 'IDLE') {
514
- return true;
515
- }
516
- }
517
- });
518
-
519
- server.listen(0, '127.0.0.1', async () => {
520
- const port = server.address().port;
521
-
522
- const client = new ImapFlow({
523
- host: '127.0.0.1',
524
- port,
525
- secure: false,
526
- logger: false,
527
- disableAutoIdle: true,
528
- auth: {
529
- user: 'test',
530
- pass: 'test'
531
- }
532
- });
533
-
534
- const detector = installRejectionDetector(test);
535
-
536
- try {
537
- await client.connect();
538
- await client.mailboxOpen('INBOX');
539
-
540
- client.idle().catch(() => {
541
- // Expected: IDLE rejects when the connection is closed
542
- });
543
-
544
- // Let the IDLE command reach the server and install preCheck()
545
- await new Promise(r => setTimeout(r, 100));
546
- test.equal(typeof client.preCheck, 'function', 'IDLE should have installed preCheck()');
520
+ const detector = installRejectionDetector(test);
547
521
 
548
- // Request an IDLE break and drop the returned promise on the floor, so the
549
- // 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
550
528
  client.preCheck();
551
-
552
529
  client.close();
553
530
 
554
531
  await new Promise(r => setTimeout(r, 100));
555
532
  detector.check();
556
- } catch (err) {
557
- detector.check();
558
- test.ok(false, 'Unexpected error: ' + err.message);
559
- } finally {
560
- server.close(() => test.done());
561
- }
562
- });
533
+ },
534
+ () => detector.check()
535
+ );
563
536
  },
564
537
 
565
538
  'BAD response to FETCH should not cause unhandled rejection (Death 2)'(test) {