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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +14 -0
- package/lib/commands/idle.js +10 -4
- package/lib/imap-flow.js +3 -16
- package/lib/tools.js +65 -0
- package/package.json +1 -1
- package/test/unhandled-rejection-test.js +83 -48
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
|
|
package/lib/commands/idle.js
CHANGED
|
@@ -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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
@@ -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
|
|
443
|
-
test.expect(
|
|
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
|
-
|
|
498
|
+
withQueuedIdleWaiter(test, { id: 'waiter-cid' }, async client => {
|
|
499
|
+
let waiter = client.preCheck();
|
|
500
|
+
client.close();
|
|
473
501
|
|
|
474
502
|
try {
|
|
475
|
-
await
|
|
476
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
});
|
|
517
|
+
'preCheck() waiter rejected by close() should not cause unhandled rejection'(test) {
|
|
518
|
+
test.expect(2);
|
|
481
519
|
|
|
482
|
-
|
|
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
|
-
|
|
487
|
-
|
|
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
|
-
}
|
|
495
|
-
|
|
496
|
-
|
|
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) {
|