imapflow 2.2.9 → 2.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.2.11](https://github.com/postalsys/imapflow/compare/v2.2.10...v2.2.11) (2026-10-08)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * ask a download chunk again when the answer comes back empty instead of failing the download ([0e6e46a](https://github.com/postalsys/imapflow/commit/0e6e46aa81a16a44ccb78df25382ab78a5708ce3))
9
+ * read NAMESPACE prefixes, QUOTAROOT names and ID values that the server sends as literals ([9c77594](https://github.com/postalsys/imapflow/commit/9c7759468f7d64ad6ef857b1861d4a846a8c7c04))
10
+
11
+ ## [2.2.10](https://github.com/postalsys/imapflow/compare/v2.2.9...v2.2.10) (2026-10-07)
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * log socket errors after a failed STARTTLS upgrade instead of throwing them on Bun ([74fad57](https://github.com/postalsys/imapflow/commit/74fad57386cde6a60211b9b1671228b72aa9f94a))
17
+ * 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)
18
+
3
19
  ## [2.2.9](https://github.com/postalsys/imapflow/compare/v2.2.8...v2.2.9) (2026-10-07)
4
20
 
5
21
 
@@ -39,17 +39,19 @@ async function id(connection, clientInfo) {
39
39
  let params = untagged.attributes && untagged.attributes[0];
40
40
  let key;
41
41
  (Array.isArray(params) ? params : [].concat(params || [])).forEach((val, i) => {
42
+ // keys are strings and values nstrings, either may come as a literal
43
+ let value = (0, tools_js_1.getStringValue)(val);
42
44
  if (i % 2 === 0) {
43
- key = val.value;
45
+ key = value;
44
46
  }
45
- else if (typeof key === 'string' && typeof val.value === 'string') {
47
+ else if (key !== undefined && value !== undefined) {
46
48
  // The server picks the keys of this object, which the caller reads
47
49
  // back as serverInfo: a prototype-chain name is skipped as it is for
48
50
  // every other server-named key, so it can neither be shadowed nor
49
51
  // written through
50
52
  let name = key.toLowerCase().trim();
51
53
  if (!(0, tools_js_1.isUnsafeKey)(name)) {
52
- map[name] = val.value;
54
+ map[name] = value;
53
55
  }
54
56
  }
55
57
  });
@@ -158,7 +158,7 @@ async function list(connection, reference, mailbox, options) {
158
158
  path: (0, tools_js_1.normalizePath)(connection, (0, tools_js_1.decodePath)(connection, rawPath)),
159
159
  pathAsListed: rawPath,
160
160
  flags: new Set((0, tools_js_1.getStringList)(untagged.attributes[0])),
161
- delimiter: (untagged.attributes[1] && untagged.attributes[1].value),
161
+ delimiter: (0, tools_js_1.getStringValue)(untagged.attributes[1]),
162
162
  listed: true
163
163
  };
164
164
  normalizeFlags(entry);
@@ -399,7 +399,7 @@ async function list(connection, reference, mailbox, options) {
399
399
  path: (0, tools_js_1.normalizePath)(connection, (0, tools_js_1.decodePath)(connection, rawPath)),
400
400
  pathAsListed: rawPath,
401
401
  flags: new Set((0, tools_js_1.getStringList)(untagged.attributes[0])),
402
- delimiter: (untagged.attributes[1] && untagged.attributes[1].value),
402
+ delimiter: (0, tools_js_1.getStringValue)(untagged.attributes[1]),
403
403
  subscribed: true
404
404
  };
405
405
  if (entry.path.toUpperCase() === 'INBOX') {
@@ -94,8 +94,9 @@ async function getListPrefix(connection) {
94
94
  return;
95
95
  }
96
96
  map.flags = new Set((0, tools_js_1.getStringList)(untagged.attributes[0]));
97
- map.delimiter = (untagged.attributes[1] && untagged.attributes[1].value);
98
- map.prefix = ((untagged.attributes[2] && untagged.attributes[2].value) || '');
97
+ // the name may be a literal, which arrives as a Buffer
98
+ map.delimiter = (0, tools_js_1.getStringValue)(untagged.attributes[1]) || null;
99
+ map.prefix = (0, tools_js_1.getStringValue)(untagged.attributes[2]) || '';
99
100
  if (map.delimiter && map.prefix.charAt(0) === map.delimiter) {
100
101
  map.prefix = map.prefix.slice(1);
101
102
  }
@@ -120,24 +121,27 @@ function getNamsepaceInfo(attribute) {
120
121
  if (!attribute || !Array.isArray(attribute) || !attribute.length) {
121
122
  return false;
122
123
  }
123
- return attribute
124
- .filter(entry => {
125
- let pair = entry;
126
- // RFC 2342 section 5 allows the delimiter to be NIL when the namespace has
127
- // no hierarchy. The token parser emits a literal `null` for NIL.
128
- return pair.length >= 2 && pair[0] && typeof pair[0].value === 'string' && (pair[1] === null || (pair[1] && typeof pair[1].value === 'string'));
129
- })
130
- .map(entry => {
124
+ let entries = [];
125
+ for (let entry of attribute) {
126
+ if (!Array.isArray(entry)) {
127
+ continue;
128
+ }
131
129
  let pair = entry;
132
- let prefix = pair[0].value;
133
- let delimiter = pair[1] === null ? null : pair[1].value;
130
+ let prefix = (0, tools_js_1.getStringValue)(pair[0]);
131
+ // RFC 2342 section 5 allows the delimiter to be NIL when the namespace has no hierarchy.
132
+ // The token parser emits a literal `null` for NIL.
133
+ let delimiter = pair[1] === null ? null : (0, tools_js_1.getStringValue)(pair[1]);
134
+ if (pair.length < 2 || prefix === undefined || delimiter === undefined) {
135
+ continue;
136
+ }
134
137
  // Append the delimiter to the prefix if it doesn't already end with one,
135
138
  // so callers can construct full paths by simply concatenating prefix + name.
136
139
  if (delimiter && prefix && prefix.charAt(prefix.length - 1) !== delimiter) {
137
140
  prefix += delimiter;
138
141
  }
139
- return { prefix, delimiter };
140
- });
142
+ entries.push({ prefix, delimiter });
143
+ }
144
+ return entries;
141
145
  }
142
146
  module.exports = exports.default;
143
147
  Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
@@ -82,9 +82,8 @@ async function quota(connection, path) {
82
82
  // QUOTAROOT response tells us which quota root applies to this mailbox.
83
83
  // A mailbox may have zero or one quota root.
84
84
  QUOTAROOT: async (untagged) => {
85
- let quotaRoot = untagged.attributes && untagged.attributes[1] && typeof untagged.attributes[1].value === 'string'
86
- ? untagged.attributes[1].value
87
- : false;
85
+ // the root name is an astring, which may come as a literal
86
+ let quotaRoot = (0, tools_js_1.getStringValue)(untagged.attributes && untagged.attributes[1]);
88
87
  if (quotaRoot) {
89
88
  map.quotaRoot = quotaRoot;
90
89
  }
@@ -40,8 +40,9 @@ const partialStarts = (requests) => {
40
40
  }
41
41
  return starts;
42
42
  };
43
- // How many times a request is repeated after answers that belong to another request
44
- const MAX_FOREIGN_ANSWERS = 3;
43
+ // Attempts per request when the answer belongs to another request or, for a message known to
44
+ // exist, is empty
45
+ const MAX_FETCH_ATTEMPTS = 3;
45
46
  const requestedUid = (range, options) => options.uid && /^\d+$/.test(String(range)) ? Number(range) : undefined;
46
47
  const isForeignAnswer = (response, expected) => {
47
48
  if (expected.uid && response.uid && response.uid !== expected.uid) {
@@ -63,11 +64,19 @@ const isForeignAnswer = (response, expected) => {
63
64
  * within the answer to the next one. Taken at face value it would be the data of that next
64
65
  * request, and a download would end without an error but with misplaced bytes. An answer for
65
66
  * another UID, or with a partial section that starts at another offset than asked, is dropped
66
- * and the request repeated.
67
+ * and the request repeated. An empty answer is repeated too while `expected.retryEmpty()` says
68
+ * the message exists, as the data of a late answer arrives with the repeated request.
67
69
  */
68
70
  async function fetchExpected(client, range, query, options, expected) {
69
71
  for (let attempt = 1;; attempt++) {
70
72
  let response = await client.fetchOne(range, query, options);
73
+ if (response === false && expected.retryEmpty && expected.retryEmpty() && attempt < MAX_FETCH_ATTEMPTS) {
74
+ // The answer may come late instead (after the tagged OK, with the next answer), then
75
+ // it shows up in the answer to the repeated request. A message that is really gone
76
+ // answers empty every time, and the caller reports it.
77
+ client.log.warn({ msg: 'Server answered a request for an existing message with no data, asking again', attempt, cid: client.id });
78
+ continue;
79
+ }
71
80
  if (!response || !isForeignAnswer(response, expected)) {
72
81
  return response;
73
82
  }
@@ -78,7 +87,7 @@ async function fetchExpected(client, range, query, options, expected) {
78
87
  attempt,
79
88
  cid: client.id
80
89
  });
81
- if (attempt >= MAX_FOREIGN_ANSWERS) {
90
+ if (attempt >= MAX_FETCH_ATTEMPTS) {
82
91
  let err = new Error('Server kept answering with data of another request');
83
92
  err.code = 'DownloadIncomplete';
84
93
  err.cid = client.id;
@@ -108,7 +117,8 @@ async function refetchDroppedSections(client, response, range, options, sections
108
117
  let uid = response.uid;
109
118
  let retry = await fetchExpected(client, uid || range, { uid: true, bodyParts: sections }, uid ? { ...options, uid: true } : options, {
110
119
  uid,
111
- origins: partialStarts(sections)
120
+ origins: partialStarts(sections),
121
+ retryEmpty: () => true
112
122
  });
113
123
  if (!retry) {
114
124
  return;
@@ -170,6 +180,13 @@ async function downloadMessage(client, range, part, options) {
170
180
  part = 'TEXT';
171
181
  }
172
182
  }
183
+ // The decoder pipeline, built once the head chunk told what the part is (see below)
184
+ let stream;
185
+ let output;
186
+ let fetchAborted = false;
187
+ // A consumer that gave up: its 'close' may still be a tick away from setting fetchAborted,
188
+ // so the stream's own flag is checked as well
189
+ let downloadAborted = () => fetchAborted || output.destroyed;
173
190
  let getNextPart = async (query) => {
174
191
  query = query || {};
175
192
  let mimeKey;
@@ -205,7 +222,9 @@ async function downloadMessage(client, range, part, options) {
205
222
  }
206
223
  let expected = {
207
224
  uid: uid || requestedUid(range, downloadOptions),
208
- origins: new Map([[part || '', processed]])
225
+ origins: new Map([[part || '', processed]]),
226
+ // every chunk after the first is of a message that was there a moment ago
227
+ retryEmpty: processed > 0 ? () => !downloadAborted() : undefined
209
228
  };
210
229
  let response = await fetchExpected(client, range, query, downloadOptions, expected);
211
230
  if (!response) {
@@ -316,9 +335,6 @@ async function downloadMessage(client, range, part, options) {
316
335
  meta.filename = filename;
317
336
  }
318
337
  }
319
- let stream;
320
- let output;
321
- let fetchAborted = false;
322
338
  // Build a decoder pipeline that progressively transforms the raw FETCH data:
323
339
  // 1. Transfer-encoding decoder (base64 or quoted-printable -> binary)
324
340
  // 2. Format decoder (format=flowed -> plain text, if applicable)
@@ -480,9 +496,8 @@ async function downloadMessage(client, range, part, options) {
480
496
  throw err;
481
497
  }
482
498
  let { response, chunk } = await getNextPart();
483
- // A consumer that gave up while the chunk was in flight: its 'close' may still be a
484
- // tick away from setting fetchAborted, so the stream's own flag is checked as well
485
- if (fetchAborted || output.destroyed) {
499
+ // A consumer that gave up while the chunk was in flight
500
+ if (downloadAborted()) {
486
501
  break;
487
502
  }
488
503
  if (response === false) {
@@ -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.11";
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.11";
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
  *
@@ -423,6 +433,14 @@ export declare function isUnsafeKey(key: unknown): boolean;
423
433
  * @returns The string values, in order, with unusable entries dropped.
424
434
  */
425
435
  export declare function getStringList(list: unknown): string[];
436
+ /**
437
+ * Reads the text of a string value from a response. A server may send any string as a literal,
438
+ * which arrives as a Buffer and is decoded as UTF-8.
439
+ *
440
+ * @param attribute - Parsed attribute from a response
441
+ * @returns The string, or undefined when the attribute holds no string (NIL, a list)
442
+ */
443
+ export declare function getStringValue(attribute: unknown): string | undefined;
426
444
  /**
427
445
  * Parses an untrusted decimal value from a server response into a BigInt.
428
446
  *
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;
@@ -48,6 +49,7 @@ exports.isValidSequenceValue = isValidSequenceValue;
48
49
  exports.isDecimalString = isDecimalString;
49
50
  exports.isUnsafeKey = isUnsafeKey;
50
51
  exports.getStringList = getStringList;
52
+ exports.getStringValue = getStringValue;
51
53
  exports.parseBigIntValue = parseBigIntValue;
52
54
  exports.parseUintValue = parseUintValue;
53
55
  exports.expandRange = expandRange;
@@ -713,6 +715,34 @@ function getColorFlags(color) {
713
715
  }
714
716
  return result;
715
717
  }
718
+ /**
719
+ * Merges the FETCH rows a server sent for one message into one message object. A server may
720
+ * split the data items of a message over several FETCH responses, and may put an unsolicited
721
+ * one (a flag change made by another session) in between (RFC 9051 sections 7.5.2 and 5.2).
722
+ * Later rows win for single values, bodyParts, binaryParts and partialOrigins are combined.
723
+ *
724
+ * @param rows - Formatted rows of the same message, in the order they arrived
725
+ * @returns One message object holding the data of every row
726
+ */
727
+ function mergeFetchRows(rows) {
728
+ if (rows.length === 1) {
729
+ return rows[0];
730
+ }
731
+ let merged = {};
732
+ let partialOrigins;
733
+ for (let row of rows) {
734
+ let { bodyParts, binaryParts, ...rest } = row;
735
+ Object.assign(merged, rest);
736
+ // combined into collections of their own, so the rows stay untouched
737
+ bodyParts?.forEach((value, key) => (merged.bodyParts ??= new Map()).set(key, value));
738
+ binaryParts?.forEach(key => (merged.binaryParts ??= new Set()).add(key));
739
+ row.partialOrigins?.forEach((value, key) => (partialOrigins ??= new Map()).set(key, value));
740
+ }
741
+ if (partialOrigins) {
742
+ Object.defineProperty(merged, 'partialOrigins', { value: partialOrigins, writable: true, configurable: true });
743
+ }
744
+ return merged;
745
+ }
716
746
  /**
717
747
  * Formats a raw untagged FETCH response into a structured message object.
718
748
  *
@@ -1473,16 +1503,27 @@ function getStringList(list) {
1473
1503
  }
1474
1504
  let strings = [];
1475
1505
  for (let entry of list) {
1476
- let value = entry && entry.value;
1477
- if (Buffer.isBuffer(value)) {
1478
- value = value.toString();
1479
- }
1480
- if (value && typeof value === 'string') {
1506
+ let value = getStringValue(entry);
1507
+ if (value) {
1481
1508
  strings.push(value);
1482
1509
  }
1483
1510
  }
1484
1511
  return strings;
1485
1512
  }
1513
+ /**
1514
+ * Reads the text of a string value from a response. A server may send any string as a literal,
1515
+ * which arrives as a Buffer and is decoded as UTF-8.
1516
+ *
1517
+ * @param attribute - Parsed attribute from a response
1518
+ * @returns The string, or undefined when the attribute holds no string (NIL, a list)
1519
+ */
1520
+ function getStringValue(attribute) {
1521
+ let value = attribute && attribute.value;
1522
+ if (Buffer.isBuffer(value)) {
1523
+ return value.toString();
1524
+ }
1525
+ return typeof value === 'string' ? value : undefined;
1526
+ }
1486
1527
  /**
1487
1528
  * Parses an untrusted decimal value from a server response into a BigInt.
1488
1529
  *
@@ -1,4 +1,4 @@
1
- import { formatDateTime, isUnsafeKey } from '../tools.js';
1
+ import { formatDateTime, isUnsafeKey, getStringValue } from '../tools.js';
2
2
  /**
3
3
  * Sends ID info to the server and updates server info data based on the response.
4
4
  *
@@ -36,17 +36,19 @@ export default async function id(connection, clientInfo) {
36
36
  let params = untagged.attributes && untagged.attributes[0];
37
37
  let key;
38
38
  (Array.isArray(params) ? params : [].concat(params || [])).forEach((val, i) => {
39
+ // keys are strings and values nstrings, either may come as a literal
40
+ let value = getStringValue(val);
39
41
  if (i % 2 === 0) {
40
- key = val.value;
42
+ key = value;
41
43
  }
42
- else if (typeof key === 'string' && typeof val.value === 'string') {
44
+ else if (key !== undefined && value !== undefined) {
43
45
  // The server picks the keys of this object, which the caller reads
44
46
  // back as serverInfo: a prototype-chain name is skipped as it is for
45
47
  // every other server-named key, so it can neither be shadowed nor
46
48
  // written through
47
49
  let name = key.toLowerCase().trim();
48
50
  if (!isUnsafeKey(name)) {
49
- map[name] = val.value;
51
+ map[name] = value;
50
52
  }
51
53
  }
52
54
  });
@@ -1,4 +1,4 @@
1
- import { decodePath, encodePath, normalizePath, enhanceCommandError, hasCapability, isRev2Active, buildStatusQueryAttributes, getStringList } from '../tools.js';
1
+ import { decodePath, encodePath, normalizePath, enhanceCommandError, hasCapability, isRev2Active, buildStatusQueryAttributes, getStringList, getStringValue } from '../tools.js';
2
2
  import { parseStatusList } from './status-fields.js';
3
3
  import { specialUse } from '../special-use.js';
4
4
  /**
@@ -155,7 +155,7 @@ export default async function list(connection, reference, mailbox, options) {
155
155
  path: normalizePath(connection, decodePath(connection, rawPath)),
156
156
  pathAsListed: rawPath,
157
157
  flags: new Set(getStringList(untagged.attributes[0])),
158
- delimiter: (untagged.attributes[1] && untagged.attributes[1].value),
158
+ delimiter: getStringValue(untagged.attributes[1]),
159
159
  listed: true
160
160
  };
161
161
  normalizeFlags(entry);
@@ -396,7 +396,7 @@ export default async function list(connection, reference, mailbox, options) {
396
396
  path: normalizePath(connection, decodePath(connection, rawPath)),
397
397
  pathAsListed: rawPath,
398
398
  flags: new Set(getStringList(untagged.attributes[0])),
399
- delimiter: (untagged.attributes[1] && untagged.attributes[1].value),
399
+ delimiter: getStringValue(untagged.attributes[1]),
400
400
  subscribed: true
401
401
  };
402
402
  if (entry.path.toUpperCase() === 'INBOX') {
@@ -1,4 +1,4 @@
1
- import { hasCapability, getStringList, isAuthenticatedState } from '../tools.js';
1
+ import { hasCapability, getStringList, getStringValue, isAuthenticatedState } from '../tools.js';
2
2
  /**
3
3
  * Requests NAMESPACE info from the server.
4
4
  *
@@ -91,8 +91,9 @@ async function getListPrefix(connection) {
91
91
  return;
92
92
  }
93
93
  map.flags = new Set(getStringList(untagged.attributes[0]));
94
- map.delimiter = (untagged.attributes[1] && untagged.attributes[1].value);
95
- map.prefix = ((untagged.attributes[2] && untagged.attributes[2].value) || '');
94
+ // the name may be a literal, which arrives as a Buffer
95
+ map.delimiter = getStringValue(untagged.attributes[1]) || null;
96
+ map.prefix = getStringValue(untagged.attributes[2]) || '';
96
97
  if (map.delimiter && map.prefix.charAt(0) === map.delimiter) {
97
98
  map.prefix = map.prefix.slice(1);
98
99
  }
@@ -117,22 +118,25 @@ function getNamsepaceInfo(attribute) {
117
118
  if (!attribute || !Array.isArray(attribute) || !attribute.length) {
118
119
  return false;
119
120
  }
120
- return attribute
121
- .filter(entry => {
122
- let pair = entry;
123
- // RFC 2342 section 5 allows the delimiter to be NIL when the namespace has
124
- // no hierarchy. The token parser emits a literal `null` for NIL.
125
- return pair.length >= 2 && pair[0] && typeof pair[0].value === 'string' && (pair[1] === null || (pair[1] && typeof pair[1].value === 'string'));
126
- })
127
- .map(entry => {
121
+ let entries = [];
122
+ for (let entry of attribute) {
123
+ if (!Array.isArray(entry)) {
124
+ continue;
125
+ }
128
126
  let pair = entry;
129
- let prefix = pair[0].value;
130
- let delimiter = pair[1] === null ? null : pair[1].value;
127
+ let prefix = getStringValue(pair[0]);
128
+ // RFC 2342 section 5 allows the delimiter to be NIL when the namespace has no hierarchy.
129
+ // The token parser emits a literal `null` for NIL.
130
+ let delimiter = pair[1] === null ? null : getStringValue(pair[1]);
131
+ if (pair.length < 2 || prefix === undefined || delimiter === undefined) {
132
+ continue;
133
+ }
131
134
  // Append the delimiter to the prefix if it doesn't already end with one,
132
135
  // so callers can construct full paths by simply concatenating prefix + name.
133
136
  if (delimiter && prefix && prefix.charAt(prefix.length - 1) !== delimiter) {
134
137
  prefix += delimiter;
135
138
  }
136
- return { prefix, delimiter };
137
- });
139
+ entries.push({ prefix, delimiter });
140
+ }
141
+ return entries;
138
142
  }
@@ -1,4 +1,4 @@
1
- import { encodePath, normalizePath, parseUintValue, isUnsafeKey, isAuthenticatedState, reportCommandError } from '../tools.js';
1
+ import { encodePath, normalizePath, parseUintValue, isUnsafeKey, isAuthenticatedState, reportCommandError, getStringValue } from '../tools.js';
2
2
  /**
3
3
  * Requests quota information for a mailbox.
4
4
  *
@@ -79,9 +79,8 @@ export default async function quota(connection, path) {
79
79
  // QUOTAROOT response tells us which quota root applies to this mailbox.
80
80
  // A mailbox may have zero or one quota root.
81
81
  QUOTAROOT: async (untagged) => {
82
- let quotaRoot = untagged.attributes && untagged.attributes[1] && typeof untagged.attributes[1].value === 'string'
83
- ? untagged.attributes[1].value
84
- : false;
82
+ // the root name is an astring, which may come as a literal
83
+ let quotaRoot = getStringValue(untagged.attributes && untagged.attributes[1]);
85
84
  if (quotaRoot) {
86
85
  map.quotaRoot = quotaRoot;
87
86
  }
@@ -33,8 +33,9 @@ const partialStarts = (requests) => {
33
33
  }
34
34
  return starts;
35
35
  };
36
- // How many times a request is repeated after answers that belong to another request
37
- const MAX_FOREIGN_ANSWERS = 3;
36
+ // Attempts per request when the answer belongs to another request or, for a message known to
37
+ // exist, is empty
38
+ const MAX_FETCH_ATTEMPTS = 3;
38
39
  const requestedUid = (range, options) => options.uid && /^\d+$/.test(String(range)) ? Number(range) : undefined;
39
40
  const isForeignAnswer = (response, expected) => {
40
41
  if (expected.uid && response.uid && response.uid !== expected.uid) {
@@ -56,11 +57,19 @@ const isForeignAnswer = (response, expected) => {
56
57
  * within the answer to the next one. Taken at face value it would be the data of that next
57
58
  * request, and a download would end without an error but with misplaced bytes. An answer for
58
59
  * another UID, or with a partial section that starts at another offset than asked, is dropped
59
- * and the request repeated.
60
+ * and the request repeated. An empty answer is repeated too while `expected.retryEmpty()` says
61
+ * the message exists, as the data of a late answer arrives with the repeated request.
60
62
  */
61
63
  async function fetchExpected(client, range, query, options, expected) {
62
64
  for (let attempt = 1;; attempt++) {
63
65
  let response = await client.fetchOne(range, query, options);
66
+ if (response === false && expected.retryEmpty && expected.retryEmpty() && attempt < MAX_FETCH_ATTEMPTS) {
67
+ // The answer may come late instead (after the tagged OK, with the next answer), then
68
+ // it shows up in the answer to the repeated request. A message that is really gone
69
+ // answers empty every time, and the caller reports it.
70
+ client.log.warn({ msg: 'Server answered a request for an existing message with no data, asking again', attempt, cid: client.id });
71
+ continue;
72
+ }
64
73
  if (!response || !isForeignAnswer(response, expected)) {
65
74
  return response;
66
75
  }
@@ -71,7 +80,7 @@ async function fetchExpected(client, range, query, options, expected) {
71
80
  attempt,
72
81
  cid: client.id
73
82
  });
74
- if (attempt >= MAX_FOREIGN_ANSWERS) {
83
+ if (attempt >= MAX_FETCH_ATTEMPTS) {
75
84
  let err = new Error('Server kept answering with data of another request');
76
85
  err.code = 'DownloadIncomplete';
77
86
  err.cid = client.id;
@@ -101,7 +110,8 @@ async function refetchDroppedSections(client, response, range, options, sections
101
110
  let uid = response.uid;
102
111
  let retry = await fetchExpected(client, uid || range, { uid: true, bodyParts: sections }, uid ? { ...options, uid: true } : options, {
103
112
  uid,
104
- origins: partialStarts(sections)
113
+ origins: partialStarts(sections),
114
+ retryEmpty: () => true
105
115
  });
106
116
  if (!retry) {
107
117
  return;
@@ -163,6 +173,13 @@ export async function downloadMessage(client, range, part, options) {
163
173
  part = 'TEXT';
164
174
  }
165
175
  }
176
+ // The decoder pipeline, built once the head chunk told what the part is (see below)
177
+ let stream;
178
+ let output;
179
+ let fetchAborted = false;
180
+ // A consumer that gave up: its 'close' may still be a tick away from setting fetchAborted,
181
+ // so the stream's own flag is checked as well
182
+ let downloadAborted = () => fetchAborted || output.destroyed;
166
183
  let getNextPart = async (query) => {
167
184
  query = query || {};
168
185
  let mimeKey;
@@ -198,7 +215,9 @@ export async function downloadMessage(client, range, part, options) {
198
215
  }
199
216
  let expected = {
200
217
  uid: uid || requestedUid(range, downloadOptions),
201
- origins: new Map([[part || '', processed]])
218
+ origins: new Map([[part || '', processed]]),
219
+ // every chunk after the first is of a message that was there a moment ago
220
+ retryEmpty: processed > 0 ? () => !downloadAborted() : undefined
202
221
  };
203
222
  let response = await fetchExpected(client, range, query, downloadOptions, expected);
204
223
  if (!response) {
@@ -309,9 +328,6 @@ export async function downloadMessage(client, range, part, options) {
309
328
  meta.filename = filename;
310
329
  }
311
330
  }
312
- let stream;
313
- let output;
314
- let fetchAborted = false;
315
331
  // Build a decoder pipeline that progressively transforms the raw FETCH data:
316
332
  // 1. Transfer-encoding decoder (base64 or quoted-printable -> binary)
317
333
  // 2. Format decoder (format=flowed -> plain text, if applicable)
@@ -473,9 +489,8 @@ export async function downloadMessage(client, range, part, options) {
473
489
  throw err;
474
490
  }
475
491
  let { response, chunk } = await getNextPart();
476
- // A consumer that gave up while the chunk was in flight: its 'close' may still be a
477
- // tick away from setting fetchAborted, so the stream's own flag is checked as well
478
- if (fetchAborted || output.destroyed) {
492
+ // A consumer that gave up while the chunk was in flight
493
+ if (downloadAborted()) {
479
494
  break;
480
495
  }
481
496
  if (response === false) {
@@ -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.11";
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.11";
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
  *
@@ -423,6 +433,14 @@ export declare function isUnsafeKey(key: unknown): boolean;
423
433
  * @returns The string values, in order, with unusable entries dropped.
424
434
  */
425
435
  export declare function getStringList(list: unknown): string[];
436
+ /**
437
+ * Reads the text of a string value from a response. A server may send any string as a literal,
438
+ * which arrives as a Buffer and is decoded as UTF-8.
439
+ *
440
+ * @param attribute - Parsed attribute from a response
441
+ * @returns The string, or undefined when the attribute holds no string (NIL, a list)
442
+ */
443
+ export declare function getStringValue(attribute: unknown): string | undefined;
426
444
  /**
427
445
  * Parses an untrusted decimal value from a server response into a BigInt.
428
446
  *
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
  *
@@ -1417,16 +1445,27 @@ export function getStringList(list) {
1417
1445
  }
1418
1446
  let strings = [];
1419
1447
  for (let entry of list) {
1420
- let value = entry && entry.value;
1421
- if (Buffer.isBuffer(value)) {
1422
- value = value.toString();
1423
- }
1424
- if (value && typeof value === 'string') {
1448
+ let value = getStringValue(entry);
1449
+ if (value) {
1425
1450
  strings.push(value);
1426
1451
  }
1427
1452
  }
1428
1453
  return strings;
1429
1454
  }
1455
+ /**
1456
+ * Reads the text of a string value from a response. A server may send any string as a literal,
1457
+ * which arrives as a Buffer and is decoded as UTF-8.
1458
+ *
1459
+ * @param attribute - Parsed attribute from a response
1460
+ * @returns The string, or undefined when the attribute holds no string (NIL, a list)
1461
+ */
1462
+ export function getStringValue(attribute) {
1463
+ let value = attribute && attribute.value;
1464
+ if (Buffer.isBuffer(value)) {
1465
+ return value.toString();
1466
+ }
1467
+ return typeof value === 'string' ? value : undefined;
1468
+ }
1430
1469
  /**
1431
1470
  * Parses an untrusted decimal value from a server response into a BigInt.
1432
1471
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "2.2.9",
3
+ "version": "2.2.11",
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.3.1",
79
79
  "prettier": "3.9.9",
80
80
  "tsx": "4.23.15",
81
81
  "types-node-legacy": "npm:@types/node@20.0.0",