imapflow 2.2.9 → 2.2.10

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.2.10](https://github.com/postalsys/imapflow/compare/v2.2.9...v2.2.10) (2026-10-07)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * log socket errors after a failed STARTTLS upgrade instead of throwing them on Bun ([74fad57](https://github.com/postalsys/imapflow/commit/74fad57386cde6a60211b9b1671228b72aa9f94a))
9
+ * take the requested message in fetchOne() when a FETCH answer also carries unsolicited rows ([74fad57](https://github.com/postalsys/imapflow/commit/74fad57386cde6a60211b9b1671228b72aa9f94a)), closes [#426](https://github.com/postalsys/imapflow/issues/426)
10
+
3
11
  ## [2.2.9](https://github.com/postalsys/imapflow/compare/v2.2.8...v2.2.9) (2026-10-07)
4
12
 
5
13
 
@@ -1512,6 +1512,13 @@ class ImapFlow extends node_events_1.EventEmitter {
1512
1512
  // socket event cannot re-enter an already settled upgrade or leave state behind.
1513
1513
  const settle = (err, result) => {
1514
1514
  if (settled) {
1515
+ // A failed handshake can produce more than one error (Bun emits ECONNRESET on
1516
+ // the TLS socket after the one that settled the upgrade). settle() stays the
1517
+ // error listener of both sockets until they are torn down, so later errors
1518
+ // end up here instead of being thrown as unhandled 'error' events.
1519
+ if (err) {
1520
+ this.log.debug({ msg: 'Socket error after the TLS upgrade was settled', err, cid: this.id });
1521
+ }
1515
1522
  return;
1516
1523
  }
1517
1524
  settled = true;
@@ -1519,9 +1526,12 @@ class ImapFlow extends node_events_1.EventEmitter {
1519
1526
  this.upgradeTimeout = null;
1520
1527
  this.upgrading = false;
1521
1528
  this._upgradeReject = null;
1522
- socketPlain.removeListener('error', settle);
1523
- if (this.socket && this.socket !== socketPlain) {
1524
- this.socket.removeListener('error', settle);
1529
+ if (!err) {
1530
+ // the generic socket handlers took over in the success callback
1531
+ socketPlain.removeListener('error', settle);
1532
+ if (this.socket && this.socket !== socketPlain) {
1533
+ this.socket.removeListener('error', settle);
1534
+ }
1525
1535
  }
1526
1536
  if (err) {
1527
1537
  (0, tools_js_1.clearTimer)(this.connectTimeout);
@@ -1539,7 +1549,7 @@ class ImapFlow extends node_events_1.EventEmitter {
1539
1549
  // one function, one settlement, and removeListener() in settle() needs no separate
1540
1550
  // handler references. A TLS handshake failure (bad certificate, protocol mismatch)
1541
1551
  // is emitted on the new TLS socket rather than on the plain one, so both are covered.
1542
- socketPlain.once('error', settle);
1552
+ socketPlain.on('error', settle);
1543
1553
  /* c8 ignore start */ // UPGRADE_TIMEOUT is 10s; firing it deterministically would make the test suite hang
1544
1554
  this.upgradeTimeout = setTimeout(() => {
1545
1555
  let err = new Error('Failed to upgrade connection in required time');
@@ -1617,7 +1627,7 @@ class ImapFlow extends node_events_1.EventEmitter {
1617
1627
  // error listener during the handshake window; the generic handlers are installed
1618
1628
  // by setSocketHandlers() inside the success callback above, so a handshake error
1619
1629
  // has a single error path.
1620
- tlsSocket.once('error', settle);
1630
+ tlsSocket.on('error', settle);
1621
1631
  this.writeSocket = tlsSocket;
1622
1632
  });
1623
1633
  if (upgraded) {
@@ -3152,7 +3162,21 @@ class ImapFlow extends node_events_1.EventEmitter {
3152
3162
  if (!response || !response.list || !response.list.length) {
3153
3163
  return false;
3154
3164
  }
3155
- return response.list[0];
3165
+ // Every FETCH row that arrived during the command is in the list, also unsolicited ones
3166
+ // for other messages or with only a flag change, and a server may split the answer for
3167
+ // one message over several rows. Taking the first row returned a flag update instead
3168
+ // of the requested data, which ended a download after its first chunk (issue #426).
3169
+ let rows = response.list;
3170
+ let requested = (0, tools_js_1.parseUintValue)(String(seq), tools_js_1.MAX_UINT32_DIGITS);
3171
+ // a range: the first message of the answer, as before
3172
+ let target = requested === false ? rows[0] : rows.find(row => (options && options.uid ? row.uid : row.seq) === requested);
3173
+ if (!target) {
3174
+ return false;
3175
+ }
3176
+ // rows of the same message, without the ones a malformed answer gives no usable
3177
+ // sequence number or another UID
3178
+ let { seq: targetSeq, uid: targetUid } = target;
3179
+ return targetSeq ? (0, tools_js_1.mergeFetchRows)(rows.filter(row => row.seq === targetSeq && (!row.uid || !targetUid || row.uid === targetUid))) : target;
3156
3180
  }
3157
3181
  /**
3158
3182
  * Download either full rfc822 formatted message or a specific bodystructure part as a Stream.
@@ -1,3 +1,3 @@
1
1
  export declare const name = "imapflow";
2
- export declare const version = "2.2.9";
2
+ export declare const version = "2.2.10";
3
3
  export declare const homepage = "https://imapflow.com/";
@@ -3,5 +3,5 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.homepage = exports.version = exports.name = void 0;
5
5
  exports.name = "imapflow";
6
- exports.version = "2.2.9";
6
+ exports.version = "2.2.10";
7
7
  exports.homepage = "https://imapflow.com/";
@@ -287,6 +287,16 @@ export declare function getColorFlags(color: string | null | undefined): {
287
287
  add: string[];
288
288
  remove: string[];
289
289
  } | null;
290
+ /**
291
+ * Merges the FETCH rows a server sent for one message into one message object. A server may
292
+ * split the data items of a message over several FETCH responses, and may put an unsolicited
293
+ * one (a flag change made by another session) in between (RFC 9051 sections 7.5.2 and 5.2).
294
+ * Later rows win for single values, bodyParts, binaryParts and partialOrigins are combined.
295
+ *
296
+ * @param rows - Formatted rows of the same message, in the order they arrived
297
+ * @returns One message object holding the data of every row
298
+ */
299
+ export declare function mergeFetchRows(rows: FetchMessageObject[]): FetchMessageObject;
290
300
  /**
291
301
  * Formats a raw untagged FETCH response into a structured message object.
292
302
  *
package/dist/cjs/tools.js CHANGED
@@ -32,6 +32,7 @@ exports.getSelectedMailbox = getSelectedMailbox;
32
32
  exports.getFolderTree = getFolderTree;
33
33
  exports.getFlagColor = getFlagColor;
34
34
  exports.getColorFlags = getColorFlags;
35
+ exports.mergeFetchRows = mergeFetchRows;
35
36
  exports.formatMessageResponse = formatMessageResponse;
36
37
  exports.processName = processName;
37
38
  exports.decodeText = decodeText;
@@ -713,6 +714,34 @@ function getColorFlags(color) {
713
714
  }
714
715
  return result;
715
716
  }
717
+ /**
718
+ * Merges the FETCH rows a server sent for one message into one message object. A server may
719
+ * split the data items of a message over several FETCH responses, and may put an unsolicited
720
+ * one (a flag change made by another session) in between (RFC 9051 sections 7.5.2 and 5.2).
721
+ * Later rows win for single values, bodyParts, binaryParts and partialOrigins are combined.
722
+ *
723
+ * @param rows - Formatted rows of the same message, in the order they arrived
724
+ * @returns One message object holding the data of every row
725
+ */
726
+ function mergeFetchRows(rows) {
727
+ if (rows.length === 1) {
728
+ return rows[0];
729
+ }
730
+ let merged = {};
731
+ let partialOrigins;
732
+ for (let row of rows) {
733
+ let { bodyParts, binaryParts, ...rest } = row;
734
+ Object.assign(merged, rest);
735
+ // combined into collections of their own, so the rows stay untouched
736
+ bodyParts?.forEach((value, key) => (merged.bodyParts ??= new Map()).set(key, value));
737
+ binaryParts?.forEach(key => (merged.binaryParts ??= new Set()).add(key));
738
+ row.partialOrigins?.forEach((value, key) => (partialOrigins ??= new Map()).set(key, value));
739
+ }
740
+ if (partialOrigins) {
741
+ Object.defineProperty(merged, 'partialOrigins', { value: partialOrigins, writable: true, configurable: true });
742
+ }
743
+ return merged;
744
+ }
716
745
  /**
717
746
  * Formats a raw untagged FETCH response into a structured message object.
718
747
  *
@@ -16,7 +16,7 @@ import { ConnectionDeadline } from './connection-deadline.js';
16
16
  import { downloadMessage, downloadMessageParts } from './download.js';
17
17
  import { AuthenticationFailure } from './errors.js';
18
18
  import imapCommands from './imap-commands.js';
19
- import { comparePaths, updateCapabilities, getFolderTree, formatMessageResponse, packMessageRange, normalizePath, expandRange, getColorFlags, hasCapability, isRev2Active, logConnectionError, unrefTimer, clearTimer, parseUintValue, getStringList, getTextValues, emitSafe, buildConnectionError, guardedPromise, guardedReject, MAX_UINT32_DIGITS } from './tools.js';
19
+ import { comparePaths, updateCapabilities, getFolderTree, formatMessageResponse, mergeFetchRows, packMessageRange, normalizePath, expandRange, getColorFlags, hasCapability, isRev2Active, logConnectionError, unrefTimer, clearTimer, parseUintValue, getStringList, getTextValues, emitSafe, buildConnectionError, guardedPromise, guardedReject, MAX_UINT32_DIGITS } from './tools.js';
20
20
  export { AuthenticationFailure, ImapFlowErrorCode } from './errors.js';
21
21
  const GREETING_TIMEOUT = 16 * 1000;
22
22
  const UPGRADE_TIMEOUT = 10 * 1000;
@@ -1471,6 +1471,13 @@ export class ImapFlow extends EventEmitter {
1471
1471
  // socket event cannot re-enter an already settled upgrade or leave state behind.
1472
1472
  const settle = (err, result) => {
1473
1473
  if (settled) {
1474
+ // A failed handshake can produce more than one error (Bun emits ECONNRESET on
1475
+ // the TLS socket after the one that settled the upgrade). settle() stays the
1476
+ // error listener of both sockets until they are torn down, so later errors
1477
+ // end up here instead of being thrown as unhandled 'error' events.
1478
+ if (err) {
1479
+ this.log.debug({ msg: 'Socket error after the TLS upgrade was settled', err, cid: this.id });
1480
+ }
1474
1481
  return;
1475
1482
  }
1476
1483
  settled = true;
@@ -1478,9 +1485,12 @@ export class ImapFlow extends EventEmitter {
1478
1485
  this.upgradeTimeout = null;
1479
1486
  this.upgrading = false;
1480
1487
  this._upgradeReject = null;
1481
- socketPlain.removeListener('error', settle);
1482
- if (this.socket && this.socket !== socketPlain) {
1483
- this.socket.removeListener('error', settle);
1488
+ if (!err) {
1489
+ // the generic socket handlers took over in the success callback
1490
+ socketPlain.removeListener('error', settle);
1491
+ if (this.socket && this.socket !== socketPlain) {
1492
+ this.socket.removeListener('error', settle);
1493
+ }
1484
1494
  }
1485
1495
  if (err) {
1486
1496
  clearTimer(this.connectTimeout);
@@ -1498,7 +1508,7 @@ export class ImapFlow extends EventEmitter {
1498
1508
  // one function, one settlement, and removeListener() in settle() needs no separate
1499
1509
  // handler references. A TLS handshake failure (bad certificate, protocol mismatch)
1500
1510
  // is emitted on the new TLS socket rather than on the plain one, so both are covered.
1501
- socketPlain.once('error', settle);
1511
+ socketPlain.on('error', settle);
1502
1512
  /* c8 ignore start */ // UPGRADE_TIMEOUT is 10s; firing it deterministically would make the test suite hang
1503
1513
  this.upgradeTimeout = setTimeout(() => {
1504
1514
  let err = new Error('Failed to upgrade connection in required time');
@@ -1576,7 +1586,7 @@ export class ImapFlow extends EventEmitter {
1576
1586
  // error listener during the handshake window; the generic handlers are installed
1577
1587
  // by setSocketHandlers() inside the success callback above, so a handshake error
1578
1588
  // has a single error path.
1579
- tlsSocket.once('error', settle);
1589
+ tlsSocket.on('error', settle);
1580
1590
  this.writeSocket = tlsSocket;
1581
1591
  });
1582
1592
  if (upgraded) {
@@ -3111,7 +3121,21 @@ export class ImapFlow extends EventEmitter {
3111
3121
  if (!response || !response.list || !response.list.length) {
3112
3122
  return false;
3113
3123
  }
3114
- return response.list[0];
3124
+ // Every FETCH row that arrived during the command is in the list, also unsolicited ones
3125
+ // for other messages or with only a flag change, and a server may split the answer for
3126
+ // one message over several rows. Taking the first row returned a flag update instead
3127
+ // of the requested data, which ended a download after its first chunk (issue #426).
3128
+ let rows = response.list;
3129
+ let requested = parseUintValue(String(seq), MAX_UINT32_DIGITS);
3130
+ // a range: the first message of the answer, as before
3131
+ let target = requested === false ? rows[0] : rows.find(row => (options && options.uid ? row.uid : row.seq) === requested);
3132
+ if (!target) {
3133
+ return false;
3134
+ }
3135
+ // rows of the same message, without the ones a malformed answer gives no usable
3136
+ // sequence number or another UID
3137
+ let { seq: targetSeq, uid: targetUid } = target;
3138
+ return targetSeq ? mergeFetchRows(rows.filter(row => row.seq === targetSeq && (!row.uid || !targetUid || row.uid === targetUid))) : target;
3115
3139
  }
3116
3140
  /**
3117
3141
  * Download either full rfc822 formatted message or a specific bodystructure part as a Stream.
@@ -1,3 +1,3 @@
1
1
  export declare const name = "imapflow";
2
- export declare const version = "2.2.9";
2
+ export declare const version = "2.2.10";
3
3
  export declare const homepage = "https://imapflow.com/";
@@ -1,4 +1,4 @@
1
1
  // Generated by scripts/build.js from package.json. Do not edit by hand.
2
2
  export const name = "imapflow";
3
- export const version = "2.2.9";
3
+ export const version = "2.2.10";
4
4
  export const homepage = "https://imapflow.com/";
@@ -287,6 +287,16 @@ export declare function getColorFlags(color: string | null | undefined): {
287
287
  add: string[];
288
288
  remove: string[];
289
289
  } | null;
290
+ /**
291
+ * Merges the FETCH rows a server sent for one message into one message object. A server may
292
+ * split the data items of a message over several FETCH responses, and may put an unsolicited
293
+ * one (a flag change made by another session) in between (RFC 9051 sections 7.5.2 and 5.2).
294
+ * Later rows win for single values, bodyParts, binaryParts and partialOrigins are combined.
295
+ *
296
+ * @param rows - Formatted rows of the same message, in the order they arrived
297
+ * @returns One message object holding the data of every row
298
+ */
299
+ export declare function mergeFetchRows(rows: FetchMessageObject[]): FetchMessageObject;
290
300
  /**
291
301
  * Formats a raw untagged FETCH response into a structured message object.
292
302
  *
package/dist/esm/tools.js CHANGED
@@ -657,6 +657,34 @@ export function getColorFlags(color) {
657
657
  }
658
658
  return result;
659
659
  }
660
+ /**
661
+ * Merges the FETCH rows a server sent for one message into one message object. A server may
662
+ * split the data items of a message over several FETCH responses, and may put an unsolicited
663
+ * one (a flag change made by another session) in between (RFC 9051 sections 7.5.2 and 5.2).
664
+ * Later rows win for single values, bodyParts, binaryParts and partialOrigins are combined.
665
+ *
666
+ * @param rows - Formatted rows of the same message, in the order they arrived
667
+ * @returns One message object holding the data of every row
668
+ */
669
+ export function mergeFetchRows(rows) {
670
+ if (rows.length === 1) {
671
+ return rows[0];
672
+ }
673
+ let merged = {};
674
+ let partialOrigins;
675
+ for (let row of rows) {
676
+ let { bodyParts, binaryParts, ...rest } = row;
677
+ Object.assign(merged, rest);
678
+ // combined into collections of their own, so the rows stay untouched
679
+ bodyParts?.forEach((value, key) => (merged.bodyParts ??= new Map()).set(key, value));
680
+ binaryParts?.forEach(key => (merged.binaryParts ??= new Set()).add(key));
681
+ row.partialOrigins?.forEach((value, key) => (partialOrigins ??= new Map()).set(key, value));
682
+ }
683
+ if (partialOrigins) {
684
+ Object.defineProperty(merged, 'partialOrigins', { value: partialOrigins, writable: true, configurable: true });
685
+ }
686
+ return merged;
687
+ }
660
688
  /**
661
689
  * Formats a raw untagged FETCH response into a structured message object.
662
690
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "2.2.9",
3
+ "version": "2.2.10",
4
4
  "description": "IMAP Client for Node",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/imap-flow.js",
@@ -75,7 +75,7 @@
75
75
  "eslint": "10.12.0",
76
76
  "eslint-config-prettier": "10.1.8",
77
77
  "globals": "17.13.0",
78
- "imapkit": "4.1.0",
78
+ "imapkit": "4.1.1",
79
79
  "prettier": "3.9.9",
80
80
  "tsx": "4.23.15",
81
81
  "types-node-legacy": "npm:@types/node@20.0.0",