imapflow 1.7.5 → 1.7.7
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 -25
- package/lib/tools.js +36 -0
- package/package.json +6 -6
- package/test/unhandled-rejection-test.js +76 -103
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.7.7](https://github.com/postalsys/imapflow/compare/v1.7.6...v1.7.7) (2026-08-31)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **deps:** update encoding-japanese to 2.3.0 for the CP932 extended areas ([8ae7ea4](https://github.com/postalsys/imapflow/commit/8ae7ea4cbb48357ffd4e19423d7844c19db9516f))
|
|
9
|
+
|
|
10
|
+
## [1.7.6](https://github.com/postalsys/imapflow/compare/v1.7.5...v1.7.6) (2026-08-24)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **errors:** stop a re-stamped rejection carrying the previous site's markers ([ffd266c](https://github.com/postalsys/imapflow/commit/ffd266c1459c6f29c5cc2286592a2b2ce88c2706))
|
|
16
|
+
|
|
3
17
|
## [1.7.5](https://github.com/postalsys/imapflow/compare/v1.7.4...v1.7.5) (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 {
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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.
|
|
3
|
+
"version": "1.7.7",
|
|
4
4
|
"description": "IMAP Client for Node",
|
|
5
5
|
"main": "lib/imap-flow.js",
|
|
6
6
|
"types": "lib/imap-flow.d.ts",
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
"homepage": "https://imapflow.com/",
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@eslint/js": "10.0.1",
|
|
33
|
-
"@types/node": "26.
|
|
33
|
+
"@types/node": "26.4.0",
|
|
34
34
|
"c8": "12.0.0",
|
|
35
|
-
"eslint": "10.9.
|
|
35
|
+
"eslint": "10.9.1",
|
|
36
36
|
"eslint-config-nodemailer": "1.2.0",
|
|
37
37
|
"eslint-config-prettier": "10.1.8",
|
|
38
38
|
"grunt": "1.6.3",
|
|
@@ -44,11 +44,11 @@
|
|
|
44
44
|
"typescript": "7.0.2"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@zone-eu/mailsplit": "5.4.
|
|
48
|
-
"encoding-japanese": "2.
|
|
47
|
+
"@zone-eu/mailsplit": "5.4.16",
|
|
48
|
+
"encoding-japanese": "2.3.0",
|
|
49
49
|
"iconv-lite": "0.7.3",
|
|
50
50
|
"libbase64": "1.3.0",
|
|
51
|
-
"libmime": "5.4.
|
|
51
|
+
"libmime": "5.4.3",
|
|
52
52
|
"libqp": "2.1.1",
|
|
53
53
|
"pino": "10.3.1",
|
|
54
54
|
"socks": "2.8.9"
|
|
@@ -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(
|
|
496
|
+
test.expect(8);
|
|
444
497
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
|
474
|
-
|
|
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.
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
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
|
-
|
|
549
|
-
|
|
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
|
-
}
|
|
557
|
-
|
|
558
|
-
|
|
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) {
|