imapflow 1.7.7 → 1.7.8

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.7"
2
+ ".": "1.7.8"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.7.8](https://github.com/postalsys/imapflow/compare/v1.7.7...v1.7.8) (2026-09-01)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **download:** keep the pipeline error forwarder across a backpressure wait ([c6b2ed7](https://github.com/postalsys/imapflow/commit/c6b2ed7f2ffb598dad0d7a4beb915055c3cfbef6))
9
+
3
10
  ## [1.7.7](https://github.com/postalsys/imapflow/compare/v1.7.6...v1.7.7) (2026-08-31)
4
11
 
5
12
 
package/lib/imap-flow.js CHANGED
@@ -2673,6 +2673,10 @@ class ImapFlow extends EventEmitter {
2673
2673
  // built by guardedPromise(), so its rejection is already observed and cannot trigger
2674
2674
  // unhandledRejection. close() is synchronous, so all remaining cleanup runs before
2675
2675
  // any microtask rejection handler fires.
2676
+ //
2677
+ // The error travels on, though, through await chains and .then() links that
2678
+ // guardedPromise() knows nothing about. Read a crash stack ending here as "this is
2679
+ // the value that escaped", never as "this is the promise that escaped".
2676
2680
  let byeReason = this.byeReason;
2677
2681
 
2678
2682
  for (let request of pendingRequests) {
@@ -4124,17 +4128,17 @@ class ImapFlow extends EventEmitter {
4124
4128
  // Wait for drain event before continuing
4125
4129
  try {
4126
4130
  await new Promise((resolve, reject) => {
4127
- let resolved = false;
4128
-
4131
+ // finish() is the listener itself, as settle() is for the TLS upgrade:
4132
+ // 'drain' and 'close' emit no arguments, 'error' emits the error, and
4133
+ // removal needs no separate handler references. It removes only the
4134
+ // three listeners this wait installed - removeAllListeners('error')
4135
+ // also took off the forwarder pipeStage() attached to the head stream
4136
+ // when the pipeline was built, and the head must keep that forwarder
4137
+ // for the life of the download or a chunk failure has nowhere to go.
4129
4138
  const finish = err => {
4130
- /* c8 ignore next */ // the first call removes all three listeners, so a later drain/error/close can't re-enter finish; this guard is belt-and-suspenders
4131
- if (resolved) return;
4132
- resolved = true;
4133
-
4134
- // Remove all listeners
4135
- stream.removeAllListeners('drain');
4136
- stream.removeAllListeners('error');
4137
- stream.removeAllListeners('close');
4139
+ for (let event of ['drain', 'error', 'close']) {
4140
+ stream.removeListener(event, finish);
4141
+ }
4138
4142
 
4139
4143
  /* c8 ignore next 2 */ // stream error during a backpressure drain wait is timing-dependent
4140
4144
  if (err) {
@@ -4144,9 +4148,9 @@ class ImapFlow extends EventEmitter {
4144
4148
  }
4145
4149
  };
4146
4150
 
4147
- stream.once('drain', () => finish());
4148
- stream.once('error', err => finish(err));
4149
- stream.once('close', () => finish());
4151
+ stream.once('drain', finish);
4152
+ stream.once('error', finish);
4153
+ stream.once('close', finish);
4150
4154
  });
4151
4155
  /* c8 ignore start */ // re-throw path only triggers on a stream error mid-drain, which is timing-dependent
4152
4156
  } catch (err) {
@@ -4210,7 +4214,14 @@ class ImapFlow extends EventEmitter {
4210
4214
  if (!fetchAborted && stream && !stream.destroyed) {
4211
4215
  stream.end();
4212
4216
  }
4213
- });
4217
+ })
4218
+ // Terminal guard: nothing consumes this chain, so a throw from either handler
4219
+ // above rejects a promise nobody holds and takes the process down on
4220
+ // unhandledRejection. Reaching it always means an invariant broke - the head
4221
+ // stream kept pipeStage()'s error forwarder for the life of the download, so
4222
+ // emit('error') above has somewhere to go - which is why it logs at error even
4223
+ // for a routine-looking connection code.
4224
+ .catch(err => this.log.error({ msg: 'Failed to fail the download stream', err, cid: this.id }));
4214
4225
  };
4215
4226
 
4216
4227
  setImmediate(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "1.7.7",
3
+ "version": "1.7.8",
4
4
  "description": "IMAP Client for Node",
5
5
  "main": "lib/imap-flow.js",
6
6
  "types": "lib/imap-flow.d.ts",
@@ -4,6 +4,8 @@
4
4
  // One definition instead of a per-file copy, so a change to what the constructor needs lands
5
5
  // in one place and cannot drift between suites.
6
6
 
7
+ const { Writable } = require('node:stream');
8
+
7
9
  const { ImapFlow } = require('../../lib/imap-flow');
8
10
 
9
11
  // A socket stub complete enough for setSocketHandlers(), clearSocketHandlers() and close() to
@@ -54,4 +56,46 @@ const chunkedFetchOne = body => async (range, query) => {
54
56
  return { uid: 1, size: body.length, source: body.subarray(start, start + maxLength) };
55
57
  };
56
58
 
57
- module.exports = { makeClient, makeIdleReadyClient, makeSocketStub, chunkedFetchOne };
59
+ // Installs a process-level unhandledRejection detector for one test. Shared because getting the
60
+ // teardown wrong leaks a process listener into every test that runs after it, and because the
61
+ // assertion has to read the same way wherever a suite checks that nothing escaped.
62
+ const installRejectionDetector = test => {
63
+ // Tracked as a separate flag rather than by testing the reason: a rejection can carry a falsy
64
+ // value, and that is still an escaped rejection.
65
+ let unhandled = false;
66
+ let unhandledReason = null;
67
+ const handler = reason => {
68
+ unhandled = true;
69
+ unhandledReason = reason;
70
+ };
71
+ process.on('unhandledRejection', handler);
72
+
73
+ return {
74
+ check() {
75
+ process.removeListener('unhandledRejection', handler);
76
+ test.equal(unhandled, false, 'no unhandledRejection should fire' + (unhandledReason ? ': ' + unhandledReason.message : ''));
77
+ }
78
+ };
79
+ };
80
+
81
+ // A Writable that always defers its callback, so writing to a stream piped into it fills the
82
+ // pipeline and forces the producer into backpressure. `delay` is only for tests that also need
83
+ // wall-clock time to pass; deferring at all is what triggers the drain wait.
84
+ const slowConsumer = (options = {}) => {
85
+ let { delay = 0, onChunk } = options;
86
+ return new Writable({
87
+ highWaterMark: 1,
88
+ write(chunk, enc, cb) {
89
+ if (typeof onChunk === 'function') {
90
+ onChunk(chunk);
91
+ }
92
+ if (delay) {
93
+ setTimeout(cb, delay);
94
+ } else {
95
+ setImmediate(cb);
96
+ }
97
+ }
98
+ });
99
+ };
100
+
101
+ module.exports = { makeClient, makeIdleReadyClient, makeSocketStub, chunkedFetchOne, installRejectionDetector, slowConsumer };
@@ -8,8 +8,8 @@ const { ImapFlow } = require('../lib/imap-flow');
8
8
  const libbase64 = require('libbase64');
9
9
  const libqp = require('libqp');
10
10
  const libmime = require('libmime');
11
- const { Writable, finished } = require('stream');
12
- const { chunkedFetchOne } = require('./fixtures/test-client');
11
+ const { finished } = require('stream');
12
+ const { chunkedFetchOne, installRejectionDetector, slowConsumer } = require('./fixtures/test-client');
13
13
 
14
14
  const makeClient = (overrides = {}) => {
15
15
  let client = new ImapFlow({
@@ -547,13 +547,7 @@ module.exports['Download: in-loop backpressure waits for drain'] = async test =>
547
547
  // one buffer down) but subsequent loop writes return false while the consumer
548
548
  // is still draining the previous chunk, exercising the in-loop drain wait.
549
549
  let received = 0;
550
- let slow = new Writable({
551
- highWaterMark: 1,
552
- write(chunk, enc, cb) {
553
- received += chunk.length;
554
- setTimeout(cb, 40);
555
- }
556
- });
550
+ let slow = slowConsumer({ delay: 40, onChunk: chunk => (received += chunk.length) });
557
551
  content.pipe(slow);
558
552
  await new Promise(resolve => slow.on('finish', resolve));
559
553
 
@@ -587,6 +581,53 @@ module.exports['Download: error during streaming surfaces on content stream'] =
587
581
  test.done();
588
582
  };
589
583
 
584
+ module.exports['Download: a chunk failure after a backpressure wait reaches the stream'] = async test => {
585
+ // Regression: the drain wait cleared the head stream's listeners with
586
+ // removeAllListeners('error'), which also removed the error forwarder the decoder pipeline
587
+ // attached to it. From the first backpressure wait onwards a later chunk failure was emitted
588
+ // onto a listener-less stream, so emit() threw inside the .catch() handler and the rejection
589
+ // escaped as an unhandledRejection instead of reaching the consumer.
590
+ let client = makeClient();
591
+ let big = Buffer.alloc(64 * 1024, 0x61);
592
+ let calls = 0;
593
+ client.fetchOne = async () => {
594
+ calls++;
595
+ if (calls <= 3) {
596
+ return { uid: 1, size: 400 * 1024, source: big };
597
+ }
598
+ let err = new Error('Connection not available');
599
+ err.code = 'NoConnection';
600
+ throw err;
601
+ };
602
+
603
+ const detector = installRejectionDetector(test);
604
+
605
+ let received = 0;
606
+ let streamErr;
607
+ try {
608
+ let { content } = await client.download('1', false, { uid: true, chunkSize: 64 * 1024 });
609
+
610
+ // Backpressure comes from the deferred callback, not from wall-clock delay
611
+ content.pipe(slowConsumer({ onChunk: chunk => (received += chunk.length) }));
612
+ await new Promise(resolve =>
613
+ finished(content, err => {
614
+ streamErr = err;
615
+ resolve();
616
+ })
617
+ );
618
+ } finally {
619
+ detector.check();
620
+ }
621
+
622
+ // Without a completed drain wait the regression is not exercised at all, so assert the
623
+ // pipeline actually drained a chunk before the failing fetch rather than inferring it
624
+ test.ok(received >= 64 * 1024, 'a chunk drained through the consumer before the failure');
625
+ test.equal(calls, 4, 'the failing chunk was reached');
626
+ test.ok(streamErr, 'error surfaced on content stream');
627
+ test.equal(streamErr && streamErr.code, 'NoConnection');
628
+ test.done();
629
+ };
630
+
590
631
  module.exports['Download: write error on initial chunk surfaces on stream'] = async test => {
591
632
  let client = makeClient();
592
633
  // a non-Buffer source makes stream.write throw inside the setImmediate try/catch
@@ -634,12 +675,7 @@ module.exports['Download: aborting mid-backpressure stops the fetch loop'] = asy
634
675
 
635
676
  // Slow consumer to force backpressure, then destroy mid-stream so the loop
636
677
  // aborts during a drain wait.
637
- let slow = new Writable({
638
- highWaterMark: 1,
639
- write(chunk, enc, cb) {
640
- setTimeout(cb, 50);
641
- }
642
- });
678
+ let slow = slowConsumer({ delay: 50 });
643
679
  content.pipe(slow);
644
680
  setTimeout(() => content.destroy(), 70);
645
681
  await new Promise(resolve => {
@@ -18,6 +18,7 @@
18
18
 
19
19
  const net = require('net');
20
20
  const { ImapFlow } = require('../lib/imap-flow');
21
+ const { installRejectionDetector, slowConsumer } = require('./fixtures/test-client');
21
22
 
22
23
  // Create a mock IMAP server with optional custom behavior.
23
24
  // options.extraCapabilities - additional capabilities (e.g., 'IDLE')
@@ -83,24 +84,6 @@ function createMockServer(options) {
83
84
  return server;
84
85
  }
85
86
 
86
- // Helper: install an unhandledRejection detector
87
- function installRejectionDetector(test) {
88
- let unhandled = false;
89
- let unhandledReason = null;
90
- const handler = reason => {
91
- unhandled = true;
92
- unhandledReason = reason;
93
- };
94
- process.on('unhandledRejection', handler);
95
-
96
- return {
97
- check() {
98
- process.removeListener('unhandledRejection', handler);
99
- test.equal(unhandled, false, 'no unhandledRejection should fire' + (unhandledReason ? ': ' + unhandledReason.message : ''));
100
- }
101
- };
102
- }
103
-
104
87
  // Drives a connection to the one state both IDLE-waiter tests need: IDLE issued but never
105
88
  // acknowledged with a "+", so preCheck() cannot send DONE and anything it queues stays queued
106
89
  // until close() tears the IDLE command down. Hands the connected client to `run`, and always
@@ -581,6 +564,91 @@ exports['Unhandled Rejection Prevention'] = {
581
564
  await new Promise(r => setTimeout(r, 100));
582
565
  detector.check();
583
566
 
567
+ client.close();
568
+ } catch (err) {
569
+ detector.check();
570
+ test.ok(false, 'Unexpected error: ' + err.message);
571
+ } finally {
572
+ server.close(() => test.done());
573
+ }
574
+ });
575
+ },
576
+
577
+ 'a download losing its connection mid-chunk should not cause unhandled rejection (Death 3)'(test) {
578
+ // The reported production crash, end to end over a real socket: a chunked download whose
579
+ // consumer is applying backpressure, and a connection that goes away with the next
580
+ // UID FETCH in flight. close() rejects that pending request, and the rejection has to
581
+ // reach the content stream rather than a promise nobody holds.
582
+ test.expect(4);
583
+
584
+ const CHUNK = 64 * 1024;
585
+ const TOTAL = CHUNK * 8;
586
+ let fetchCount = 0;
587
+
588
+ const server = createMockServer({
589
+ onCommand(socket, tag, command) {
590
+ if (command !== 'UID') {
591
+ return;
592
+ }
593
+
594
+ fetchCount++;
595
+ if (fetchCount > 3) {
596
+ // the production event: the socket ends with a UID FETCH pending
597
+ socket.destroy();
598
+ return true;
599
+ }
600
+
601
+ // one BODY[]<offset> chunk, in the literal form download() asks for
602
+ socket.write(`* 1 FETCH (UID 1 RFC822.SIZE ${TOTAL} BODY[]<${(fetchCount - 1) * CHUNK}> {${CHUNK}}\r\n`);
603
+ socket.write(Buffer.alloc(CHUNK, 0x61));
604
+ socket.write(')\r\n');
605
+ socket.write(`${tag} OK FETCH completed\r\n`);
606
+ return true;
607
+ }
608
+ });
609
+
610
+ server.listen(0, '127.0.0.1', async () => {
611
+ const client = new ImapFlow({
612
+ host: '127.0.0.1',
613
+ port: server.address().port,
614
+ secure: false,
615
+ logger: false,
616
+ disableAutoIdle: true,
617
+ auth: { user: 'test', pass: 'test' }
618
+ });
619
+
620
+ // the connection is destroyed under the client on purpose
621
+ client.on('error', () => false);
622
+
623
+ const detector = installRejectionDetector(test);
624
+
625
+ try {
626
+ await client.connect();
627
+ await client.mailboxOpen('INBOX');
628
+
629
+ let { content } = await client.download('1', false, { uid: true, chunkSize: CHUNK });
630
+
631
+ let received = 0;
632
+ let streamErr = null;
633
+ content.on('error', err => {
634
+ streamErr = err;
635
+ });
636
+ // The delay is load-bearing: it has to outlast a loopback round trip, or the
637
+ // pipeline drains between chunks, writeChunk() never returns false, and the
638
+ // backpressure wait this test exists for is never entered
639
+ content.pipe(slowConsumer({ delay: 30, onChunk: chunk => (received += chunk.length) }));
640
+
641
+ await new Promise(resolve => {
642
+ content.on('close', resolve);
643
+ content.on('error', resolve);
644
+ });
645
+ await new Promise(r => setTimeout(r, 100));
646
+ detector.check();
647
+
648
+ test.ok(received >= CHUNK, 'a chunk drained through the consumer before the connection went away');
649
+ test.ok(streamErr, 'the failure surfaced on the content stream');
650
+ test.equal(streamErr && streamErr.code, 'NoConnection', 'the caller gets the connection error');
651
+
584
652
  client.close();
585
653
  } catch (err) {
586
654
  detector.check();