imapflow 1.2.11 → 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.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "1.2.11"
2
+ ".": "1.2.13"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
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
+
10
+ ## [1.2.12](https://github.com/postalsys/imapflow/compare/v1.2.11...v1.2.12) (2026-03-05)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * prevent unhandled NoConnection rejection in IDLE recovery ([e177372](https://github.com/postalsys/imapflow/commit/e17737296b6b2de9ba25392b79542cf6d7d45826))
16
+
3
17
  ## [1.2.11](https://github.com/postalsys/imapflow/compare/v1.2.10...v1.2.11) (2026-03-03)
4
18
 
5
19
 
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
- async exec(command, attributes, options) {
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
- throw error;
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
- throw error;
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
- return new Promise((resolve, reject) => {
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
@@ -856,7 +869,12 @@ class ImapFlow extends EventEmitter {
856
869
  // Attempt to recover IDLE connections
857
870
  this.run('NOOP')
858
871
  .then(() => this.idle())
859
- .catch(this._socketError); // Natural circuit breaker
872
+ .catch(err => {
873
+ this.log.warn({ msg: 'IDLE recovery failed after timeout', err, cid: this.id });
874
+ if (!this.isClosed) {
875
+ this.close();
876
+ }
877
+ });
860
878
  } else {
861
879
  // Close immediately for non-IDLE operations
862
880
  this.log.debug({ msg: 'Socket timeout', cid: this.id });
@@ -1614,7 +1632,7 @@ class ImapFlow extends EventEmitter {
1614
1632
  }
1615
1633
  }
1616
1634
 
1617
- await new Promise((resolve, reject) => {
1635
+ let connectPromise = new Promise((resolve, reject) => {
1618
1636
  this.connectTimeout = setTimeout(() => {
1619
1637
  let err = new Error('Failed to establish connection in required time');
1620
1638
  err.code = 'CONNECT_TIMEOUT';
@@ -1708,6 +1726,12 @@ class ImapFlow extends EventEmitter {
1708
1726
  };
1709
1727
  this.socket.on('error', this._connectErrorHandler);
1710
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;
1711
1735
  }
1712
1736
 
1713
1737
  /**
@@ -3621,7 +3645,7 @@ class ImapFlow extends EventEmitter {
3621
3645
  * lock.release();
3622
3646
  * }
3623
3647
  */
3624
- async getMailboxLock(path, options) {
3648
+ getMailboxLock(path, options) {
3625
3649
  options = options || {};
3626
3650
 
3627
3651
  path = normalizePath(this, path);
@@ -3640,10 +3664,16 @@ class ImapFlow extends EventEmitter {
3640
3664
  : null
3641
3665
  });
3642
3666
 
3643
- return await new Promise((resolve, reject) => {
3667
+ let lockPromise = new Promise((resolve, reject) => {
3644
3668
  this.locks.push({ resolve, reject, path, options, lockId });
3645
3669
  this.processLocks().catch(err => reject(err));
3646
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;
3647
3677
  }
3648
3678
 
3649
3679
  getLogger() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "1.2.11",
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.3",
31
+ "@types/node": "25.3.5",
32
32
  "c8": "11.0.0",
33
- "eslint": "10.0.2",
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.1",
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
+ };