imapflow 1.2.12 → 1.2.13
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 +7 -0
- package/lib/imap-flow.js +32 -7
- package/package.json +4 -4
- package/test/unhandled-rejection-test.js +327 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.2.13](https://github.com/postalsys/imapflow/compare/v1.2.12...v1.2.13) (2026-03-09)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* prevent unhandled promise rejections on close() race conditions ([fcff6be](https://github.com/postalsys/imapflow/commit/fcff6bee42914afdce6ba183b2222dcf080a6725))
|
|
9
|
+
|
|
3
10
|
## [1.2.12](https://github.com/postalsys/imapflow/compare/v1.2.11...v1.2.12) (2026-03-05)
|
|
4
11
|
|
|
5
12
|
|
package/lib/imap-flow.js
CHANGED
|
@@ -40,6 +40,8 @@ const {
|
|
|
40
40
|
|
|
41
41
|
const imapCommands = require('./imap-commands.js');
|
|
42
42
|
|
|
43
|
+
const noop = () => {};
|
|
44
|
+
|
|
43
45
|
const CONNECT_TIMEOUT = 90 * 1000;
|
|
44
46
|
const GREETING_TIMEOUT = 16 * 1000;
|
|
45
47
|
const UPGRADE_TIMEOUT = 10 * 1000;
|
|
@@ -552,24 +554,28 @@ class ImapFlow extends EventEmitter {
|
|
|
552
554
|
});
|
|
553
555
|
}
|
|
554
556
|
|
|
555
|
-
|
|
557
|
+
exec(command, attributes, options) {
|
|
556
558
|
if (this.state === this.states.LOGOUT || this.isClosed) {
|
|
557
559
|
const error = new Error('Connection not available');
|
|
558
560
|
error.code = 'NoConnection';
|
|
559
|
-
|
|
561
|
+
let p = Promise.reject(error);
|
|
562
|
+
p.catch(noop);
|
|
563
|
+
return p;
|
|
560
564
|
}
|
|
561
565
|
|
|
562
566
|
if (!this.socket || this.socket.destroyed) {
|
|
563
567
|
let error = new Error('Connection closed');
|
|
564
568
|
error.code = 'EConnectionClosed';
|
|
565
|
-
|
|
569
|
+
let p = Promise.reject(error);
|
|
570
|
+
p.catch(noop);
|
|
571
|
+
return p;
|
|
566
572
|
}
|
|
567
573
|
|
|
568
574
|
let tag = (++this.tagCounter).toString(16).toUpperCase();
|
|
569
575
|
|
|
570
576
|
options = options || {};
|
|
571
577
|
|
|
572
|
-
|
|
578
|
+
let promise = new Promise((resolve, reject) => {
|
|
573
579
|
this.requestTagMap.set(tag, { command, attributes, options, resolve, reject });
|
|
574
580
|
this.requestQueue.push({ tag, command, attributes, options });
|
|
575
581
|
this.trySend().catch(err => {
|
|
@@ -577,6 +583,13 @@ class ImapFlow extends EventEmitter {
|
|
|
577
583
|
reject(err);
|
|
578
584
|
});
|
|
579
585
|
});
|
|
586
|
+
|
|
587
|
+
// Prevent unhandled promise rejection if close() rejects this request
|
|
588
|
+
// via setImmediate before the caller's handler is attached. The rejection
|
|
589
|
+
// still propagates normally to the caller's await/.catch().
|
|
590
|
+
promise.catch(noop);
|
|
591
|
+
|
|
592
|
+
return promise;
|
|
580
593
|
}
|
|
581
594
|
|
|
582
595
|
// Resolves the handler for an untagged server response. IMAP untagged responses
|
|
@@ -1619,7 +1632,7 @@ class ImapFlow extends EventEmitter {
|
|
|
1619
1632
|
}
|
|
1620
1633
|
}
|
|
1621
1634
|
|
|
1622
|
-
|
|
1635
|
+
let connectPromise = new Promise((resolve, reject) => {
|
|
1623
1636
|
this.connectTimeout = setTimeout(() => {
|
|
1624
1637
|
let err = new Error('Failed to establish connection in required time');
|
|
1625
1638
|
err.code = 'CONNECT_TIMEOUT';
|
|
@@ -1713,6 +1726,12 @@ class ImapFlow extends EventEmitter {
|
|
|
1713
1726
|
};
|
|
1714
1727
|
this.socket.on('error', this._connectErrorHandler);
|
|
1715
1728
|
});
|
|
1729
|
+
|
|
1730
|
+
// Prevent unhandled promise rejection if close() rejects the connect
|
|
1731
|
+
// promise via setImmediate. The rejection still propagates to the caller.
|
|
1732
|
+
connectPromise.catch(noop);
|
|
1733
|
+
|
|
1734
|
+
await connectPromise;
|
|
1716
1735
|
}
|
|
1717
1736
|
|
|
1718
1737
|
/**
|
|
@@ -3626,7 +3645,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3626
3645
|
* lock.release();
|
|
3627
3646
|
* }
|
|
3628
3647
|
*/
|
|
3629
|
-
|
|
3648
|
+
getMailboxLock(path, options) {
|
|
3630
3649
|
options = options || {};
|
|
3631
3650
|
|
|
3632
3651
|
path = normalizePath(this, path);
|
|
@@ -3645,10 +3664,16 @@ class ImapFlow extends EventEmitter {
|
|
|
3645
3664
|
: null
|
|
3646
3665
|
});
|
|
3647
3666
|
|
|
3648
|
-
|
|
3667
|
+
let lockPromise = new Promise((resolve, reject) => {
|
|
3649
3668
|
this.locks.push({ resolve, reject, path, options, lockId });
|
|
3650
3669
|
this.processLocks().catch(err => reject(err));
|
|
3651
3670
|
});
|
|
3671
|
+
|
|
3672
|
+
// Prevent unhandled promise rejection if close() rejects this lock
|
|
3673
|
+
// via setImmediate. The rejection still propagates to the caller.
|
|
3674
|
+
lockPromise.catch(noop);
|
|
3675
|
+
|
|
3676
|
+
return lockPromise;
|
|
3652
3677
|
}
|
|
3653
3678
|
|
|
3654
3679
|
getLogger() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "imapflow",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.13",
|
|
4
4
|
"description": "IMAP Client for Node",
|
|
5
5
|
"main": "lib/imap-flow.js",
|
|
6
6
|
"types": "lib/imap-flow.d.ts",
|
|
@@ -28,9 +28,9 @@
|
|
|
28
28
|
"homepage": "https://imapflow.com/",
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@eslint/js": "10.0.1",
|
|
31
|
-
"@types/node": "25.3.
|
|
31
|
+
"@types/node": "25.3.5",
|
|
32
32
|
"c8": "11.0.0",
|
|
33
|
-
"eslint": "10.0.
|
|
33
|
+
"eslint": "10.0.3",
|
|
34
34
|
"eslint-config-nodemailer": "1.2.0",
|
|
35
35
|
"eslint-config-prettier": "10.1.8",
|
|
36
36
|
"grunt": "1.6.1",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"libbase64": "1.3.0",
|
|
49
49
|
"libmime": "5.3.7",
|
|
50
50
|
"libqp": "2.1.1",
|
|
51
|
-
"nodemailer": "8.0.
|
|
51
|
+
"nodemailer": "8.0.2",
|
|
52
52
|
"pino": "10.3.1",
|
|
53
53
|
"socks": "2.8.7"
|
|
54
54
|
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tests for unhandled rejection prevention.
|
|
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.
|
|
10
|
+
*
|
|
11
|
+
* Key fix: exec() and getMailboxLock() are non-async, returning the promise
|
|
12
|
+
* directly (with .catch(noop)), so the caller gets the same promise object
|
|
13
|
+
* that has the noop handler. This prevents the async-wrapper double-promise
|
|
14
|
+
* issue where .catch(noop) on an inner promise doesn't protect the outer
|
|
15
|
+
* async wrapper promise.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const net = require('net');
|
|
19
|
+
const { ImapFlow } = require('../lib/imap-flow');
|
|
20
|
+
|
|
21
|
+
// Create a simple mock IMAP server
|
|
22
|
+
function createMockServer() {
|
|
23
|
+
const server = net.createServer(socket => {
|
|
24
|
+
socket.write('* OK Mock IMAP Server ready\r\n');
|
|
25
|
+
|
|
26
|
+
socket.on('data', data => {
|
|
27
|
+
const lines = data
|
|
28
|
+
.toString()
|
|
29
|
+
.split('\r\n')
|
|
30
|
+
.filter(l => l.trim());
|
|
31
|
+
|
|
32
|
+
for (const line of lines) {
|
|
33
|
+
const parts = line.split(' ');
|
|
34
|
+
const tag = parts[0];
|
|
35
|
+
const command = parts[1] ? parts[1].toUpperCase() : '';
|
|
36
|
+
|
|
37
|
+
if (command === 'CAPABILITY') {
|
|
38
|
+
socket.write('* CAPABILITY IMAP4rev1 AUTH=PLAIN\r\n');
|
|
39
|
+
socket.write(`${tag} OK CAPABILITY completed\r\n`);
|
|
40
|
+
} else if (command === 'LOGIN') {
|
|
41
|
+
socket.write(`${tag} OK LOGIN completed\r\n`);
|
|
42
|
+
} else if (command === 'LOGOUT') {
|
|
43
|
+
socket.write('* BYE Server logging out\r\n');
|
|
44
|
+
socket.write(`${tag} OK LOGOUT completed\r\n`);
|
|
45
|
+
socket.end();
|
|
46
|
+
} else if (command === 'NAMESPACE') {
|
|
47
|
+
socket.write('* NAMESPACE (("" "/")) NIL NIL\r\n');
|
|
48
|
+
socket.write(`${tag} OK NAMESPACE completed\r\n`);
|
|
49
|
+
} else if (command === 'COMPRESS') {
|
|
50
|
+
socket.write(`${tag} NO COMPRESS not supported\r\n`);
|
|
51
|
+
} else if (command === 'ENABLE') {
|
|
52
|
+
socket.write(`${tag} OK ENABLE completed\r\n`);
|
|
53
|
+
} else if (command === 'ID') {
|
|
54
|
+
socket.write('* ID NIL\r\n');
|
|
55
|
+
socket.write(`${tag} OK ID completed\r\n`);
|
|
56
|
+
} else if (command === 'NOOP') {
|
|
57
|
+
socket.write(`${tag} OK NOOP completed\r\n`);
|
|
58
|
+
} else if (tag && command) {
|
|
59
|
+
socket.write(`${tag} OK Command completed\r\n`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
socket.on('error', () => {});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return server;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Helper: install an unhandledRejection detector
|
|
71
|
+
function installRejectionDetector(test) {
|
|
72
|
+
let unhandled = false;
|
|
73
|
+
let unhandledReason = null;
|
|
74
|
+
const handler = reason => {
|
|
75
|
+
unhandled = true;
|
|
76
|
+
unhandledReason = reason;
|
|
77
|
+
};
|
|
78
|
+
process.on('unhandledRejection', handler);
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
check() {
|
|
82
|
+
process.removeListener('unhandledRejection', handler);
|
|
83
|
+
test.equal(unhandled, false, 'no unhandledRejection should fire' + (unhandledReason ? ': ' + unhandledReason.message : ''));
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
exports['Unhandled Rejection Prevention'] = {
|
|
89
|
+
'exec() + close() race should not cause unhandled rejection'(test) {
|
|
90
|
+
test.expect(2);
|
|
91
|
+
|
|
92
|
+
const client = new ImapFlow({
|
|
93
|
+
host: '127.0.0.1',
|
|
94
|
+
port: 1,
|
|
95
|
+
secure: false,
|
|
96
|
+
logger: false
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Set up state so exec() doesn't reject synchronously
|
|
100
|
+
client.state = client.states.AUTHENTICATED;
|
|
101
|
+
client.socket = new net.Socket();
|
|
102
|
+
|
|
103
|
+
const detector = installRejectionDetector(test);
|
|
104
|
+
|
|
105
|
+
// 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.
|
|
109
|
+
let promise = client.exec('NOOP');
|
|
110
|
+
client.close();
|
|
111
|
+
|
|
112
|
+
// Wait for setImmediate and microtask rejections to settle
|
|
113
|
+
setTimeout(() => {
|
|
114
|
+
detector.check();
|
|
115
|
+
promise.catch(err => {
|
|
116
|
+
test.equal(err.code, 'NoConnection', 'caller should receive NoConnection error');
|
|
117
|
+
test.done();
|
|
118
|
+
});
|
|
119
|
+
}, 100);
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
'exec() on LOGOUT state should not cause unhandled rejection'(test) {
|
|
123
|
+
test.expect(2);
|
|
124
|
+
|
|
125
|
+
const client = new ImapFlow({
|
|
126
|
+
host: '127.0.0.1',
|
|
127
|
+
port: 1,
|
|
128
|
+
secure: false,
|
|
129
|
+
logger: false
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
client.state = client.states.LOGOUT;
|
|
133
|
+
|
|
134
|
+
const detector = installRejectionDetector(test);
|
|
135
|
+
|
|
136
|
+
// exec() detects LOGOUT and returns a rejected promise with .catch(noop)
|
|
137
|
+
let promise = client.exec('NOOP');
|
|
138
|
+
|
|
139
|
+
setTimeout(() => {
|
|
140
|
+
detector.check();
|
|
141
|
+
promise.catch(err => {
|
|
142
|
+
test.equal(err.code, 'NoConnection', 'should reject with NoConnection');
|
|
143
|
+
test.done();
|
|
144
|
+
});
|
|
145
|
+
}, 100);
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
'exec() with destroyed socket should not cause unhandled rejection'(test) {
|
|
149
|
+
test.expect(2);
|
|
150
|
+
|
|
151
|
+
const client = new ImapFlow({
|
|
152
|
+
host: '127.0.0.1',
|
|
153
|
+
port: 1,
|
|
154
|
+
secure: false,
|
|
155
|
+
logger: false
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
client.state = client.states.AUTHENTICATED;
|
|
159
|
+
let sock = new net.Socket();
|
|
160
|
+
sock.destroy();
|
|
161
|
+
client.socket = sock;
|
|
162
|
+
|
|
163
|
+
const detector = installRejectionDetector(test);
|
|
164
|
+
|
|
165
|
+
let promise = client.exec('NOOP');
|
|
166
|
+
|
|
167
|
+
setTimeout(() => {
|
|
168
|
+
detector.check();
|
|
169
|
+
promise.catch(err => {
|
|
170
|
+
test.equal(err.code, 'EConnectionClosed', 'should reject with EConnectionClosed');
|
|
171
|
+
test.done();
|
|
172
|
+
});
|
|
173
|
+
}, 100);
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
'multiple pending exec() + close() should not cause unhandled rejections'(test) {
|
|
177
|
+
test.expect(7);
|
|
178
|
+
|
|
179
|
+
const client = new ImapFlow({
|
|
180
|
+
host: '127.0.0.1',
|
|
181
|
+
port: 1,
|
|
182
|
+
secure: false,
|
|
183
|
+
logger: false
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
client.state = client.states.AUTHENTICATED;
|
|
187
|
+
client.socket = new net.Socket();
|
|
188
|
+
|
|
189
|
+
const detector = installRejectionDetector(test);
|
|
190
|
+
|
|
191
|
+
let p1 = client.exec('NOOP');
|
|
192
|
+
let p2 = client.exec('NOOP');
|
|
193
|
+
let p3 = client.exec('NOOP');
|
|
194
|
+
client.close();
|
|
195
|
+
|
|
196
|
+
setTimeout(() => {
|
|
197
|
+
detector.check();
|
|
198
|
+
|
|
199
|
+
let settled = 0;
|
|
200
|
+
const checkDone = () => {
|
|
201
|
+
if (++settled === 3) {
|
|
202
|
+
test.done();
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
p1.catch(err => {
|
|
207
|
+
test.ok(err, 'p1 should reject');
|
|
208
|
+
test.equal(err.code, 'NoConnection', 'p1 error code');
|
|
209
|
+
checkDone();
|
|
210
|
+
});
|
|
211
|
+
p2.catch(err => {
|
|
212
|
+
test.ok(err, 'p2 should reject');
|
|
213
|
+
test.equal(err.code, 'NoConnection', 'p2 error code');
|
|
214
|
+
checkDone();
|
|
215
|
+
});
|
|
216
|
+
p3.catch(err => {
|
|
217
|
+
test.ok(err, 'p3 should reject');
|
|
218
|
+
test.equal(err.code, 'NoConnection', 'p3 error code');
|
|
219
|
+
checkDone();
|
|
220
|
+
});
|
|
221
|
+
}, 100);
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
'getMailboxLock() + close() race should not cause unhandled rejection'(test) {
|
|
225
|
+
test.expect(2);
|
|
226
|
+
|
|
227
|
+
const server = createMockServer();
|
|
228
|
+
|
|
229
|
+
server.listen(0, '127.0.0.1', async () => {
|
|
230
|
+
const port = server.address().port;
|
|
231
|
+
|
|
232
|
+
const client = new ImapFlow({
|
|
233
|
+
host: '127.0.0.1',
|
|
234
|
+
port,
|
|
235
|
+
secure: false,
|
|
236
|
+
logger: false,
|
|
237
|
+
auth: {
|
|
238
|
+
user: 'test',
|
|
239
|
+
pass: 'test'
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const detector = installRejectionDetector(test);
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
await client.connect();
|
|
247
|
+
|
|
248
|
+
// Request a lock, then immediately close before it resolves.
|
|
249
|
+
// close() rejects pending locks via setImmediate.
|
|
250
|
+
let lockPromise = client.getMailboxLock('INBOX');
|
|
251
|
+
client.close();
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
await lockPromise;
|
|
255
|
+
test.ok(false, 'lockPromise should have rejected');
|
|
256
|
+
} catch (err) {
|
|
257
|
+
// Wait for any deferred unhandled rejection
|
|
258
|
+
await new Promise(r => setTimeout(r, 100));
|
|
259
|
+
detector.check();
|
|
260
|
+
test.equal(err.code, 'NoConnection', 'caller should receive NoConnection error');
|
|
261
|
+
}
|
|
262
|
+
} catch (err) {
|
|
263
|
+
detector.check();
|
|
264
|
+
test.ok(false, 'unexpected error: ' + err.message);
|
|
265
|
+
} finally {
|
|
266
|
+
server.close(() => test.done());
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
'connect() + close() during greeting timeout should not cause unhandled rejection'(test) {
|
|
272
|
+
test.expect(2);
|
|
273
|
+
|
|
274
|
+
// Server that accepts TCP but never sends a greeting.
|
|
275
|
+
// The client's greetingTimeout will fire.
|
|
276
|
+
const server = net.createServer(socket => {
|
|
277
|
+
// Intentionally send nothing - let client timeout
|
|
278
|
+
socket.on('error', () => {});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
server.listen(0, '127.0.0.1', () => {
|
|
282
|
+
const port = server.address().port;
|
|
283
|
+
|
|
284
|
+
const client = new ImapFlow({
|
|
285
|
+
host: '127.0.0.1',
|
|
286
|
+
port,
|
|
287
|
+
secure: false,
|
|
288
|
+
logger: false,
|
|
289
|
+
greetingTimeout: 200,
|
|
290
|
+
auth: {
|
|
291
|
+
user: 'test',
|
|
292
|
+
pass: 'test'
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const detector = installRejectionDetector(test);
|
|
297
|
+
|
|
298
|
+
// Close shortly after the TCP connection is established but
|
|
299
|
+
// before the greeting is received
|
|
300
|
+
const origSetSocket = client.setSocketHandlers;
|
|
301
|
+
let closeTriggered = false;
|
|
302
|
+
client.setSocketHandlers = function () {
|
|
303
|
+
origSetSocket.call(this);
|
|
304
|
+
if (!closeTriggered) {
|
|
305
|
+
closeTriggered = true;
|
|
306
|
+
// Close on the next tick to simulate race
|
|
307
|
+
setImmediate(() => client.close());
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
client.connect().then(
|
|
312
|
+
() => {
|
|
313
|
+
detector.check();
|
|
314
|
+
test.ok(false, 'connect() should have rejected');
|
|
315
|
+
server.close(() => test.done());
|
|
316
|
+
},
|
|
317
|
+
err => {
|
|
318
|
+
setTimeout(() => {
|
|
319
|
+
detector.check();
|
|
320
|
+
test.ok(err && err.code, 'connect() should reject with an error code');
|
|
321
|
+
server.close(() => test.done());
|
|
322
|
+
}, 100);
|
|
323
|
+
}
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
};
|