imapflow 2.2.10 → 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 +8 -0
- package/dist/cjs/commands/id.js +5 -3
- package/dist/cjs/commands/list.js +2 -2
- package/dist/cjs/commands/namespace.js +18 -14
- package/dist/cjs/commands/quota.js +2 -3
- package/dist/cjs/download.js +27 -12
- package/dist/cjs/package-info.d.ts +1 -1
- package/dist/cjs/package-info.js +1 -1
- package/dist/cjs/tools.d.ts +8 -0
- package/dist/cjs/tools.js +17 -5
- package/dist/esm/commands/id.js +6 -4
- package/dist/esm/commands/list.js +3 -3
- package/dist/esm/commands/namespace.js +19 -15
- package/dist/esm/commands/quota.js +3 -4
- package/dist/esm/download.js +27 -12
- package/dist/esm/package-info.d.ts +1 -1
- package/dist/esm/package-info.js +1 -1
- package/dist/esm/tools.d.ts +8 -0
- package/dist/esm/tools.js +16 -5
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
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
|
+
|
|
3
11
|
## [2.2.10](https://github.com/postalsys/imapflow/compare/v2.2.9...v2.2.10) (2026-10-07)
|
|
4
12
|
|
|
5
13
|
|
package/dist/cjs/commands/id.js
CHANGED
|
@@ -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 =
|
|
45
|
+
key = value;
|
|
44
46
|
}
|
|
45
|
-
else if (
|
|
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] =
|
|
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: (
|
|
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: (
|
|
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
|
-
|
|
98
|
-
map.
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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]
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
}
|
package/dist/cjs/download.js
CHANGED
|
@@ -40,8 +40,9 @@ const partialStarts = (requests) => {
|
|
|
40
40
|
}
|
|
41
41
|
return starts;
|
|
42
42
|
};
|
|
43
|
-
//
|
|
44
|
-
|
|
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 >=
|
|
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
|
|
484
|
-
|
|
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) {
|
package/dist/cjs/package-info.js
CHANGED
package/dist/cjs/tools.d.ts
CHANGED
|
@@ -433,6 +433,14 @@ export declare function isUnsafeKey(key: unknown): boolean;
|
|
|
433
433
|
* @returns The string values, in order, with unusable entries dropped.
|
|
434
434
|
*/
|
|
435
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;
|
|
436
444
|
/**
|
|
437
445
|
* Parses an untrusted decimal value from a server response into a BigInt.
|
|
438
446
|
*
|
package/dist/cjs/tools.js
CHANGED
|
@@ -49,6 +49,7 @@ exports.isValidSequenceValue = isValidSequenceValue;
|
|
|
49
49
|
exports.isDecimalString = isDecimalString;
|
|
50
50
|
exports.isUnsafeKey = isUnsafeKey;
|
|
51
51
|
exports.getStringList = getStringList;
|
|
52
|
+
exports.getStringValue = getStringValue;
|
|
52
53
|
exports.parseBigIntValue = parseBigIntValue;
|
|
53
54
|
exports.parseUintValue = parseUintValue;
|
|
54
55
|
exports.expandRange = expandRange;
|
|
@@ -1502,16 +1503,27 @@ function getStringList(list) {
|
|
|
1502
1503
|
}
|
|
1503
1504
|
let strings = [];
|
|
1504
1505
|
for (let entry of list) {
|
|
1505
|
-
let value = entry
|
|
1506
|
-
if (
|
|
1507
|
-
value = value.toString();
|
|
1508
|
-
}
|
|
1509
|
-
if (value && typeof value === 'string') {
|
|
1506
|
+
let value = getStringValue(entry);
|
|
1507
|
+
if (value) {
|
|
1510
1508
|
strings.push(value);
|
|
1511
1509
|
}
|
|
1512
1510
|
}
|
|
1513
1511
|
return strings;
|
|
1514
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
|
+
}
|
|
1515
1527
|
/**
|
|
1516
1528
|
* Parses an untrusted decimal value from a server response into a BigInt.
|
|
1517
1529
|
*
|
package/dist/esm/commands/id.js
CHANGED
|
@@ -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 =
|
|
42
|
+
key = value;
|
|
41
43
|
}
|
|
42
|
-
else if (
|
|
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] =
|
|
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]
|
|
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]
|
|
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
|
-
|
|
95
|
-
map.
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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]
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
}
|
package/dist/esm/download.js
CHANGED
|
@@ -33,8 +33,9 @@ const partialStarts = (requests) => {
|
|
|
33
33
|
}
|
|
34
34
|
return starts;
|
|
35
35
|
};
|
|
36
|
-
//
|
|
37
|
-
|
|
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 >=
|
|
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
|
|
477
|
-
|
|
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) {
|
package/dist/esm/package-info.js
CHANGED
package/dist/esm/tools.d.ts
CHANGED
|
@@ -433,6 +433,14 @@ export declare function isUnsafeKey(key: unknown): boolean;
|
|
|
433
433
|
* @returns The string values, in order, with unusable entries dropped.
|
|
434
434
|
*/
|
|
435
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;
|
|
436
444
|
/**
|
|
437
445
|
* Parses an untrusted decimal value from a server response into a BigInt.
|
|
438
446
|
*
|
package/dist/esm/tools.js
CHANGED
|
@@ -1445,16 +1445,27 @@ export function getStringList(list) {
|
|
|
1445
1445
|
}
|
|
1446
1446
|
let strings = [];
|
|
1447
1447
|
for (let entry of list) {
|
|
1448
|
-
let value = entry
|
|
1449
|
-
if (
|
|
1450
|
-
value = value.toString();
|
|
1451
|
-
}
|
|
1452
|
-
if (value && typeof value === 'string') {
|
|
1448
|
+
let value = getStringValue(entry);
|
|
1449
|
+
if (value) {
|
|
1453
1450
|
strings.push(value);
|
|
1454
1451
|
}
|
|
1455
1452
|
}
|
|
1456
1453
|
return strings;
|
|
1457
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
|
+
}
|
|
1458
1469
|
/**
|
|
1459
1470
|
* Parses an untrusted decimal value from a server response into a BigInt.
|
|
1460
1471
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "imapflow",
|
|
3
|
-
"version": "2.2.
|
|
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.
|
|
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",
|