imapflow 1.2.9 → 1.2.11

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.9"
2
+ ".": "1.2.11"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.2.11](https://github.com/postalsys/imapflow/compare/v1.2.10...v1.2.11) (2026-03-03)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * 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)
9
+
10
+ ## [1.2.10](https://github.com/postalsys/imapflow/compare/v1.2.9...v1.2.10) (2026-02-19)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * add null guards in compress() to prevent crash on TLS teardown ([84cd3ff](https://github.com/postalsys/imapflow/commit/84cd3ff8815d8ba37a37c1e7a09c244e80b61948))
16
+
3
17
  ## [1.2.9](https://github.com/postalsys/imapflow/compare/v1.2.8...v1.2.9) (2026-02-06)
4
18
 
5
19
 
package/README.md CHANGED
@@ -1,109 +1,87 @@
1
1
  # ImapFlow
2
2
 
3
- ImapFlow is a modern and easy-to-use IMAP client library for Node.js.
3
+ Modern and easy-to-use IMAP client library for Node.js.
4
4
 
5
- > [!NOTE]
6
- > Managing an IMAP connection is cool, but if you are only looking for an easy way to integrate email accounts, then ImapFlow was built for [EmailEngine Email API](https://emailengine.app/). It's a self-hosted software that converts all IMAP accounts to easy-to-use REST interfaces.
7
-
8
- The focus for ImapFlow is to provide easy to use API over IMAP. Using ImapFlow does not expect knowledge about specific IMAP details. A general understanding is good enough.
9
-
10
- IMAP extensions are handled in the background, so, for example, you can always request `labels` value from a `fetch()` call, but if the IMAP server does not support `X-GM-EXT-1` extension, then `labels` value is not included in the response.
5
+ [![npm](https://img.shields.io/npm/v/imapflow)](https://www.npmjs.com/package/imapflow)
6
+ [![license](https://img.shields.io/npm/l/imapflow)](https://github.com/postalsys/imapflow/blob/master/LICENSE)
11
7
 
12
- ## Source
8
+ ImapFlow provides a clean, promise-based API for working with IMAP, so you don't need in-depth knowledge of the protocol. IMAP extensions are detected and handled automatically. You write the same code regardless of server capabilities, and ImapFlow adapts behind the scenes.
13
9
 
14
- Source code is available from [Github](https://github.com/postalsys/imapflow).
10
+ ## Features
15
11
 
16
- ## Usage
12
+ - **Async/await API** - all methods return Promises
13
+ - **Automatic IMAP extension handling** - CONDSTORE, QRESYNC, IDLE, COMPRESS, and [more](https://imapflow.com/docs/)
14
+ - **Message streaming** - async iterators for efficient processing
15
+ - **Mailbox locking** - built-in locking mechanism for safe concurrent access
16
+ - **TypeScript support** - type definitions included
17
+ - **Proxy support** - SOCKS and HTTP CONNECT proxies
18
+ - **Gmail support** - labels, raw search via X-GM-EXT-1
17
19
 
18
- First install the module from npm:
20
+ ## Installation
19
21
 
20
- ```
22
+ ```bash
21
23
  npm install imapflow
22
24
  ```
23
25
 
24
- next import the ImapFlow class into your script:
26
+ ## Quick Example
25
27
 
26
28
  ```js
27
29
  const { ImapFlow } = require('imapflow');
28
- ```
29
-
30
- ### Promises
31
30
 
32
- All ImapFlow methods use Promises, so you need to wait using `await` or wait for the `then()` method to fire until you get the response.
33
-
34
- ```js
35
- const { ImapFlow } = require('imapflow');
36
31
  const client = new ImapFlow({
37
- host: 'ethereal.email',
32
+ host: 'imap.example.com',
38
33
  port: 993,
39
34
  secure: true,
40
35
  auth: {
41
- user: 'garland.mcclure71@ethereal.email',
42
- pass: 'mW6e4wWWnEd3H4hT5B'
36
+ user: 'user@example.com',
37
+ pass: 'password'
43
38
  }
44
39
  });
45
40
 
46
41
  const main = async () => {
47
- // Wait until client connects and authorizes
48
42
  await client.connect();
49
43
 
50
- // Select and lock a mailbox. Throws if mailbox does not exist
51
44
  let lock = await client.getMailboxLock('INBOX');
52
45
  try {
53
- // fetch latest message source
54
- // client.mailbox includes information about currently selected mailbox
55
- // "exists" value is also the largest sequence number available in the mailbox
46
+ // fetch latest message
56
47
  let message = await client.fetchOne(client.mailbox.exists, { source: true });
57
48
  console.log(message.source.toString());
58
49
 
59
50
  // list subjects for all messages
60
- // uid value is always included in FETCH response, envelope strings are in unicode.
61
51
  for await (let message of client.fetch('1:*', { envelope: true })) {
62
52
  console.log(`${message.uid}: ${message.envelope.subject}`);
63
53
  }
64
54
  } finally {
65
- // Make sure lock is released, otherwise next `getMailboxLock()` never returns
55
+ // always release the lock
66
56
  lock.release();
67
57
  }
68
58
 
69
- // log out and close connection
70
59
  await client.logout();
71
60
  };
72
61
 
73
- main().catch(err => console.error(err));
62
+ main().catch(console.error);
74
63
  ```
75
64
 
76
- ### Admin Impersonation / Delegation (SASL PLAIN with authzid)
65
+ See the [Quick Start guide](https://imapflow.com/docs/getting-started/quick-start) for more examples, including Gmail, Outlook, and Yahoo configuration.
77
66
 
78
- ImapFlow supports admin impersonation for mail systems like Zimbra that allow administrators to access user mailboxes. This is done using the SASL PLAIN mechanism with an authorization identity (`authzid`).
79
-
80
- ```js
81
- const { ImapFlow } = require('imapflow');
82
- const client = new ImapFlow({
83
- host: 'mail.example.com',
84
- port: 993,
85
- secure: true,
86
- auth: {
87
- user: 'admin@example.com', // Admin credentials (authentication identity)
88
- pass: 'adminpassword',
89
- authzid: 'user@example.com', // User to impersonate (authorization identity)
90
- loginMethod: 'AUTH=PLAIN' // Must use PLAIN mechanism for authzid
91
- }
92
- });
67
+ ## Documentation
93
68
 
94
- // Connection will authenticate as admin but authorize as the specified user
95
- await client.connect();
96
- // Now operating on user@example.com's mailbox as admin
97
- ```
69
+ Full documentation is available at **[imapflow.com](https://imapflow.com/docs/)**.
98
70
 
99
- **Note:** The `authzid` parameter only works with the `AUTH=PLAIN` mechanism. The server must support admin delegation/impersonation for this to work.
71
+ - [Installation](https://imapflow.com/docs/getting-started/installation) - requirements and setup
72
+ - [Quick Start](https://imapflow.com/docs/getting-started/quick-start) - your first ImapFlow application
73
+ - [Basic Usage](https://imapflow.com/docs/guides/basic-usage) - core concepts and patterns
74
+ - [Configuration](https://imapflow.com/docs/guides/configuration) - connection options and settings
75
+ - [Fetching Messages](https://imapflow.com/docs/guides/fetching-messages) - reading email data
76
+ - [Searching](https://imapflow.com/docs/guides/searching) - finding messages with search queries
77
+ - [Mailbox Management](https://imapflow.com/docs/guides/mailbox-management) - creating, renaming, and deleting mailboxes
78
+ - [API Reference](https://imapflow.com/docs/api/imapflow-client) - complete method and event documentation
100
79
 
101
- ## Documentation
102
-
103
- [API reference](https://imapflow.com/docs/api/imapflow-client).
80
+ > [!NOTE]
81
+ > If you are looking for a complete email integration solution, ImapFlow was built for [EmailEngine](https://emailengine.app/), a self-hosted email gateway that provides REST API access to IMAP and SMTP accounts.
104
82
 
105
83
  ## License
106
84
 
107
- © 2020-2025 Postal Systems OÜ
85
+ Copyright (c) 2020-2025 Postal Systems OU
108
86
 
109
- Licensed under **MIT-license**
87
+ Licensed under the MIT license.
package/eslint.config.js CHANGED
@@ -1,38 +1,40 @@
1
1
  'use strict';
2
2
 
3
- const { FlatCompat } = require('@eslint/eslintrc');
4
3
  const js = require('@eslint/js');
5
-
6
- const compat = new FlatCompat({
7
- baseDirectory: __dirname,
8
- recommendedConfig: js.configs.recommended
9
- });
4
+ const globals = require('globals');
5
+ const prettierConfig = require('eslint-config-prettier/flat');
6
+ const nodemailerConfig = require('eslint-config-nodemailer');
10
7
 
11
8
  module.exports = [
12
9
  {
13
10
  ignores: ['node_modules/**', 'examples/**', 'docs/**']
14
11
  },
15
- ...compat.extends('nodemailer', 'prettier'),
12
+ js.configs.recommended,
16
13
  {
17
14
  languageOptions: {
18
- ecmaVersion: 2020,
15
+ ecmaVersion: 2022,
19
16
  sourceType: 'script',
20
- globals: {
21
- BigInt: 'readonly'
22
- },
23
- parser: require('@babel/eslint-parser'),
24
17
  parserOptions: {
25
- requireConfigFile: false
18
+ ecmaFeatures: {
19
+ globalReturn: true
20
+ }
21
+ },
22
+ globals: {
23
+ ...globals.node,
24
+ ...globals.es2021,
25
+ it: 'readonly',
26
+ describe: 'readonly',
27
+ beforeEach: 'readonly',
28
+ afterEach: 'readonly'
26
29
  }
27
30
  },
28
- plugins: {
29
- '@babel': require('@babel/eslint-plugin')
30
- },
31
31
  rules: {
32
+ ...nodemailerConfig.rules,
32
33
  'no-await-in-loop': 0,
33
34
  'require-atomic-updates': 0
34
35
  }
35
36
  },
37
+ prettierConfig,
36
38
  {
37
39
  files: ['eslint.config.js', '.prettierrc.js', '.ncurc.js'],
38
40
  rules: {
package/lib/imap-flow.js CHANGED
@@ -1018,7 +1018,7 @@ class ImapFlow extends EventEmitter {
1018
1018
  processedChunks = 0;
1019
1019
 
1020
1020
  let chunk;
1021
- while ((chunk = this.writeSocket.read()) !== null) {
1021
+ while (this.writeSocket && (chunk = this.writeSocket.read()) !== null) {
1022
1022
  if (this._deflate && this._deflate.write(chunk) === false) {
1023
1023
  return this._deflate.once('drain', readNext);
1024
1024
  }
@@ -1027,6 +1027,9 @@ class ImapFlow extends EventEmitter {
1027
1027
  processedChunks++;
1028
1028
  if (processedChunks % 100 === 0) {
1029
1029
  await new Promise(resolve => setImmediate(resolve));
1030
+ if (!this.writeSocket) {
1031
+ break;
1032
+ }
1030
1033
  }
1031
1034
  }
1032
1035
 
@@ -1042,17 +1045,21 @@ class ImapFlow extends EventEmitter {
1042
1045
  };
1043
1046
 
1044
1047
  this.writeSocket.on('readable', () => {
1045
- if (!reading) {
1048
+ if (!reading && this.writeSocket) {
1046
1049
  readNext();
1047
1050
  }
1048
1051
  });
1049
1052
  this.writeSocket.on('error', err => {
1050
- this.socket.emit('error', err);
1053
+ if (this.socket) {
1054
+ this.socket.emit('error', err);
1055
+ }
1051
1056
  });
1052
1057
 
1053
1058
  this._deflate.pipe(this.socket);
1054
1059
  this._deflate.on('error', err => {
1055
- this.socket.emit('error', err);
1060
+ if (this.socket) {
1061
+ this.socket.emit('error', err);
1062
+ }
1056
1063
  });
1057
1064
  }
1058
1065
 
@@ -2711,6 +2718,7 @@ class ImapFlow extends EventEmitter {
2711
2718
  // two: when the consumer is waiting and the queue is empty, `push` is set to
2712
2719
  // a function that wakes up the consumer when new data arrives.
2713
2720
  let finished = false;
2721
+ let aborted = false;
2714
2722
  let push = false;
2715
2723
  let rowQueue = [];
2716
2724
 
@@ -2748,6 +2756,10 @@ class ImapFlow extends EventEmitter {
2748
2756
  binary: options.binary,
2749
2757
  changedSince: options.changedSince,
2750
2758
  onUntaggedFetch: (untagged, next) => {
2759
+ if (aborted) {
2760
+ next();
2761
+ return;
2762
+ }
2751
2763
  rowQueue.push({
2752
2764
  value: {
2753
2765
  response: untagged,
@@ -2772,26 +2784,36 @@ class ImapFlow extends EventEmitter {
2772
2784
  }
2773
2785
  });
2774
2786
 
2775
- let res;
2776
- while ((res = await getNext())) {
2777
- if (this.isClosed || !this.socket || this.socket.destroyed) {
2778
- let error = new Error('Connection closed');
2779
- error.code = 'EConnectionClosed';
2780
- throw error;
2781
- }
2787
+ let lastRes = null;
2788
+ try {
2789
+ let res;
2790
+ while ((res = await getNext())) {
2791
+ lastRes = res;
2792
+
2793
+ if (this.isClosed || !this.socket || this.socket.destroyed) {
2794
+ let error = new Error('Connection closed');
2795
+ error.code = 'EConnectionClosed';
2796
+ throw error;
2797
+ }
2782
2798
 
2783
- if (res !== null) {
2784
2799
  yield res.response;
2785
2800
  // Signal the FETCH handler to process the next untagged response
2786
2801
  res.next();
2802
+ lastRes = null;
2803
+ }
2804
+ } finally {
2805
+ aborted = true;
2806
+ // Release backpressure for the item that was yielded but whose
2807
+ // next() was not yet called (happens on break/return/throw)
2808
+ if (lastRes && typeof lastRes.next === 'function') {
2809
+ lastRes.next();
2810
+ }
2811
+ while (rowQueue.length) {
2812
+ let entry = rowQueue.shift();
2813
+ if (entry.value && typeof entry.value.next === 'function') {
2814
+ entry.value.next();
2815
+ }
2787
2816
  }
2788
- }
2789
-
2790
- if (!finished) {
2791
- // FETCH never finished!
2792
- let error = new Error('FETCH did not finish');
2793
- error.code = 'ENotFinished';
2794
- throw error;
2795
2817
  }
2796
2818
  }
2797
2819
 
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.9",
3
+ "version": "1.2.11",
4
4
  "description": "IMAP Client for Node",
5
5
  "main": "lib/imap-flow.js",
6
6
  "types": "lib/imap-flow.d.ts",
@@ -27,21 +27,16 @@
27
27
  },
28
28
  "homepage": "https://imapflow.com/",
29
29
  "devDependencies": {
30
- "@babel/eslint-parser": "7.28.6",
31
- "@babel/eslint-plugin": "7.27.1",
32
- "@babel/plugin-syntax-class-properties": "7.12.13",
33
- "@babel/preset-env": "7.29.0",
34
- "@eslint/eslintrc": "3.3.3",
35
- "@eslint/js": "9.39.2",
36
- "@types/node": "25.2.1",
37
- "c8": "10.1.3",
38
- "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",
39
34
  "eslint-config-nodemailer": "1.2.0",
40
35
  "eslint-config-prettier": "10.1.8",
41
36
  "grunt": "1.6.1",
42
37
  "grunt-cli": "1.5.0",
43
38
  "grunt-contrib-nodeunit": "5.0.0",
44
- "grunt-eslint": "24.3.0",
39
+ "grunt-eslint": "26.0.0",
45
40
  "prettier": "3.8.1",
46
41
  "proxyquire": "^2.1.3",
47
42
  "typescript": "5.9.3"
@@ -53,8 +48,8 @@
53
48
  "libbase64": "1.3.0",
54
49
  "libmime": "5.3.7",
55
50
  "libqp": "2.1.1",
56
- "nodemailer": "8.0.0",
57
- "pino": "10.3.0",
51
+ "nodemailer": "8.0.1",
52
+ "pino": "10.3.1",
58
53
  "socks": "2.8.7"
59
54
  }
60
55
  }
@@ -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, {
@@ -1464,3 +1464,108 @@ module.exports['Connection Edge: connect throws if called twice'] = async test =
1464
1464
 
1465
1465
  test.done();
1466
1466
  };
1467
+
1468
+ // Helper to create a mock client with compression enabled
1469
+ async function setupCompressedClient() {
1470
+ let client = new ImapFlow({
1471
+ host: 'imap.example.com',
1472
+ port: 993,
1473
+ auth: { user: 'test', pass: 'test' }
1474
+ });
1475
+
1476
+ let mockSocket = new EventEmitter();
1477
+ mockSocket.pipe = dest => dest;
1478
+ mockSocket.unpipe = () => {};
1479
+ mockSocket.destroy = () => {};
1480
+ mockSocket.destroyed = false;
1481
+ client.socket = mockSocket;
1482
+ client.streamer = new EventEmitter();
1483
+
1484
+ client.run = async command => {
1485
+ if (command === 'COMPRESS') {
1486
+ return true;
1487
+ }
1488
+ };
1489
+
1490
+ await client.compress();
1491
+ return { client, mockSocket };
1492
+ }
1493
+
1494
+ module.exports['Connection Edge: compress writeSocket error forwarded to socket when live'] = async test => {
1495
+ let { client, mockSocket } = await setupCompressedClient();
1496
+
1497
+ let errorReceived = false;
1498
+ mockSocket.on('error', err => {
1499
+ errorReceived = true;
1500
+ test.equal(err.message, 'writeSocket error');
1501
+ });
1502
+
1503
+ client.writeSocket.emit('error', new Error('writeSocket error'));
1504
+
1505
+ test.ok(errorReceived, 'Error should be forwarded to socket');
1506
+ test.done();
1507
+ };
1508
+
1509
+ module.exports['Connection Edge: compress _deflate error forwarded to socket when live'] = async test => {
1510
+ let { client, mockSocket } = await setupCompressedClient();
1511
+
1512
+ let errorReceived = false;
1513
+ mockSocket.on('error', err => {
1514
+ errorReceived = true;
1515
+ test.equal(err.message, 'deflate error');
1516
+ });
1517
+
1518
+ client._deflate.emit('error', new Error('deflate error'));
1519
+
1520
+ test.ok(errorReceived, 'Error should be forwarded to socket');
1521
+ test.done();
1522
+ };
1523
+
1524
+ module.exports['Connection Edge: compress readable after close does not crash'] = async test => {
1525
+ let { client } = await setupCompressedClient();
1526
+
1527
+ // Save reference before close() nulls it
1528
+ let writeSocket = client.writeSocket;
1529
+
1530
+ client.close();
1531
+
1532
+ // Emit readable on the saved reference after close has nulled this.writeSocket
1533
+ // This should not throw (guards at lines 1048 and 1021)
1534
+ test.doesNotThrow(() => {
1535
+ writeSocket.emit('readable');
1536
+ });
1537
+
1538
+ test.done();
1539
+ };
1540
+
1541
+ module.exports['Connection Edge: compress writeSocket error after close does not crash'] = async test => {
1542
+ let { client } = await setupCompressedClient();
1543
+
1544
+ let writeSocket = client.writeSocket;
1545
+
1546
+ client.close();
1547
+
1548
+ // Emit error on saved writeSocket after close has nulled this.socket
1549
+ // This should not throw (guard at lines 1053-1055)
1550
+ test.doesNotThrow(() => {
1551
+ writeSocket.emit('error', new Error('late writeSocket error'));
1552
+ });
1553
+
1554
+ test.done();
1555
+ };
1556
+
1557
+ module.exports['Connection Edge: compress _deflate error after close does not crash'] = async test => {
1558
+ let { client } = await setupCompressedClient();
1559
+
1560
+ let deflate = client._deflate;
1561
+
1562
+ client.close();
1563
+
1564
+ // Emit error on saved _deflate after close has nulled this.socket
1565
+ // This should not throw (guard at lines 1060-1062)
1566
+ test.doesNotThrow(() => {
1567
+ deflate.emit('error', new Error('late deflate error'));
1568
+ });
1569
+
1570
+ test.done();
1571
+ };
@@ -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
+ };
package/.babelrc DELETED
@@ -1,6 +0,0 @@
1
- {
2
- "presets": ["@babel/env"],
3
- "plugins": [
4
- "@babel/plugin-syntax-class-properties"
5
- ]
6
- }
package/.eslintrc DELETED
@@ -1,16 +0,0 @@
1
- {
2
- "rules": {
3
- "no-await-in-loop": 0,
4
- "require-atomic-updates": 0
5
- },
6
- "globals": {
7
- "BigInt": true
8
- },
9
- "extends": ["nodemailer", "prettier"],
10
- "parser": "@babel/eslint-parser",
11
- "parserOptions": {
12
- "ecmaVersion": 2020,
13
- "sourceType": "script"
14
- },
15
- "plugins": ["@babel"]
16
- }