imapflow 1.2.10 → 1.2.12

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.10"
2
+ ".": "1.2.12"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.2.12](https://github.com/postalsys/imapflow/compare/v1.2.11...v1.2.12) (2026-03-05)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * prevent unhandled NoConnection rejection in IDLE recovery ([e177372](https://github.com/postalsys/imapflow/commit/e17737296b6b2de9ba25392b79542cf6d7d45826))
9
+
10
+ ## [1.2.11](https://github.com/postalsys/imapflow/compare/v1.2.10...v1.2.11) (2026-03-03)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * release backpressure in fetch() generator on early break/throw ([d42a377](https://github.com/postalsys/imapflow/commit/d42a377f8102f44fc76d8fa9a268bbf2526a7fa8)), closes [#338](https://github.com/postalsys/imapflow/issues/338)
16
+
3
17
  ## [1.2.10](https://github.com/postalsys/imapflow/compare/v1.2.9...v1.2.10) (2026-02-19)
4
18
 
5
19
 
package/lib/imap-flow.js CHANGED
@@ -856,7 +856,12 @@ class ImapFlow extends EventEmitter {
856
856
  // Attempt to recover IDLE connections
857
857
  this.run('NOOP')
858
858
  .then(() => this.idle())
859
- .catch(this._socketError); // Natural circuit breaker
859
+ .catch(err => {
860
+ this.log.warn({ msg: 'IDLE recovery failed after timeout', err, cid: this.id });
861
+ if (!this.isClosed) {
862
+ this.close();
863
+ }
864
+ });
860
865
  } else {
861
866
  // Close immediately for non-IDLE operations
862
867
  this.log.debug({ msg: 'Socket timeout', cid: this.id });
@@ -2718,6 +2723,7 @@ class ImapFlow extends EventEmitter {
2718
2723
  // two: when the consumer is waiting and the queue is empty, `push` is set to
2719
2724
  // a function that wakes up the consumer when new data arrives.
2720
2725
  let finished = false;
2726
+ let aborted = false;
2721
2727
  let push = false;
2722
2728
  let rowQueue = [];
2723
2729
 
@@ -2755,6 +2761,10 @@ class ImapFlow extends EventEmitter {
2755
2761
  binary: options.binary,
2756
2762
  changedSince: options.changedSince,
2757
2763
  onUntaggedFetch: (untagged, next) => {
2764
+ if (aborted) {
2765
+ next();
2766
+ return;
2767
+ }
2758
2768
  rowQueue.push({
2759
2769
  value: {
2760
2770
  response: untagged,
@@ -2779,26 +2789,36 @@ class ImapFlow extends EventEmitter {
2779
2789
  }
2780
2790
  });
2781
2791
 
2782
- let res;
2783
- while ((res = await getNext())) {
2784
- if (this.isClosed || !this.socket || this.socket.destroyed) {
2785
- let error = new Error('Connection closed');
2786
- error.code = 'EConnectionClosed';
2787
- throw error;
2788
- }
2792
+ let lastRes = null;
2793
+ try {
2794
+ let res;
2795
+ while ((res = await getNext())) {
2796
+ lastRes = res;
2797
+
2798
+ if (this.isClosed || !this.socket || this.socket.destroyed) {
2799
+ let error = new Error('Connection closed');
2800
+ error.code = 'EConnectionClosed';
2801
+ throw error;
2802
+ }
2789
2803
 
2790
- if (res !== null) {
2791
2804
  yield res.response;
2792
2805
  // Signal the FETCH handler to process the next untagged response
2793
2806
  res.next();
2807
+ lastRes = null;
2808
+ }
2809
+ } finally {
2810
+ aborted = true;
2811
+ // Release backpressure for the item that was yielded but whose
2812
+ // next() was not yet called (happens on break/return/throw)
2813
+ if (lastRes && typeof lastRes.next === 'function') {
2814
+ lastRes.next();
2815
+ }
2816
+ while (rowQueue.length) {
2817
+ let entry = rowQueue.shift();
2818
+ if (entry.value && typeof entry.value.next === 'function') {
2819
+ entry.value.next();
2820
+ }
2794
2821
  }
2795
- }
2796
-
2797
- if (!finished) {
2798
- // FETCH never finished!
2799
- let error = new Error('FETCH did not finish');
2800
- error.code = 'ENotFinished';
2801
- throw error;
2802
2822
  }
2803
2823
  }
2804
2824
 
package/lib/tools.js CHANGED
@@ -206,11 +206,7 @@ const tools = {
206
206
  if (cur) {
207
207
  node = cur;
208
208
  } else {
209
- // not yet set
210
- cur = {
211
- name: parent,
212
- folders: []
213
- };
209
+ // not yet set, will be created by the caller
214
210
  }
215
211
  }
216
212
 
@@ -901,7 +897,6 @@ const tools = {
901
897
  if (node[i]) {
902
898
  curNode.location = ((node[i] || {}).value || '').toString();
903
899
  }
904
- i++;
905
900
  }
906
901
 
907
902
  return curNode;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "1.2.10",
3
+ "version": "1.2.12",
4
4
  "description": "IMAP Client for Node",
5
5
  "main": "lib/imap-flow.js",
6
6
  "types": "lib/imap-flow.d.ts",
@@ -27,10 +27,10 @@
27
27
  },
28
28
  "homepage": "https://imapflow.com/",
29
29
  "devDependencies": {
30
- "@eslint/js": "9.39.2",
31
- "@types/node": "25.3.0",
32
- "c8": "10.1.3",
33
- "eslint": "9.39.2",
30
+ "@eslint/js": "10.0.1",
31
+ "@types/node": "25.3.3",
32
+ "c8": "11.0.0",
33
+ "eslint": "10.0.2",
34
34
  "eslint-config-nodemailer": "1.2.0",
35
35
  "eslint-config-prettier": "10.1.8",
36
36
  "grunt": "1.6.1",
@@ -234,237 +234,6 @@ module.exports['Process correct TEXT with line count'] = test => {
234
234
  null
235
235
  ];
236
236
 
237
- attribute = [
238
- [
239
- [
240
- {
241
- type: 'STRING',
242
- value: 'text'
243
- },
244
- {
245
- type: 'STRING',
246
- value: 'plain'
247
- },
248
- [
249
- {
250
- type: 'STRING',
251
- value: 'charset'
252
- },
253
- {
254
- type: 'STRING',
255
- value: 'UTF-8'
256
- }
257
- ],
258
- null,
259
- null,
260
- {
261
- type: 'STRING',
262
- value: '7bit'
263
- },
264
- {
265
- type: 'ATOM',
266
- value: '23'
267
- },
268
- {
269
- type: 'ATOM',
270
- value: '1'
271
- },
272
- null,
273
- null,
274
- null,
275
- null
276
- ],
277
- [
278
- {
279
- type: 'STRING',
280
- value: 'text'
281
- },
282
- {
283
- type: 'STRING',
284
- value: 'html'
285
- },
286
- [
287
- {
288
- type: 'STRING',
289
- value: 'charset'
290
- },
291
- {
292
- type: 'STRING',
293
- value: 'UTF-8'
294
- }
295
- ],
296
- null,
297
- null,
298
- {
299
- type: 'STRING',
300
- value: '7bit'
301
- },
302
- {
303
- type: 'ATOM',
304
- value: '49'
305
- },
306
- {
307
- type: 'ATOM',
308
- value: '1'
309
- },
310
- null,
311
- null,
312
- null,
313
- null
314
- ],
315
- {
316
- type: 'STRING',
317
- value: 'alternative'
318
- },
319
- [
320
- {
321
- type: 'STRING',
322
- value: 'boundary'
323
- },
324
- {
325
- type: 'STRING',
326
- value: '000000000000b56402062b1fba83'
327
- }
328
- ],
329
- null,
330
- null,
331
- null
332
- ],
333
- [
334
- {
335
- type: 'STRING',
336
- value: 'text'
337
- },
338
- {
339
- type: 'STRING',
340
- value: 'plain'
341
- },
342
- [
343
- {
344
- type: 'STRING',
345
- value: 'charset'
346
- },
347
- {
348
- type: 'STRING',
349
- value: 'US-ASCII'
350
- },
351
- {
352
- type: 'STRING',
353
- value: 'name'
354
- },
355
- {
356
- type: 'STRING',
357
- value: 'log_imap_missing_attachments.txt'
358
- }
359
- ],
360
- {
361
- type: 'STRING',
362
- value: '<f_m5mnf91y1>'
363
- },
364
- null,
365
- {
366
- type: 'STRING',
367
- value: 'base64'
368
- },
369
- {
370
- type: 'ATOM',
371
- value: '33954'
372
- },
373
- {
374
- type: 'ATOM',
375
- value: '435'
376
- },
377
- null,
378
- [
379
- {
380
- type: 'STRING',
381
- value: 'attachment'
382
- },
383
- [
384
- {
385
- type: 'STRING',
386
- value: 'filename'
387
- },
388
- {
389
- type: 'STRING',
390
- value: 'log_imap_missing_attachments.txt'
391
- }
392
- ]
393
- ],
394
- null,
395
- null
396
- ],
397
- [
398
- {
399
- type: 'STRING',
400
- value: 'image'
401
- },
402
- {
403
- type: 'STRING',
404
- value: 'jpeg'
405
- },
406
- [
407
- {
408
- type: 'STRING',
409
- value: 'name'
410
- },
411
- {
412
- type: 'STRING',
413
- value: 'img2.jpeg'
414
- }
415
- ],
416
- {
417
- type: 'STRING',
418
- value: '<f_m5mnf4c70>'
419
- },
420
- null,
421
- {
422
- type: 'STRING',
423
- value: 'base64'
424
- },
425
- {
426
- type: 'ATOM',
427
- value: '3776960'
428
- },
429
- null,
430
- [
431
- {
432
- type: 'STRING',
433
- value: 'attachment'
434
- },
435
- [
436
- {
437
- type: 'STRING',
438
- value: 'filename'
439
- },
440
- {
441
- type: 'STRING',
442
- value: 'img2.jpeg'
443
- }
444
- ]
445
- ],
446
- null,
447
- null
448
- ],
449
- {
450
- type: 'STRING',
451
- value: 'mixed'
452
- },
453
- [
454
- {
455
- type: 'STRING',
456
- value: 'boundary'
457
- },
458
- {
459
- type: 'STRING',
460
- value: '000000000000b56402062b1fba85'
461
- }
462
- ],
463
- null,
464
- null,
465
- null
466
- ];
467
-
468
237
  let bodyStruct = parseBodystructure(attribute);
469
238
 
470
239
  test.deepEqual(bodyStruct, {
@@ -0,0 +1,216 @@
1
+ 'use strict';
2
+
3
+ const { ImapFlow } = require('../lib/imap-flow');
4
+
5
+ // Helper: create a minimal mock context with the fetch() generator bound to it.
6
+ // The `run` override controls how untagged FETCH responses are delivered.
7
+ const createFetchContext = runOverride => ({
8
+ mailbox: { path: 'INBOX', exists: 10 },
9
+ isClosed: false,
10
+ socket: { destroyed: false },
11
+ resolveRange: async range => range,
12
+ run: runOverride
13
+ });
14
+
15
+ // Helper: invoke the fetch generator on a mock context
16
+ function callFetch(ctx, range, query, options) {
17
+ return ImapFlow.prototype.fetch.call(ctx, range, query, options);
18
+ }
19
+
20
+ // Helper: create a mock `run` that delivers `count` untagged FETCH responses,
21
+ // each with a `next` callback for backpressure. Returns a tracking object.
22
+ function createMockRun(count) {
23
+ const tracker = {
24
+ nextCalls: [], // records which message indices had next() called
25
+ onUntaggedFetch: null,
26
+ runResolved: false
27
+ };
28
+
29
+ const run = async (_cmd, _range, _query, opts) => {
30
+ tracker.onUntaggedFetch = opts.onUntaggedFetch;
31
+
32
+ for (let i = 0; i < count; i++) {
33
+ await new Promise(resolve => {
34
+ opts.onUntaggedFetch({ seq: i + 1, uid: 100 + i }, () => {
35
+ tracker.nextCalls.push(i);
36
+ resolve();
37
+ });
38
+ });
39
+ }
40
+ tracker.runResolved = true;
41
+ };
42
+
43
+ return { tracker, run };
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Test: early break releases backpressure
48
+ // ---------------------------------------------------------------------------
49
+ module.exports['fetch generator: early break releases backpressure'] = async test => {
50
+ const { tracker, run } = createMockRun(5);
51
+ const ctx = createFetchContext(run);
52
+
53
+ const gen = callFetch(ctx, '1:5', { uid: true });
54
+ // Consume only the first message and break
55
+ for await (let msg of gen) {
56
+ test.ok(msg.seq === 1, 'should receive the first message');
57
+ break;
58
+ }
59
+
60
+ // Allow microtasks to flush (the finally block runs synchronously with
61
+ // generator return, but the remaining run() deliveries are async)
62
+ await new Promise(r => setTimeout(r, 50));
63
+
64
+ // The first message had next() called by the yield loop (res.next()).
65
+ // Remaining queued/future messages should have next() called by the
66
+ // finally block or the aborted guard.
67
+ test.equal(tracker.nextCalls.length, 5, 'all 5 messages should have next() called');
68
+ test.done();
69
+ };
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Test: subsequent operations work after early exit
73
+ // ---------------------------------------------------------------------------
74
+ module.exports['fetch generator: connection usable after early break'] = async test => {
75
+ let runCallCount = 0;
76
+
77
+ const run = async (_cmd, _range, _query, opts) => {
78
+ runCallCount++;
79
+ // Deliver 3 messages
80
+ for (let i = 0; i < 3; i++) {
81
+ await new Promise(resolve => {
82
+ opts.onUntaggedFetch({ seq: i + 1, uid: 200 + i }, () => resolve());
83
+ });
84
+ }
85
+ };
86
+
87
+ const ctx = createFetchContext(run);
88
+
89
+ // First fetch: break early
90
+ const gen1 = callFetch(ctx, '1:3', { uid: true });
91
+ for await (let msg of gen1) {
92
+ test.ok(msg.seq === 1);
93
+ break;
94
+ }
95
+
96
+ await new Promise(r => setTimeout(r, 50));
97
+
98
+ // Second fetch: should work (run gets called again)
99
+ const gen2 = callFetch(ctx, '1:3', { uid: true });
100
+ let secondFetchMessages = [];
101
+ for await (let msg of gen2) {
102
+ secondFetchMessages.push(msg);
103
+ }
104
+
105
+ test.ok(runCallCount === 2, 'run was called for both fetch operations');
106
+ test.ok(secondFetchMessages.length === 3, 'second fetch received all 3 messages');
107
+ test.done();
108
+ };
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Test: normal full iteration works correctly
112
+ // ---------------------------------------------------------------------------
113
+ module.exports['fetch generator: normal full iteration receives all messages'] = async test => {
114
+ const { tracker, run } = createMockRun(4);
115
+ const ctx = createFetchContext(run);
116
+
117
+ let messages = [];
118
+ for await (let msg of callFetch(ctx, '1:4', { uid: true })) {
119
+ messages.push(msg);
120
+ }
121
+
122
+ test.equal(messages.length, 4, 'should receive all 4 messages');
123
+ test.equal(messages[0].seq, 1);
124
+ test.equal(messages[3].seq, 4);
125
+ test.equal(tracker.nextCalls.length, 4, 'all next() callbacks were called');
126
+ test.done();
127
+ };
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Test: error thrown in loop body releases backpressure
131
+ // ---------------------------------------------------------------------------
132
+ module.exports['fetch generator: error in loop body releases backpressure'] = async test => {
133
+ const { tracker, run } = createMockRun(5);
134
+ const ctx = createFetchContext(run);
135
+
136
+ const gen = callFetch(ctx, '1:5', { uid: true });
137
+
138
+ let caught = false;
139
+ try {
140
+ for await (let msg of gen) {
141
+ if (msg.seq === 2) {
142
+ throw new Error('intentional test error');
143
+ }
144
+ }
145
+ } catch (err) {
146
+ caught = true;
147
+ test.equal(err.message, 'intentional test error');
148
+ }
149
+
150
+ await new Promise(r => setTimeout(r, 50));
151
+
152
+ test.ok(caught, 'error was caught');
153
+ // Messages 0 and 1 had next() called by the yield loop. The rest should
154
+ // be drained by the finally block or the aborted guard.
155
+ test.equal(tracker.nextCalls.length, 5, 'all 5 messages should have next() called');
156
+ test.done();
157
+ };
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Test: zero messages from server
161
+ // ---------------------------------------------------------------------------
162
+ module.exports['fetch generator: zero messages yields nothing'] = async test => {
163
+ const { tracker, run } = createMockRun(0);
164
+ const ctx = createFetchContext(run);
165
+
166
+ let messages = [];
167
+ for await (let msg of callFetch(ctx, '1:*', { uid: true })) {
168
+ messages.push(msg);
169
+ }
170
+
171
+ test.equal(messages.length, 0, 'no messages should be yielded');
172
+ test.equal(tracker.nextCalls.length, 0, 'no next() calls needed');
173
+ test.ok(tracker.runResolved, 'run should have resolved');
174
+ test.done();
175
+ };
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Test: connection closed during iteration throws and releases backpressure
179
+ // ---------------------------------------------------------------------------
180
+ module.exports['fetch generator: connection closed mid-iteration throws and cleans up'] = async test => {
181
+ const tracker = {
182
+ nextCalls: []
183
+ };
184
+
185
+ const run = async (_cmd, _range, _query, opts) => {
186
+ for (let i = 0; i < 3; i++) {
187
+ await new Promise(resolve => {
188
+ opts.onUntaggedFetch({ seq: i + 1, uid: 300 + i }, () => {
189
+ tracker.nextCalls.push(i);
190
+ resolve();
191
+ });
192
+ });
193
+ }
194
+ };
195
+
196
+ const ctx = createFetchContext(run);
197
+
198
+ let caught = false;
199
+ try {
200
+ for await (let msg of callFetch(ctx, '1:3', { uid: true })) {
201
+ if (msg.seq === 1) {
202
+ // Simulate connection closing after first message
203
+ ctx.isClosed = true;
204
+ }
205
+ }
206
+ } catch (err) {
207
+ caught = true;
208
+ test.equal(err.code, 'EConnectionClosed', 'should throw EConnectionClosed');
209
+ }
210
+
211
+ await new Promise(r => setTimeout(r, 50));
212
+
213
+ test.ok(caught, 'error was caught');
214
+ test.equal(tracker.nextCalls.length, 3, 'all next() callbacks should be called for cleanup');
215
+ test.done();
216
+ };