imapflow 2.2.7 → 2.2.8
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 +15 -0
- package/dist/cjs/commands/authenticate.js +15 -4
- package/dist/cjs/commands/enable.js +6 -0
- package/dist/cjs/commands/fetch.js +22 -5
- package/dist/cjs/commands/store.js +7 -6
- package/dist/cjs/download.js +139 -5
- package/dist/cjs/imap-flow.d.ts +2 -2
- package/dist/cjs/imap-flow.js +21 -6
- package/dist/cjs/package-info.d.ts +1 -1
- package/dist/cjs/package-info.js +1 -1
- package/dist/cjs/search-compiler.js +6 -3
- package/dist/cjs/tools.d.ts +4 -3
- package/dist/cjs/tools.js +39 -6
- package/dist/esm/commands/authenticate.js +15 -4
- package/dist/esm/commands/enable.js +6 -0
- package/dist/esm/commands/fetch.js +22 -5
- package/dist/esm/commands/store.js +8 -7
- package/dist/esm/download.js +139 -5
- package/dist/esm/imap-flow.d.ts +2 -2
- package/dist/esm/imap-flow.js +21 -6
- package/dist/esm/package-info.d.ts +1 -1
- package/dist/esm/package-info.js +1 -1
- package/dist/esm/search-compiler.js +6 -3
- package/dist/esm/tools.d.ts +4 -3
- package/dist/esm/tools.js +39 -6
- package/package.json +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.2.8](https://github.com/postalsys/imapflow/compare/v2.2.7...v2.2.8) (2026-10-07)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* decrement mailbox.exists on untagged VANISHED (RFC 7162 section 3.2.10) ([b40cea2](https://github.com/postalsys/imapflow/commit/b40cea22d19edd10428fe1cda98115c2f8b6cd7a))
|
|
9
|
+
* do not send sequence sets to an empty mailbox ([b40cea2](https://github.com/postalsys/imapflow/commit/b40cea22d19edd10428fe1cda98115c2f8b6cd7a))
|
|
10
|
+
* drop flags and keywords that are not atoms instead of sending them quoted ([b40cea2](https://github.com/postalsys/imapflow/commit/b40cea22d19edd10428fe1cda98115c2f8b6cd7a))
|
|
11
|
+
* encode and decode non-ASCII Gmail labels as modified UTF-7 like mailbox names ([b40cea2](https://github.com/postalsys/imapflow/commit/b40cea22d19edd10428fe1cda98115c2f8b6cd7a))
|
|
12
|
+
* expand the FETCH ALL, FAST and FULL macros instead of sending them in a list ([b40cea2](https://github.com/postalsys/imapflow/commit/b40cea22d19edd10428fe1cda98115c2f8b6cd7a))
|
|
13
|
+
* leave servername out of tls.connect() for IP literal hosts, which Bun rejects ([b40cea2](https://github.com/postalsys/imapflow/commit/b40cea22d19edd10428fe1cda98115c2f8b6cd7a))
|
|
14
|
+
* refetch body sections Apache James drops, check FETCH answers belong to the download, enable CONDSTORE with QRESYNC ([6973263](https://github.com/postalsys/imapflow/commit/697326393a4a66e743822d7fb0ac045454458492))
|
|
15
|
+
* send the OAuth token after the continuation request when SASL-IR is not advertised ([b40cea2](https://github.com/postalsys/imapflow/commit/b40cea22d19edd10428fe1cda98115c2f8b6cd7a))
|
|
16
|
+
* send the STORE UNCHANGEDSINCE modifier before the flags (RFC 7162 section 3.1.3) ([b40cea2](https://github.com/postalsys/imapflow/commit/b40cea22d19edd10428fe1cda98115c2f8b6cd7a))
|
|
17
|
+
|
|
3
18
|
## [2.2.7](https://github.com/postalsys/imapflow/compare/v2.2.6...v2.2.7) (2026-10-07)
|
|
4
19
|
|
|
5
20
|
|
|
@@ -66,15 +66,26 @@ async function authOauth(connection, username, accessToken) {
|
|
|
66
66
|
// Empty breaker: XOAUTH2 expects an empty response to abort the SASL exchange
|
|
67
67
|
breaker = '';
|
|
68
68
|
}
|
|
69
|
+
let encoded = Buffer.from(oauthbearer).toString('base64');
|
|
70
|
+
// Without SASL-IR the payload may not ride on the command line (RFC 4959 section 3): it is
|
|
71
|
+
// sent as the answer to the first, empty continuation request instead
|
|
72
|
+
let payloadPending = !connection.capabilities.has('SASL-IR');
|
|
69
73
|
let errorResponse = false;
|
|
70
74
|
try {
|
|
71
|
-
let
|
|
72
|
-
|
|
73
|
-
{ type: 'ATOM', value:
|
|
74
|
-
|
|
75
|
+
let attributes = [{ type: 'ATOM', value: command }];
|
|
76
|
+
if (!payloadPending) {
|
|
77
|
+
attributes.push({ type: 'ATOM', value: encoded, sensitive: true });
|
|
78
|
+
}
|
|
79
|
+
let response = await connection.exec('AUTHENTICATE', attributes, {
|
|
75
80
|
// Server sends a "+" continuation if auth fails, with a base64 JSON error payload.
|
|
76
81
|
// We decode it for diagnostics, then send the breaker to terminate the exchange.
|
|
77
82
|
onPlusTag: async (resp) => {
|
|
83
|
+
if (payloadPending) {
|
|
84
|
+
payloadPending = false;
|
|
85
|
+
connection.log.debug({ src: 'c', msg: '(* value hidden *)', comment: `Encoded response for AUTH=${command}`, cid: connection.id });
|
|
86
|
+
connection.write(encoded);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
78
89
|
if (resp.attributes && resp.attributes[0] && resp.attributes[0].type === 'TEXT') {
|
|
79
90
|
try {
|
|
80
91
|
errorResponse = JSON.parse(Buffer.from(resp.attributes[0].value, 'base64').toString());
|
|
@@ -49,6 +49,12 @@ async function enable(connection, extensionList) {
|
|
|
49
49
|
// extensions enabled by this command (RFC 5161), so a replace would drop
|
|
50
50
|
// grants from an earlier ENABLE call
|
|
51
51
|
connection.enabled = new Set([...connection.enabled, ...enabled]);
|
|
52
|
+
if (connection.enabled.has('QRESYNC')) {
|
|
53
|
+
// ENABLE QRESYNC is a CONDSTORE enabling command (RFC 7162 3.2.3), whether or not the
|
|
54
|
+
// server lists CONDSTORE in its ENABLED answer. Apache James advertises only QRESYNC
|
|
55
|
+
// and leaves a lone ENABLE CONDSTORE unanswered, which the RFC allows.
|
|
56
|
+
connection.enabled.add('CONDSTORE');
|
|
57
|
+
}
|
|
52
58
|
response.next();
|
|
53
59
|
return connection.enabled;
|
|
54
60
|
}
|
|
@@ -65,13 +65,30 @@ async function fetch(connection, range, query, options) {
|
|
|
65
65
|
};
|
|
66
66
|
queryStructure.push(bodyPeek);
|
|
67
67
|
};
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
// The ALL, FAST and FULL macros may only be sent on their own, never in a list with other
|
|
69
|
+
// items (RFC 3501 section 9), and UID is always in the list, so they are expanded into
|
|
70
|
+
// the items they stand for. FULL is expanded to BODYSTRUCTURE rather than the
|
|
71
|
+
// non-extensible BODY, as documented for the full option.
|
|
72
|
+
let full = !!query.full;
|
|
73
|
+
let all = !!query.all || full;
|
|
74
|
+
let fast = !!query.fast || all;
|
|
75
|
+
let items = {
|
|
76
|
+
flags: query.flags || fast,
|
|
77
|
+
internalDate: query.internalDate || fast,
|
|
78
|
+
size: query.size || fast,
|
|
79
|
+
envelope: query.envelope || all,
|
|
80
|
+
bodyStructure: query.bodyStructure || full
|
|
81
|
+
};
|
|
82
|
+
// standard data items map directly to IMAP atoms
|
|
83
|
+
if (query.uid) {
|
|
84
|
+
queryStructure.push({ type: 'ATOM', value: 'UID' });
|
|
85
|
+
}
|
|
86
|
+
['flags', 'bodyStructure', 'envelope', 'internalDate'].forEach(key => {
|
|
87
|
+
if (items[key]) {
|
|
71
88
|
queryStructure.push({ type: 'ATOM', value: key.toUpperCase() });
|
|
72
89
|
}
|
|
73
90
|
});
|
|
74
|
-
if (
|
|
91
|
+
if (items.size) {
|
|
75
92
|
queryStructure.push({ type: 'ATOM', value: 'RFC822.SIZE' });
|
|
76
93
|
}
|
|
77
94
|
// Fetch full message source, optionally with byte range (start/maxLength)
|
|
@@ -194,7 +211,7 @@ async function fetch(connection, range, query, options) {
|
|
|
194
211
|
if (consumerError) {
|
|
195
212
|
return;
|
|
196
213
|
}
|
|
197
|
-
let formatted = await (0, tools_js_1.formatMessageResponse)(untagged, mailbox, connection.idHashAlgorithm);
|
|
214
|
+
let formatted = await (0, tools_js_1.formatMessageResponse)(untagged, mailbox, connection.idHashAlgorithm, connection);
|
|
198
215
|
if (typeof options.onUntaggedFetch === 'function') {
|
|
199
216
|
/* c8 ignore next */ // a UID FETCH row without its UID is a non-compliant server, so the seq fallback is not exercised
|
|
200
217
|
let key = options.uid ? formatted.uid || formatted.seq : formatted.seq;
|
|
@@ -61,7 +61,10 @@ async function store(connection, range, flags, options) {
|
|
|
61
61
|
const dropped = [];
|
|
62
62
|
flags = (Array.isArray(flags) ? flags : [].concat(flags || []))
|
|
63
63
|
.map(flag => {
|
|
64
|
-
|
|
64
|
+
// Gmail labels other than the \-prefixed system labels are mailbox names: astrings in
|
|
65
|
+
// the form mailbox names take on the session (modified UTF-7 unless UTF-8 is enabled),
|
|
66
|
+
// not atoms like IMAP keywords
|
|
67
|
+
let formatted = options.useLabels && flag && flag.charAt(0) !== '\\' ? (0, tools_js_1.encodePath)(connection, flag) : (0, tools_js_1.formatFlag)(flag);
|
|
65
68
|
if (!formatted || (!(0, tools_js_1.canUseFlag)(flagSource, formatted) && operationName !== 'remove')) {
|
|
66
69
|
dropped.push(flag);
|
|
67
70
|
return false;
|
|
@@ -83,13 +86,10 @@ async function store(connection, range, flags, options) {
|
|
|
83
86
|
if (!flags.length && !clearAll) {
|
|
84
87
|
return false;
|
|
85
88
|
}
|
|
86
|
-
let attributes = [
|
|
87
|
-
{ type: 'SEQUENCE', value: range },
|
|
88
|
-
{ type: 'ATOM', value: operation },
|
|
89
|
-
flags.map(flag => ({ type: 'ATOM', value: flag }))
|
|
90
|
-
];
|
|
89
|
+
let attributes = [{ type: 'SEQUENCE', value: range }];
|
|
91
90
|
// CONDSTORE (RFC 7162): UNCHANGEDSINCE modifier prevents updating messages whose
|
|
92
91
|
// mod-sequence is higher than the specified value, avoiding overwriting concurrent changes.
|
|
92
|
+
// The store-modifiers list goes between the sequence set and the item name (section 3.1.3).
|
|
93
93
|
if (options.unchangedSince && connection.enabled.has('CONDSTORE') && !mailbox.noModseq) {
|
|
94
94
|
attributes.push([
|
|
95
95
|
{
|
|
@@ -102,6 +102,7 @@ async function store(connection, range, flags, options) {
|
|
|
102
102
|
}
|
|
103
103
|
]);
|
|
104
104
|
}
|
|
105
|
+
attributes.push({ type: 'ATOM', value: operation }, flags.map(flag => ({ type: 'ATOM', value: flag })));
|
|
105
106
|
let response;
|
|
106
107
|
try {
|
|
107
108
|
response = await connection.exec(options.uid ? 'UID STORE' : 'STORE', attributes);
|
package/dist/cjs/download.js
CHANGED
|
@@ -19,6 +19,113 @@ const limited_passthrough_js_1 = require("./limited-passthrough.js");
|
|
|
19
19
|
// away (a consumer destroying it closes it without a 'drain')
|
|
20
20
|
const DRAIN_WAIT_EVENTS = ['drain', 'error', 'close'];
|
|
21
21
|
const tools_js_1 = require("./tools.js");
|
|
22
|
+
const isEmptySection = (value) => !value?.length;
|
|
23
|
+
/**
|
|
24
|
+
* The section to ask again when exactly one of a part's MIME headers and content came back
|
|
25
|
+
* empty (see refetchDroppedSections()), undefined when both or neither did
|
|
26
|
+
*/
|
|
27
|
+
const droppedSection = (mime, content, mimeRequest, contentRequest) => {
|
|
28
|
+
if (isEmptySection(mime) === isEmptySection(content)) {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
return isEmptySection(content) ? contentRequest : mimeRequest;
|
|
32
|
+
};
|
|
33
|
+
/** Start offset of every partial section among the requests, keyed by section */
|
|
34
|
+
const partialStarts = (requests) => {
|
|
35
|
+
let starts = new Map();
|
|
36
|
+
for (let request of requests) {
|
|
37
|
+
if (typeof request !== 'string') {
|
|
38
|
+
starts.set(request.key, Number(request.start) || 0);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return starts;
|
|
42
|
+
};
|
|
43
|
+
// How many times a request is repeated after answers that belong to another request
|
|
44
|
+
const MAX_FOREIGN_ANSWERS = 3;
|
|
45
|
+
const requestedUid = (range, options) => options.uid && /^\d+$/.test(String(range)) ? Number(range) : undefined;
|
|
46
|
+
const isForeignAnswer = (response, expected) => {
|
|
47
|
+
if (expected.uid && response.uid && response.uid !== expected.uid) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
for (let [key, start] of expected.origins) {
|
|
51
|
+
let origin = response.partialOrigins && response.partialOrigins.get(key);
|
|
52
|
+
// An answer without the origin is taken as is: some servers leave it out, and some
|
|
53
|
+
// ignore the partial specifier altogether
|
|
54
|
+
if (typeof origin === 'number' && origin !== start) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* fetchOne() that takes only an answer belonging to the request. Apache James now and then
|
|
62
|
+
* writes the head of a FETCH answer after its tagged OK, so the data of one request shows up
|
|
63
|
+
* within the answer to the next one. Taken at face value it would be the data of that next
|
|
64
|
+
* request, and a download would end without an error but with misplaced bytes. An answer for
|
|
65
|
+
* another UID, or with a partial section that starts at another offset than asked, is dropped
|
|
66
|
+
* and the request repeated.
|
|
67
|
+
*/
|
|
68
|
+
async function fetchExpected(client, range, query, options, expected) {
|
|
69
|
+
for (let attempt = 1;; attempt++) {
|
|
70
|
+
let response = await client.fetchOne(range, query, options);
|
|
71
|
+
if (!response || !isForeignAnswer(response, expected)) {
|
|
72
|
+
return response;
|
|
73
|
+
}
|
|
74
|
+
client.log.warn({
|
|
75
|
+
msg: 'Server answered with data of another request, asking again',
|
|
76
|
+
uid: response.uid,
|
|
77
|
+
origins: response.partialOrigins && Object.fromEntries(response.partialOrigins),
|
|
78
|
+
attempt,
|
|
79
|
+
cid: client.id
|
|
80
|
+
});
|
|
81
|
+
if (attempt >= MAX_FOREIGN_ANSWERS) {
|
|
82
|
+
let err = new Error('Server kept answering with data of another request');
|
|
83
|
+
err.code = 'DownloadIncomplete';
|
|
84
|
+
err.cid = client.id;
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Apache James (and the servers built on it, Twake Mail among them) answers only the first
|
|
91
|
+
* section it is asked for each MIME part of one FETCH and returns the other one empty:
|
|
92
|
+
* BODY[2.MIME] with BODY[2] yields the headers and a zero-length body, the reverse order loses
|
|
93
|
+
* the headers (FetchGroup.addPartContent() keeps the first descriptor for a part path). Asks the
|
|
94
|
+
* sections that came back empty again, in a FETCH of their own, and merges the answer into
|
|
95
|
+
* `response`. Callers only list a section whose companion did arrive, so a compliant server
|
|
96
|
+
* pays the extra round trip only for a part that really is empty.
|
|
97
|
+
*/
|
|
98
|
+
async function refetchDroppedSections(client, response, range, options, sections) {
|
|
99
|
+
if (!sections.length) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
client.log.debug({
|
|
103
|
+
msg: 'Server answered a body section empty while its companion section was not, asking it again separately',
|
|
104
|
+
sections: sections.map(section => (typeof section === 'string' ? section : section.key)),
|
|
105
|
+
cid: client.id
|
|
106
|
+
});
|
|
107
|
+
// the UID pins the message even when the first command addressed it by sequence number
|
|
108
|
+
let uid = response.uid;
|
|
109
|
+
let retry = await fetchExpected(client, uid || range, { uid: true, bodyParts: sections }, uid ? { ...options, uid: true } : options, {
|
|
110
|
+
uid,
|
|
111
|
+
origins: partialStarts(sections)
|
|
112
|
+
});
|
|
113
|
+
if (!retry) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (retry.headers) {
|
|
117
|
+
response.headers = retry.headers;
|
|
118
|
+
}
|
|
119
|
+
for (let [key, value] of retry.bodyParts || []) {
|
|
120
|
+
(response.bodyParts ??= new Map()).set(key, value);
|
|
121
|
+
if (retry.binaryParts && retry.binaryParts.has(key)) {
|
|
122
|
+
(response.binaryParts ??= new Set()).add(key);
|
|
123
|
+
}
|
|
124
|
+
else if (response.binaryParts) {
|
|
125
|
+
response.binaryParts.delete(key);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
22
129
|
/**
|
|
23
130
|
* Implements ImapFlow.download(), see its documentation
|
|
24
131
|
*
|
|
@@ -66,6 +173,7 @@ async function downloadMessage(client, range, part, options) {
|
|
|
66
173
|
let getNextPart = async (query) => {
|
|
67
174
|
query = query || {};
|
|
68
175
|
let mimeKey;
|
|
176
|
+
let contentRequest;
|
|
69
177
|
if (!part) {
|
|
70
178
|
query.source = {
|
|
71
179
|
start: processed,
|
|
@@ -88,13 +196,18 @@ async function downloadMessage(client, range, part, options) {
|
|
|
88
196
|
query.bodyParts.push(mimeKey);
|
|
89
197
|
}
|
|
90
198
|
}
|
|
91
|
-
|
|
199
|
+
contentRequest = {
|
|
92
200
|
key: part,
|
|
93
201
|
start: processed,
|
|
94
202
|
maxLength: chunkSize
|
|
95
|
-
}
|
|
203
|
+
};
|
|
204
|
+
query.bodyParts.push(contentRequest);
|
|
96
205
|
}
|
|
97
|
-
let
|
|
206
|
+
let expected = {
|
|
207
|
+
uid: uid || requestedUid(range, downloadOptions),
|
|
208
|
+
origins: new Map([[part || '', processed]])
|
|
209
|
+
};
|
|
210
|
+
let response = await fetchExpected(client, range, query, downloadOptions, expected);
|
|
98
211
|
if (!response) {
|
|
99
212
|
return { response: false, chunk: false };
|
|
100
213
|
}
|
|
@@ -104,6 +217,12 @@ async function downloadMessage(client, range, part, options) {
|
|
|
104
217
|
range = uid;
|
|
105
218
|
downloadOptions.uid = true;
|
|
106
219
|
}
|
|
220
|
+
if (mimeKey && contentRequest) {
|
|
221
|
+
let dropped = droppedSection(mimeKey === 'header' ? response.headers : response.bodyParts?.get(mimeKey), response.bodyParts?.get(part), mimeKey, contentRequest);
|
|
222
|
+
if (dropped) {
|
|
223
|
+
await refetchDroppedSections(client, response, range, downloadOptions, [dropped]);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
107
226
|
let chunk = !part ? response.source : response.bodyParts && response.bodyParts.get(part);
|
|
108
227
|
if (!chunk) {
|
|
109
228
|
return {};
|
|
@@ -458,16 +577,31 @@ async function downloadMessageParts(client, range, parts, options) {
|
|
|
458
577
|
// again on the answer for servers that ignore the partial specifier
|
|
459
578
|
let maxBytes = (0, limited_passthrough_js_1.normalizeByteLimit)(downloadOptions.maxBytes);
|
|
460
579
|
let query = { bodyParts: [] };
|
|
580
|
+
let contentRequests = new Map();
|
|
461
581
|
for (let part of parts) {
|
|
462
582
|
query.bodyParts.push(part + '.mime');
|
|
463
583
|
// The partial specifier carries a 32-bit length (RFC 9051 "number"), so a cap beyond
|
|
464
584
|
// that is applied on the answer alone
|
|
465
|
-
|
|
585
|
+
let contentRequest = maxBytes > 0xffffffff ? part : { key: part, start: 0, maxLength: maxBytes };
|
|
586
|
+
contentRequests.set(part, contentRequest);
|
|
587
|
+
query.bodyParts.push(contentRequest);
|
|
466
588
|
}
|
|
467
|
-
let response = await client
|
|
589
|
+
let response = await fetchExpected(client, range, query, downloadOptions, {
|
|
590
|
+
uid: requestedUid(range, downloadOptions),
|
|
591
|
+
origins: partialStarts(query.bodyParts)
|
|
592
|
+
});
|
|
468
593
|
if (!response || !response.bodyParts) {
|
|
469
594
|
return {};
|
|
470
595
|
}
|
|
596
|
+
let dropped = [];
|
|
597
|
+
for (let [part, contentRequest] of contentRequests) {
|
|
598
|
+
let section = droppedSection(response.bodyParts.get(part + '.mime'), response.bodyParts.get(part), part + '.mime', contentRequest);
|
|
599
|
+
if (section) {
|
|
600
|
+
dropped.push(section);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
// Sections of different parts do not collide, so every dropped one fits in one FETCH
|
|
604
|
+
await refetchDroppedSections(client, response, range, downloadOptions, dropped);
|
|
471
605
|
let data = {};
|
|
472
606
|
for (let [part, content] of response.bodyParts) {
|
|
473
607
|
let keyParts = part.split('.mime');
|
package/dist/cjs/imap-flow.d.ts
CHANGED
|
@@ -122,8 +122,8 @@ export declare class ImapFlow extends EventEmitter {
|
|
|
122
122
|
*/
|
|
123
123
|
usable: boolean;
|
|
124
124
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
125
|
+
* `true` once the connection is authenticated (by LOGIN, AUTHENTICATE or a PREAUTH greeting),
|
|
126
|
+
* `false` before that. The user name is in `options.auth.user`
|
|
127
127
|
*/
|
|
128
128
|
authenticated: string | boolean;
|
|
129
129
|
/**
|
package/dist/cjs/imap-flow.js
CHANGED
|
@@ -1500,9 +1500,8 @@ class ImapFlow extends node_events_1.EventEmitter {
|
|
|
1500
1500
|
// of the IP - accepting any "localhost" certificate for any IP-hosted
|
|
1501
1501
|
// server, and rejecting legitimate IP-SAN certificates.
|
|
1502
1502
|
host: this.host,
|
|
1503
|
-
servername: this.servername,
|
|
1504
1503
|
port: this.port
|
|
1505
|
-
}, this.options.tls || {});
|
|
1504
|
+
}, this.tlsServername(), this.options.tls || {});
|
|
1506
1505
|
this.clearSocketHandlers();
|
|
1507
1506
|
let settled = false;
|
|
1508
1507
|
// Single settlement path for the upgrade. Every terminal outcome - handshake
|
|
@@ -1892,12 +1891,18 @@ class ImapFlow extends node_events_1.EventEmitter {
|
|
|
1892
1891
|
uids = untagged.attributes[0].value;
|
|
1893
1892
|
}
|
|
1894
1893
|
let uidList = (0, tools_js_1.expandRange)(uids);
|
|
1894
|
+
let earlier = tags.includes('EARLIER');
|
|
1895
|
+
// RFC 7162 section 3.2.10: unlike VANISHED (EARLIER), a plain VANISHED reports messages the
|
|
1896
|
+
// client knows about and decrements the message count like the same number of EXPUNGEs would
|
|
1897
|
+
if (!earlier) {
|
|
1898
|
+
mailbox.exists = Math.max(0, mailbox.exists - uidList.length);
|
|
1899
|
+
}
|
|
1895
1900
|
for (let uid of uidList) {
|
|
1896
1901
|
let payload = {
|
|
1897
1902
|
path: mailbox.path,
|
|
1898
1903
|
uid,
|
|
1899
1904
|
vanished: true,
|
|
1900
|
-
earlier
|
|
1905
|
+
earlier
|
|
1901
1906
|
};
|
|
1902
1907
|
await this.notifyExpunge(payload);
|
|
1903
1908
|
}
|
|
@@ -1909,7 +1914,7 @@ class ImapFlow extends node_events_1.EventEmitter {
|
|
|
1909
1914
|
// mailbox closed, ignore
|
|
1910
1915
|
return;
|
|
1911
1916
|
}
|
|
1912
|
-
let message = await (0, tools_js_1.formatMessageResponse)(untagged, mailbox, this.idHashAlgorithm);
|
|
1917
|
+
let message = await (0, tools_js_1.formatMessageResponse)(untagged, mailbox, this.idHashAlgorithm, this);
|
|
1913
1918
|
if (message.flags) {
|
|
1914
1919
|
let updateEvent = {
|
|
1915
1920
|
path: mailbox.path,
|
|
@@ -1938,6 +1943,12 @@ class ImapFlow extends node_events_1.EventEmitter {
|
|
|
1938
1943
|
}
|
|
1939
1944
|
return true;
|
|
1940
1945
|
}
|
|
1946
|
+
// servername for tls.connect(), left out for an IP literal host (this.servername is false then):
|
|
1947
|
+
// Node treats a false value like a missing one, but Bun throws a TypeError for it
|
|
1948
|
+
/** @internal */
|
|
1949
|
+
tlsServername() {
|
|
1950
|
+
return this.servername ? { servername: this.servername } : {};
|
|
1951
|
+
}
|
|
1941
1952
|
// Normalizes a message range from various input formats into an IMAP-compatible
|
|
1942
1953
|
// sequence string (e.g., "1:5,7,10:*"). Handles: numbers, "*", {all:true},
|
|
1943
1954
|
// {uid:value}, search query objects (resolved via SEARCH), and arrays of numbers.
|
|
@@ -1980,6 +1991,11 @@ class ImapFlow extends node_events_1.EventEmitter {
|
|
|
1980
1991
|
if (!value) {
|
|
1981
1992
|
return false;
|
|
1982
1993
|
}
|
|
1994
|
+
// An empty mailbox has no message numbers: every sequence set, "1:*" included, would get a
|
|
1995
|
+
// BAD (RFC 9051 section 9, seq-number). UID sets may point past the end, so they are sent.
|
|
1996
|
+
if (!options.uid && this.mailbox && !this.mailbox.exists) {
|
|
1997
|
+
return false;
|
|
1998
|
+
}
|
|
1983
1999
|
return value;
|
|
1984
2000
|
}
|
|
1985
2001
|
// The single definition of "the connection is not free". A held or queued mailbox lock, a
|
|
@@ -2049,9 +2065,8 @@ class ImapFlow extends node_events_1.EventEmitter {
|
|
|
2049
2065
|
let connector = this.secureConnection ? node_tls_1.default : node_net_1.default;
|
|
2050
2066
|
let opts = Object.assign({
|
|
2051
2067
|
host: this.host,
|
|
2052
|
-
servername: this.servername,
|
|
2053
2068
|
port: this.port
|
|
2054
|
-
}, this.options.tls || {});
|
|
2069
|
+
}, this.tlsServername(), this.options.tls || {});
|
|
2055
2070
|
this.untaggedHandlers.OK = (...args) => this.initialOK(...args);
|
|
2056
2071
|
this.untaggedHandlers.BYE = (...args) => this.serverBye(...args);
|
|
2057
2072
|
this.untaggedHandlers.PREAUTH = () => this.initialPREAUTH();
|
package/dist/cjs/package-info.js
CHANGED
|
@@ -371,10 +371,13 @@ const searchCompiler = (connection, query) => {
|
|
|
371
371
|
fail('InvalidSearchQuery', `Search value for ${term.toLowerCase()} must be a string`);
|
|
372
372
|
}
|
|
373
373
|
let flag = (0, tools_js_1.formatFlag)(params[term]);
|
|
374
|
-
// formatFlag() refuses \Recent, which is not a keyword
|
|
375
|
-
// criterion would widen the search, so the query is
|
|
374
|
+
// formatFlag() refuses \Recent, which is not a keyword, and values that are
|
|
375
|
+
// not atoms. Dropping the criterion would widen the search, so the query is
|
|
376
|
+
// refused instead
|
|
376
377
|
if (flag === false) {
|
|
377
|
-
fail('InvalidSearchQuery',
|
|
378
|
+
fail('InvalidSearchQuery', /^\\recent$/i.test(params[term])
|
|
379
|
+
? `${params[term]} can not be searched as a keyword, use the "recent" search key instead`
|
|
380
|
+
: `${params[term]} is not a valid keyword`);
|
|
378
381
|
}
|
|
379
382
|
// Compiled even when the mailbox does not allow the keyword: the
|
|
380
383
|
// correct answer is then the empty set, which dropping the
|
package/dist/cjs/tools.d.ts
CHANGED
|
@@ -293,9 +293,10 @@ export declare function getColorFlags(color: string | null | undefined): {
|
|
|
293
293
|
* @param untagged - Parsed untagged IMAP response
|
|
294
294
|
* @param mailbox - Current mailbox state object
|
|
295
295
|
* @param idHashAlgorithm - Hash for the fallback message id, `md5` unless the client was told otherwise
|
|
296
|
+
* @param connection - Connection the response arrived on, decodes Gmail labels like mailbox names
|
|
296
297
|
* @returns Formatted message object with properties like seq, uid, flags, envelope, etc.
|
|
297
298
|
*/
|
|
298
|
-
export declare function formatMessageResponse(untagged: ImapResponse, mailbox: MailboxObject, idHashAlgorithm?: string): Promise<FetchMessageObject>;
|
|
299
|
+
export declare function formatMessageResponse(untagged: ImapResponse, mailbox: MailboxObject, idHashAlgorithm?: string, connection?: ImapFlow): Promise<FetchMessageObject>;
|
|
299
300
|
/**
|
|
300
301
|
* Strips surrounding double quotes from a name string.
|
|
301
302
|
*
|
|
@@ -364,8 +365,8 @@ export declare function formatDate(value: unknown): string | undefined;
|
|
|
364
365
|
*/
|
|
365
366
|
export declare function formatDateTime(value: unknown): string | undefined;
|
|
366
367
|
/**
|
|
367
|
-
* Normalizes a flag string. Returns false for non-settable flags (e.g. \Recent)
|
|
368
|
-
* and capitalizes system flags properly.
|
|
368
|
+
* Normalizes a flag string. Returns false for non-settable flags (e.g. \Recent) and for
|
|
369
|
+
* values that are not valid flags (keywords must be atoms), and capitalizes system flags properly.
|
|
369
370
|
*
|
|
370
371
|
* @param flag - Flag string to normalize
|
|
371
372
|
* @returns Normalized flag string, or false if the flag cannot be set
|
package/dist/cjs/tools.js
CHANGED
|
@@ -56,6 +56,7 @@ exports.packMessageRange = packMessageRange;
|
|
|
56
56
|
const libmime_1 = __importDefault(require("libmime"));
|
|
57
57
|
const charsets_js_1 = require("./charsets.js");
|
|
58
58
|
const imap_handler_js_1 = require("./handler/imap-handler.js");
|
|
59
|
+
const imap_formal_syntax_js_1 = __importDefault(require("./handler/imap-formal-syntax.js"));
|
|
59
60
|
const node_crypto_1 = require("node:crypto");
|
|
60
61
|
const jp_decoder_js_1 = require("./jp-decoder.js");
|
|
61
62
|
const iconv_lite_1 = __importDefault(require("iconv-lite"));
|
|
@@ -336,7 +337,8 @@ function buildStatusQueryAttributes(connection, statusQuery) {
|
|
|
336
337
|
}
|
|
337
338
|
break;
|
|
338
339
|
case 'HIGHESTMODSEQ':
|
|
339
|
-
|
|
340
|
+
// QRESYNC implies CONDSTORE (RFC 7162 3.2.3)
|
|
341
|
+
if (connection.capabilities.has('CONDSTORE') || connection.capabilities.has('QRESYNC')) {
|
|
340
342
|
attributes.push({ type: 'ATOM', value: key.toUpperCase() });
|
|
341
343
|
}
|
|
342
344
|
break;
|
|
@@ -717,24 +719,40 @@ function getColorFlags(color) {
|
|
|
717
719
|
* @param untagged - Parsed untagged IMAP response
|
|
718
720
|
* @param mailbox - Current mailbox state object
|
|
719
721
|
* @param idHashAlgorithm - Hash for the fallback message id, `md5` unless the client was told otherwise
|
|
722
|
+
* @param connection - Connection the response arrived on, decodes Gmail labels like mailbox names
|
|
720
723
|
* @returns Formatted message object with properties like seq, uid, flags, envelope, etc.
|
|
721
724
|
*/
|
|
722
|
-
async function formatMessageResponse(untagged, mailbox, idHashAlgorithm) {
|
|
725
|
+
async function formatMessageResponse(untagged, mailbox, idHashAlgorithm, connection) {
|
|
723
726
|
let map = {};
|
|
724
727
|
// The sequence number indexes into mailbox state, so an unusable one is dropped rather
|
|
725
728
|
// than coerced to NaN or Infinity
|
|
726
729
|
map.seq = parseUintValue(untagged.command, exports.MAX_UINT32_DIGITS) || undefined;
|
|
727
730
|
let key;
|
|
731
|
+
// the <origin> of a partial section ("BODY[2]<1024>"), kept for the value that follows
|
|
732
|
+
let origin = false;
|
|
733
|
+
let partialOrigins;
|
|
734
|
+
let recordOrigin = (sectionKey) => {
|
|
735
|
+
if (origin !== false) {
|
|
736
|
+
if (!partialOrigins) {
|
|
737
|
+
partialOrigins = new Map();
|
|
738
|
+
}
|
|
739
|
+
partialOrigins.set(sectionKey, origin);
|
|
740
|
+
}
|
|
741
|
+
};
|
|
728
742
|
let attributes = ((untagged.attributes && untagged.attributes[1]) || []);
|
|
729
743
|
for (let i = 0, len = attributes.length; i < len; i++) {
|
|
730
744
|
let attribute = attributes[i];
|
|
731
745
|
if (i % 2 === 0) {
|
|
746
|
+
origin = false;
|
|
732
747
|
key = (await (0, imap_handler_js_1.compiler)({
|
|
733
748
|
attributes: [attribute]
|
|
734
749
|
}))
|
|
735
750
|
.toString()
|
|
736
751
|
.toLowerCase()
|
|
737
|
-
.replace(
|
|
752
|
+
.replace(/<(\d+)(\.\d+)?>$/, (match, start) => {
|
|
753
|
+
origin = parseUintValue(start, exports.MAX_UINT32_DIGITS);
|
|
754
|
+
return '';
|
|
755
|
+
});
|
|
738
756
|
continue;
|
|
739
757
|
}
|
|
740
758
|
/* c8 ignore start */ // defensive: key is always a string produced by the compiler above
|
|
@@ -779,6 +797,7 @@ async function formatMessageResponse(untagged, mailbox, idHashAlgorithm) {
|
|
|
779
797
|
case 'body[]':
|
|
780
798
|
case 'binary[]':
|
|
781
799
|
map.source = getBuffer(attribute);
|
|
800
|
+
recordOrigin('');
|
|
782
801
|
break;
|
|
783
802
|
case 'uid':
|
|
784
803
|
// A UID feeds mailbox.uidNext one line below, and from there every range
|
|
@@ -825,7 +844,8 @@ async function formatMessageResponse(untagged, mailbox, idHashAlgorithm) {
|
|
|
825
844
|
map.threadId = getString(attribute);
|
|
826
845
|
break;
|
|
827
846
|
case 'x-gm-labels':
|
|
828
|
-
|
|
847
|
+
// labels are mailbox names, modified UTF-7 unless UTF-8 is enabled
|
|
848
|
+
map.labels = new Set(getArray(attribute).map(label => (connection ? decodePath(connection, label) : label)));
|
|
829
849
|
break;
|
|
830
850
|
case 'rfc822.size':
|
|
831
851
|
map.size = getUint(attribute) || 0;
|
|
@@ -871,6 +891,7 @@ async function formatMessageResponse(untagged, mailbox, idHashAlgorithm) {
|
|
|
871
891
|
map.bodyParts = new Map();
|
|
872
892
|
}
|
|
873
893
|
map.bodyParts.set(partKey, value);
|
|
894
|
+
recordOrigin(partKey);
|
|
874
895
|
if (match[1].toLowerCase() === 'binary') {
|
|
875
896
|
// The part arrived via FETCH BINARY (RFC 3516, FETCH side folded
|
|
876
897
|
// into IMAP4rev2), so the server has already removed the
|
|
@@ -912,6 +933,10 @@ async function formatMessageResponse(untagged, mailbox, idHashAlgorithm) {
|
|
|
912
933
|
.update([path, mailbox.uidValidity?.toString() || '', map.uid.toString()].join(':'))
|
|
913
934
|
.digest('hex');
|
|
914
935
|
}
|
|
936
|
+
if (partialOrigins) {
|
|
937
|
+
// non-enumerable, so it stays out of logged and serialized fetch results
|
|
938
|
+
Object.defineProperty(map, 'partialOrigins', { value: partialOrigins, writable: true, configurable: true });
|
|
939
|
+
}
|
|
915
940
|
if (map.flags) {
|
|
916
941
|
let flagColor = getFlagColor(map.flags);
|
|
917
942
|
if (flagColor) {
|
|
@@ -1354,14 +1379,22 @@ function formatDateTime(value) {
|
|
|
1354
1379
|
let timeStr = date.toISOString().substring(11, 19);
|
|
1355
1380
|
return `${dateStr} ${timeStr} +0000`;
|
|
1356
1381
|
}
|
|
1382
|
+
// the memoized ATOM-CHAR set of RFC 9051 section 9
|
|
1383
|
+
const atomChars = imap_formal_syntax_js_1.default['ATOM-CHAR'];
|
|
1357
1384
|
/**
|
|
1358
|
-
* Normalizes a flag string. Returns false for non-settable flags (e.g. \Recent)
|
|
1359
|
-
* and capitalizes system flags properly.
|
|
1385
|
+
* Normalizes a flag string. Returns false for non-settable flags (e.g. \Recent) and for
|
|
1386
|
+
* values that are not valid flags (keywords must be atoms), and capitalizes system flags properly.
|
|
1360
1387
|
*
|
|
1361
1388
|
* @param flag - Flag string to normalize
|
|
1362
1389
|
* @returns Normalized flag string, or false if the flag cannot be set
|
|
1363
1390
|
*/
|
|
1364
1391
|
function formatFlag(flag) {
|
|
1392
|
+
// RFC 9051 section 9: flag-keyword is an atom and flag-extension is "\\" atom, the same check
|
|
1393
|
+
// the compiler uses to decide what it can send unquoted
|
|
1394
|
+
let atom = flag.charAt(0) === '\\' ? flag.slice(1) : flag;
|
|
1395
|
+
if (!atom || imap_formal_syntax_js_1.default.verify(atom, atomChars()) >= 0) {
|
|
1396
|
+
return false;
|
|
1397
|
+
}
|
|
1365
1398
|
switch (flag.toLowerCase()) {
|
|
1366
1399
|
case '\\recent':
|
|
1367
1400
|
// can not set or remove
|
|
@@ -63,15 +63,26 @@ async function authOauth(connection, username, accessToken) {
|
|
|
63
63
|
// Empty breaker: XOAUTH2 expects an empty response to abort the SASL exchange
|
|
64
64
|
breaker = '';
|
|
65
65
|
}
|
|
66
|
+
let encoded = Buffer.from(oauthbearer).toString('base64');
|
|
67
|
+
// Without SASL-IR the payload may not ride on the command line (RFC 4959 section 3): it is
|
|
68
|
+
// sent as the answer to the first, empty continuation request instead
|
|
69
|
+
let payloadPending = !connection.capabilities.has('SASL-IR');
|
|
66
70
|
let errorResponse = false;
|
|
67
71
|
try {
|
|
68
|
-
let
|
|
69
|
-
|
|
70
|
-
{ type: 'ATOM', value:
|
|
71
|
-
|
|
72
|
+
let attributes = [{ type: 'ATOM', value: command }];
|
|
73
|
+
if (!payloadPending) {
|
|
74
|
+
attributes.push({ type: 'ATOM', value: encoded, sensitive: true });
|
|
75
|
+
}
|
|
76
|
+
let response = await connection.exec('AUTHENTICATE', attributes, {
|
|
72
77
|
// Server sends a "+" continuation if auth fails, with a base64 JSON error payload.
|
|
73
78
|
// We decode it for diagnostics, then send the breaker to terminate the exchange.
|
|
74
79
|
onPlusTag: async (resp) => {
|
|
80
|
+
if (payloadPending) {
|
|
81
|
+
payloadPending = false;
|
|
82
|
+
connection.log.debug({ src: 'c', msg: '(* value hidden *)', comment: `Encoded response for AUTH=${command}`, cid: connection.id });
|
|
83
|
+
connection.write(encoded);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
75
86
|
if (resp.attributes && resp.attributes[0] && resp.attributes[0].type === 'TEXT') {
|
|
76
87
|
try {
|
|
77
88
|
errorResponse = JSON.parse(Buffer.from(resp.attributes[0].value, 'base64').toString());
|
|
@@ -46,6 +46,12 @@ export default async function enable(connection, extensionList) {
|
|
|
46
46
|
// extensions enabled by this command (RFC 5161), so a replace would drop
|
|
47
47
|
// grants from an earlier ENABLE call
|
|
48
48
|
connection.enabled = new Set([...connection.enabled, ...enabled]);
|
|
49
|
+
if (connection.enabled.has('QRESYNC')) {
|
|
50
|
+
// ENABLE QRESYNC is a CONDSTORE enabling command (RFC 7162 3.2.3), whether or not the
|
|
51
|
+
// server lists CONDSTORE in its ENABLED answer. Apache James advertises only QRESYNC
|
|
52
|
+
// and leaves a lone ENABLE CONDSTORE unanswered, which the RFC allows.
|
|
53
|
+
connection.enabled.add('CONDSTORE');
|
|
54
|
+
}
|
|
49
55
|
response.next();
|
|
50
56
|
return connection.enabled;
|
|
51
57
|
}
|
|
@@ -62,13 +62,30 @@ export default async function fetch(connection, range, query, options) {
|
|
|
62
62
|
};
|
|
63
63
|
queryStructure.push(bodyPeek);
|
|
64
64
|
};
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
// The ALL, FAST and FULL macros may only be sent on their own, never in a list with other
|
|
66
|
+
// items (RFC 3501 section 9), and UID is always in the list, so they are expanded into
|
|
67
|
+
// the items they stand for. FULL is expanded to BODYSTRUCTURE rather than the
|
|
68
|
+
// non-extensible BODY, as documented for the full option.
|
|
69
|
+
let full = !!query.full;
|
|
70
|
+
let all = !!query.all || full;
|
|
71
|
+
let fast = !!query.fast || all;
|
|
72
|
+
let items = {
|
|
73
|
+
flags: query.flags || fast,
|
|
74
|
+
internalDate: query.internalDate || fast,
|
|
75
|
+
size: query.size || fast,
|
|
76
|
+
envelope: query.envelope || all,
|
|
77
|
+
bodyStructure: query.bodyStructure || full
|
|
78
|
+
};
|
|
79
|
+
// standard data items map directly to IMAP atoms
|
|
80
|
+
if (query.uid) {
|
|
81
|
+
queryStructure.push({ type: 'ATOM', value: 'UID' });
|
|
82
|
+
}
|
|
83
|
+
['flags', 'bodyStructure', 'envelope', 'internalDate'].forEach(key => {
|
|
84
|
+
if (items[key]) {
|
|
68
85
|
queryStructure.push({ type: 'ATOM', value: key.toUpperCase() });
|
|
69
86
|
}
|
|
70
87
|
});
|
|
71
|
-
if (
|
|
88
|
+
if (items.size) {
|
|
72
89
|
queryStructure.push({ type: 'ATOM', value: 'RFC822.SIZE' });
|
|
73
90
|
}
|
|
74
91
|
// Fetch full message source, optionally with byte range (start/maxLength)
|
|
@@ -191,7 +208,7 @@ export default async function fetch(connection, range, query, options) {
|
|
|
191
208
|
if (consumerError) {
|
|
192
209
|
return;
|
|
193
210
|
}
|
|
194
|
-
let formatted = await formatMessageResponse(untagged, mailbox, connection.idHashAlgorithm);
|
|
211
|
+
let formatted = await formatMessageResponse(untagged, mailbox, connection.idHashAlgorithm, connection);
|
|
195
212
|
if (typeof options.onUntaggedFetch === 'function') {
|
|
196
213
|
/* c8 ignore next */ // a UID FETCH row without its UID is a non-compliant server, so the seq fallback is not exercised
|
|
197
214
|
let key = options.uid ? formatted.uid || formatted.seq : formatted.seq;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatFlag, canUseFlag, reportCommandError, getSelectedMailbox } from '../tools.js';
|
|
1
|
+
import { formatFlag, canUseFlag, encodePath, reportCommandError, getSelectedMailbox } from '../tools.js';
|
|
2
2
|
/**
|
|
3
3
|
* Updates flags or labels for messages in the selected mailbox.
|
|
4
4
|
*
|
|
@@ -58,7 +58,10 @@ export default async function store(connection, range, flags, options) {
|
|
|
58
58
|
const dropped = [];
|
|
59
59
|
flags = (Array.isArray(flags) ? flags : [].concat(flags || []))
|
|
60
60
|
.map(flag => {
|
|
61
|
-
|
|
61
|
+
// Gmail labels other than the \-prefixed system labels are mailbox names: astrings in
|
|
62
|
+
// the form mailbox names take on the session (modified UTF-7 unless UTF-8 is enabled),
|
|
63
|
+
// not atoms like IMAP keywords
|
|
64
|
+
let formatted = options.useLabels && flag && flag.charAt(0) !== '\\' ? encodePath(connection, flag) : formatFlag(flag);
|
|
62
65
|
if (!formatted || (!canUseFlag(flagSource, formatted) && operationName !== 'remove')) {
|
|
63
66
|
dropped.push(flag);
|
|
64
67
|
return false;
|
|
@@ -80,13 +83,10 @@ export default async function store(connection, range, flags, options) {
|
|
|
80
83
|
if (!flags.length && !clearAll) {
|
|
81
84
|
return false;
|
|
82
85
|
}
|
|
83
|
-
let attributes = [
|
|
84
|
-
{ type: 'SEQUENCE', value: range },
|
|
85
|
-
{ type: 'ATOM', value: operation },
|
|
86
|
-
flags.map(flag => ({ type: 'ATOM', value: flag }))
|
|
87
|
-
];
|
|
86
|
+
let attributes = [{ type: 'SEQUENCE', value: range }];
|
|
88
87
|
// CONDSTORE (RFC 7162): UNCHANGEDSINCE modifier prevents updating messages whose
|
|
89
88
|
// mod-sequence is higher than the specified value, avoiding overwriting concurrent changes.
|
|
89
|
+
// The store-modifiers list goes between the sequence set and the item name (section 3.1.3).
|
|
90
90
|
if (options.unchangedSince && connection.enabled.has('CONDSTORE') && !mailbox.noModseq) {
|
|
91
91
|
attributes.push([
|
|
92
92
|
{
|
|
@@ -99,6 +99,7 @@ export default async function store(connection, range, flags, options) {
|
|
|
99
99
|
}
|
|
100
100
|
]);
|
|
101
101
|
}
|
|
102
|
+
attributes.push({ type: 'ATOM', value: operation }, flags.map(flag => ({ type: 'ATOM', value: flag })));
|
|
102
103
|
let response;
|
|
103
104
|
try {
|
|
104
105
|
response = await connection.exec(options.uid ? 'UID STORE' : 'STORE', attributes);
|
package/dist/esm/download.js
CHANGED
|
@@ -12,6 +12,113 @@ import { LimitedPassthrough, normalizeByteLimit } from './limited-passthrough.js
|
|
|
12
12
|
// away (a consumer destroying it closes it without a 'drain')
|
|
13
13
|
const DRAIN_WAIT_EVENTS = ['drain', 'error', 'close'];
|
|
14
14
|
import { getDecoder, isUnsafeKey } from './tools.js';
|
|
15
|
+
const isEmptySection = (value) => !value?.length;
|
|
16
|
+
/**
|
|
17
|
+
* The section to ask again when exactly one of a part's MIME headers and content came back
|
|
18
|
+
* empty (see refetchDroppedSections()), undefined when both or neither did
|
|
19
|
+
*/
|
|
20
|
+
const droppedSection = (mime, content, mimeRequest, contentRequest) => {
|
|
21
|
+
if (isEmptySection(mime) === isEmptySection(content)) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
return isEmptySection(content) ? contentRequest : mimeRequest;
|
|
25
|
+
};
|
|
26
|
+
/** Start offset of every partial section among the requests, keyed by section */
|
|
27
|
+
const partialStarts = (requests) => {
|
|
28
|
+
let starts = new Map();
|
|
29
|
+
for (let request of requests) {
|
|
30
|
+
if (typeof request !== 'string') {
|
|
31
|
+
starts.set(request.key, Number(request.start) || 0);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return starts;
|
|
35
|
+
};
|
|
36
|
+
// How many times a request is repeated after answers that belong to another request
|
|
37
|
+
const MAX_FOREIGN_ANSWERS = 3;
|
|
38
|
+
const requestedUid = (range, options) => options.uid && /^\d+$/.test(String(range)) ? Number(range) : undefined;
|
|
39
|
+
const isForeignAnswer = (response, expected) => {
|
|
40
|
+
if (expected.uid && response.uid && response.uid !== expected.uid) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
for (let [key, start] of expected.origins) {
|
|
44
|
+
let origin = response.partialOrigins && response.partialOrigins.get(key);
|
|
45
|
+
// An answer without the origin is taken as is: some servers leave it out, and some
|
|
46
|
+
// ignore the partial specifier altogether
|
|
47
|
+
if (typeof origin === 'number' && origin !== start) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* fetchOne() that takes only an answer belonging to the request. Apache James now and then
|
|
55
|
+
* writes the head of a FETCH answer after its tagged OK, so the data of one request shows up
|
|
56
|
+
* within the answer to the next one. Taken at face value it would be the data of that next
|
|
57
|
+
* request, and a download would end without an error but with misplaced bytes. An answer for
|
|
58
|
+
* another UID, or with a partial section that starts at another offset than asked, is dropped
|
|
59
|
+
* and the request repeated.
|
|
60
|
+
*/
|
|
61
|
+
async function fetchExpected(client, range, query, options, expected) {
|
|
62
|
+
for (let attempt = 1;; attempt++) {
|
|
63
|
+
let response = await client.fetchOne(range, query, options);
|
|
64
|
+
if (!response || !isForeignAnswer(response, expected)) {
|
|
65
|
+
return response;
|
|
66
|
+
}
|
|
67
|
+
client.log.warn({
|
|
68
|
+
msg: 'Server answered with data of another request, asking again',
|
|
69
|
+
uid: response.uid,
|
|
70
|
+
origins: response.partialOrigins && Object.fromEntries(response.partialOrigins),
|
|
71
|
+
attempt,
|
|
72
|
+
cid: client.id
|
|
73
|
+
});
|
|
74
|
+
if (attempt >= MAX_FOREIGN_ANSWERS) {
|
|
75
|
+
let err = new Error('Server kept answering with data of another request');
|
|
76
|
+
err.code = 'DownloadIncomplete';
|
|
77
|
+
err.cid = client.id;
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Apache James (and the servers built on it, Twake Mail among them) answers only the first
|
|
84
|
+
* section it is asked for each MIME part of one FETCH and returns the other one empty:
|
|
85
|
+
* BODY[2.MIME] with BODY[2] yields the headers and a zero-length body, the reverse order loses
|
|
86
|
+
* the headers (FetchGroup.addPartContent() keeps the first descriptor for a part path). Asks the
|
|
87
|
+
* sections that came back empty again, in a FETCH of their own, and merges the answer into
|
|
88
|
+
* `response`. Callers only list a section whose companion did arrive, so a compliant server
|
|
89
|
+
* pays the extra round trip only for a part that really is empty.
|
|
90
|
+
*/
|
|
91
|
+
async function refetchDroppedSections(client, response, range, options, sections) {
|
|
92
|
+
if (!sections.length) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
client.log.debug({
|
|
96
|
+
msg: 'Server answered a body section empty while its companion section was not, asking it again separately',
|
|
97
|
+
sections: sections.map(section => (typeof section === 'string' ? section : section.key)),
|
|
98
|
+
cid: client.id
|
|
99
|
+
});
|
|
100
|
+
// the UID pins the message even when the first command addressed it by sequence number
|
|
101
|
+
let uid = response.uid;
|
|
102
|
+
let retry = await fetchExpected(client, uid || range, { uid: true, bodyParts: sections }, uid ? { ...options, uid: true } : options, {
|
|
103
|
+
uid,
|
|
104
|
+
origins: partialStarts(sections)
|
|
105
|
+
});
|
|
106
|
+
if (!retry) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (retry.headers) {
|
|
110
|
+
response.headers = retry.headers;
|
|
111
|
+
}
|
|
112
|
+
for (let [key, value] of retry.bodyParts || []) {
|
|
113
|
+
(response.bodyParts ??= new Map()).set(key, value);
|
|
114
|
+
if (retry.binaryParts && retry.binaryParts.has(key)) {
|
|
115
|
+
(response.binaryParts ??= new Set()).add(key);
|
|
116
|
+
}
|
|
117
|
+
else if (response.binaryParts) {
|
|
118
|
+
response.binaryParts.delete(key);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
15
122
|
/**
|
|
16
123
|
* Implements ImapFlow.download(), see its documentation
|
|
17
124
|
*
|
|
@@ -59,6 +166,7 @@ export async function downloadMessage(client, range, part, options) {
|
|
|
59
166
|
let getNextPart = async (query) => {
|
|
60
167
|
query = query || {};
|
|
61
168
|
let mimeKey;
|
|
169
|
+
let contentRequest;
|
|
62
170
|
if (!part) {
|
|
63
171
|
query.source = {
|
|
64
172
|
start: processed,
|
|
@@ -81,13 +189,18 @@ export async function downloadMessage(client, range, part, options) {
|
|
|
81
189
|
query.bodyParts.push(mimeKey);
|
|
82
190
|
}
|
|
83
191
|
}
|
|
84
|
-
|
|
192
|
+
contentRequest = {
|
|
85
193
|
key: part,
|
|
86
194
|
start: processed,
|
|
87
195
|
maxLength: chunkSize
|
|
88
|
-
}
|
|
196
|
+
};
|
|
197
|
+
query.bodyParts.push(contentRequest);
|
|
89
198
|
}
|
|
90
|
-
let
|
|
199
|
+
let expected = {
|
|
200
|
+
uid: uid || requestedUid(range, downloadOptions),
|
|
201
|
+
origins: new Map([[part || '', processed]])
|
|
202
|
+
};
|
|
203
|
+
let response = await fetchExpected(client, range, query, downloadOptions, expected);
|
|
91
204
|
if (!response) {
|
|
92
205
|
return { response: false, chunk: false };
|
|
93
206
|
}
|
|
@@ -97,6 +210,12 @@ export async function downloadMessage(client, range, part, options) {
|
|
|
97
210
|
range = uid;
|
|
98
211
|
downloadOptions.uid = true;
|
|
99
212
|
}
|
|
213
|
+
if (mimeKey && contentRequest) {
|
|
214
|
+
let dropped = droppedSection(mimeKey === 'header' ? response.headers : response.bodyParts?.get(mimeKey), response.bodyParts?.get(part), mimeKey, contentRequest);
|
|
215
|
+
if (dropped) {
|
|
216
|
+
await refetchDroppedSections(client, response, range, downloadOptions, [dropped]);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
100
219
|
let chunk = !part ? response.source : response.bodyParts && response.bodyParts.get(part);
|
|
101
220
|
if (!chunk) {
|
|
102
221
|
return {};
|
|
@@ -451,16 +570,31 @@ export async function downloadMessageParts(client, range, parts, options) {
|
|
|
451
570
|
// again on the answer for servers that ignore the partial specifier
|
|
452
571
|
let maxBytes = normalizeByteLimit(downloadOptions.maxBytes);
|
|
453
572
|
let query = { bodyParts: [] };
|
|
573
|
+
let contentRequests = new Map();
|
|
454
574
|
for (let part of parts) {
|
|
455
575
|
query.bodyParts.push(part + '.mime');
|
|
456
576
|
// The partial specifier carries a 32-bit length (RFC 9051 "number"), so a cap beyond
|
|
457
577
|
// that is applied on the answer alone
|
|
458
|
-
|
|
578
|
+
let contentRequest = maxBytes > 0xffffffff ? part : { key: part, start: 0, maxLength: maxBytes };
|
|
579
|
+
contentRequests.set(part, contentRequest);
|
|
580
|
+
query.bodyParts.push(contentRequest);
|
|
459
581
|
}
|
|
460
|
-
let response = await client
|
|
582
|
+
let response = await fetchExpected(client, range, query, downloadOptions, {
|
|
583
|
+
uid: requestedUid(range, downloadOptions),
|
|
584
|
+
origins: partialStarts(query.bodyParts)
|
|
585
|
+
});
|
|
461
586
|
if (!response || !response.bodyParts) {
|
|
462
587
|
return {};
|
|
463
588
|
}
|
|
589
|
+
let dropped = [];
|
|
590
|
+
for (let [part, contentRequest] of contentRequests) {
|
|
591
|
+
let section = droppedSection(response.bodyParts.get(part + '.mime'), response.bodyParts.get(part), part + '.mime', contentRequest);
|
|
592
|
+
if (section) {
|
|
593
|
+
dropped.push(section);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
// Sections of different parts do not collide, so every dropped one fits in one FETCH
|
|
597
|
+
await refetchDroppedSections(client, response, range, downloadOptions, dropped);
|
|
464
598
|
let data = {};
|
|
465
599
|
for (let [part, content] of response.bodyParts) {
|
|
466
600
|
let keyParts = part.split('.mime');
|
package/dist/esm/imap-flow.d.ts
CHANGED
|
@@ -122,8 +122,8 @@ export declare class ImapFlow extends EventEmitter {
|
|
|
122
122
|
*/
|
|
123
123
|
usable: boolean;
|
|
124
124
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
125
|
+
* `true` once the connection is authenticated (by LOGIN, AUTHENTICATE or a PREAUTH greeting),
|
|
126
|
+
* `false` before that. The user name is in `options.auth.user`
|
|
127
127
|
*/
|
|
128
128
|
authenticated: string | boolean;
|
|
129
129
|
/**
|
package/dist/esm/imap-flow.js
CHANGED
|
@@ -1459,9 +1459,8 @@ export class ImapFlow extends EventEmitter {
|
|
|
1459
1459
|
// of the IP - accepting any "localhost" certificate for any IP-hosted
|
|
1460
1460
|
// server, and rejecting legitimate IP-SAN certificates.
|
|
1461
1461
|
host: this.host,
|
|
1462
|
-
servername: this.servername,
|
|
1463
1462
|
port: this.port
|
|
1464
|
-
}, this.options.tls || {});
|
|
1463
|
+
}, this.tlsServername(), this.options.tls || {});
|
|
1465
1464
|
this.clearSocketHandlers();
|
|
1466
1465
|
let settled = false;
|
|
1467
1466
|
// Single settlement path for the upgrade. Every terminal outcome - handshake
|
|
@@ -1851,12 +1850,18 @@ export class ImapFlow extends EventEmitter {
|
|
|
1851
1850
|
uids = untagged.attributes[0].value;
|
|
1852
1851
|
}
|
|
1853
1852
|
let uidList = expandRange(uids);
|
|
1853
|
+
let earlier = tags.includes('EARLIER');
|
|
1854
|
+
// RFC 7162 section 3.2.10: unlike VANISHED (EARLIER), a plain VANISHED reports messages the
|
|
1855
|
+
// client knows about and decrements the message count like the same number of EXPUNGEs would
|
|
1856
|
+
if (!earlier) {
|
|
1857
|
+
mailbox.exists = Math.max(0, mailbox.exists - uidList.length);
|
|
1858
|
+
}
|
|
1854
1859
|
for (let uid of uidList) {
|
|
1855
1860
|
let payload = {
|
|
1856
1861
|
path: mailbox.path,
|
|
1857
1862
|
uid,
|
|
1858
1863
|
vanished: true,
|
|
1859
|
-
earlier
|
|
1864
|
+
earlier
|
|
1860
1865
|
};
|
|
1861
1866
|
await this.notifyExpunge(payload);
|
|
1862
1867
|
}
|
|
@@ -1868,7 +1873,7 @@ export class ImapFlow extends EventEmitter {
|
|
|
1868
1873
|
// mailbox closed, ignore
|
|
1869
1874
|
return;
|
|
1870
1875
|
}
|
|
1871
|
-
let message = await formatMessageResponse(untagged, mailbox, this.idHashAlgorithm);
|
|
1876
|
+
let message = await formatMessageResponse(untagged, mailbox, this.idHashAlgorithm, this);
|
|
1872
1877
|
if (message.flags) {
|
|
1873
1878
|
let updateEvent = {
|
|
1874
1879
|
path: mailbox.path,
|
|
@@ -1897,6 +1902,12 @@ export class ImapFlow extends EventEmitter {
|
|
|
1897
1902
|
}
|
|
1898
1903
|
return true;
|
|
1899
1904
|
}
|
|
1905
|
+
// servername for tls.connect(), left out for an IP literal host (this.servername is false then):
|
|
1906
|
+
// Node treats a false value like a missing one, but Bun throws a TypeError for it
|
|
1907
|
+
/** @internal */
|
|
1908
|
+
tlsServername() {
|
|
1909
|
+
return this.servername ? { servername: this.servername } : {};
|
|
1910
|
+
}
|
|
1900
1911
|
// Normalizes a message range from various input formats into an IMAP-compatible
|
|
1901
1912
|
// sequence string (e.g., "1:5,7,10:*"). Handles: numbers, "*", {all:true},
|
|
1902
1913
|
// {uid:value}, search query objects (resolved via SEARCH), and arrays of numbers.
|
|
@@ -1939,6 +1950,11 @@ export class ImapFlow extends EventEmitter {
|
|
|
1939
1950
|
if (!value) {
|
|
1940
1951
|
return false;
|
|
1941
1952
|
}
|
|
1953
|
+
// An empty mailbox has no message numbers: every sequence set, "1:*" included, would get a
|
|
1954
|
+
// BAD (RFC 9051 section 9, seq-number). UID sets may point past the end, so they are sent.
|
|
1955
|
+
if (!options.uid && this.mailbox && !this.mailbox.exists) {
|
|
1956
|
+
return false;
|
|
1957
|
+
}
|
|
1942
1958
|
return value;
|
|
1943
1959
|
}
|
|
1944
1960
|
// The single definition of "the connection is not free". A held or queued mailbox lock, a
|
|
@@ -2008,9 +2024,8 @@ export class ImapFlow extends EventEmitter {
|
|
|
2008
2024
|
let connector = this.secureConnection ? tls : net;
|
|
2009
2025
|
let opts = Object.assign({
|
|
2010
2026
|
host: this.host,
|
|
2011
|
-
servername: this.servername,
|
|
2012
2027
|
port: this.port
|
|
2013
|
-
}, this.options.tls || {});
|
|
2028
|
+
}, this.tlsServername(), this.options.tls || {});
|
|
2014
2029
|
this.untaggedHandlers.OK = (...args) => this.initialOK(...args);
|
|
2015
2030
|
this.untaggedHandlers.BYE = (...args) => this.serverBye(...args);
|
|
2016
2031
|
this.untaggedHandlers.PREAUTH = () => this.initialPREAUTH();
|
package/dist/esm/package-info.js
CHANGED
|
@@ -368,10 +368,13 @@ export const searchCompiler = (connection, query) => {
|
|
|
368
368
|
fail('InvalidSearchQuery', `Search value for ${term.toLowerCase()} must be a string`);
|
|
369
369
|
}
|
|
370
370
|
let flag = formatFlag(params[term]);
|
|
371
|
-
// formatFlag() refuses \Recent, which is not a keyword
|
|
372
|
-
// criterion would widen the search, so the query is
|
|
371
|
+
// formatFlag() refuses \Recent, which is not a keyword, and values that are
|
|
372
|
+
// not atoms. Dropping the criterion would widen the search, so the query is
|
|
373
|
+
// refused instead
|
|
373
374
|
if (flag === false) {
|
|
374
|
-
fail('InvalidSearchQuery',
|
|
375
|
+
fail('InvalidSearchQuery', /^\\recent$/i.test(params[term])
|
|
376
|
+
? `${params[term]} can not be searched as a keyword, use the "recent" search key instead`
|
|
377
|
+
: `${params[term]} is not a valid keyword`);
|
|
375
378
|
}
|
|
376
379
|
// Compiled even when the mailbox does not allow the keyword: the
|
|
377
380
|
// correct answer is then the empty set, which dropping the
|
package/dist/esm/tools.d.ts
CHANGED
|
@@ -293,9 +293,10 @@ export declare function getColorFlags(color: string | null | undefined): {
|
|
|
293
293
|
* @param untagged - Parsed untagged IMAP response
|
|
294
294
|
* @param mailbox - Current mailbox state object
|
|
295
295
|
* @param idHashAlgorithm - Hash for the fallback message id, `md5` unless the client was told otherwise
|
|
296
|
+
* @param connection - Connection the response arrived on, decodes Gmail labels like mailbox names
|
|
296
297
|
* @returns Formatted message object with properties like seq, uid, flags, envelope, etc.
|
|
297
298
|
*/
|
|
298
|
-
export declare function formatMessageResponse(untagged: ImapResponse, mailbox: MailboxObject, idHashAlgorithm?: string): Promise<FetchMessageObject>;
|
|
299
|
+
export declare function formatMessageResponse(untagged: ImapResponse, mailbox: MailboxObject, idHashAlgorithm?: string, connection?: ImapFlow): Promise<FetchMessageObject>;
|
|
299
300
|
/**
|
|
300
301
|
* Strips surrounding double quotes from a name string.
|
|
301
302
|
*
|
|
@@ -364,8 +365,8 @@ export declare function formatDate(value: unknown): string | undefined;
|
|
|
364
365
|
*/
|
|
365
366
|
export declare function formatDateTime(value: unknown): string | undefined;
|
|
366
367
|
/**
|
|
367
|
-
* Normalizes a flag string. Returns false for non-settable flags (e.g. \Recent)
|
|
368
|
-
* and capitalizes system flags properly.
|
|
368
|
+
* Normalizes a flag string. Returns false for non-settable flags (e.g. \Recent) and for
|
|
369
|
+
* values that are not valid flags (keywords must be atoms), and capitalizes system flags properly.
|
|
369
370
|
*
|
|
370
371
|
* @param flag - Flag string to normalize
|
|
371
372
|
* @returns Normalized flag string, or false if the flag cannot be set
|
package/dist/esm/tools.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import libmime from 'libmime';
|
|
3
3
|
import { resolveCharset } from './charsets.js';
|
|
4
4
|
import { compiler } from './handler/imap-handler.js';
|
|
5
|
+
import imapFormalSyntax from './handler/imap-formal-syntax.js';
|
|
5
6
|
import { createHash } from 'node:crypto';
|
|
6
7
|
import { JPDecoder } from './jp-decoder.js';
|
|
7
8
|
import iconv from 'iconv-lite';
|
|
@@ -280,7 +281,8 @@ export function buildStatusQueryAttributes(connection, statusQuery) {
|
|
|
280
281
|
}
|
|
281
282
|
break;
|
|
282
283
|
case 'HIGHESTMODSEQ':
|
|
283
|
-
|
|
284
|
+
// QRESYNC implies CONDSTORE (RFC 7162 3.2.3)
|
|
285
|
+
if (connection.capabilities.has('CONDSTORE') || connection.capabilities.has('QRESYNC')) {
|
|
284
286
|
attributes.push({ type: 'ATOM', value: key.toUpperCase() });
|
|
285
287
|
}
|
|
286
288
|
break;
|
|
@@ -661,24 +663,40 @@ export function getColorFlags(color) {
|
|
|
661
663
|
* @param untagged - Parsed untagged IMAP response
|
|
662
664
|
* @param mailbox - Current mailbox state object
|
|
663
665
|
* @param idHashAlgorithm - Hash for the fallback message id, `md5` unless the client was told otherwise
|
|
666
|
+
* @param connection - Connection the response arrived on, decodes Gmail labels like mailbox names
|
|
664
667
|
* @returns Formatted message object with properties like seq, uid, flags, envelope, etc.
|
|
665
668
|
*/
|
|
666
|
-
export async function formatMessageResponse(untagged, mailbox, idHashAlgorithm) {
|
|
669
|
+
export async function formatMessageResponse(untagged, mailbox, idHashAlgorithm, connection) {
|
|
667
670
|
let map = {};
|
|
668
671
|
// The sequence number indexes into mailbox state, so an unusable one is dropped rather
|
|
669
672
|
// than coerced to NaN or Infinity
|
|
670
673
|
map.seq = parseUintValue(untagged.command, MAX_UINT32_DIGITS) || undefined;
|
|
671
674
|
let key;
|
|
675
|
+
// the <origin> of a partial section ("BODY[2]<1024>"), kept for the value that follows
|
|
676
|
+
let origin = false;
|
|
677
|
+
let partialOrigins;
|
|
678
|
+
let recordOrigin = (sectionKey) => {
|
|
679
|
+
if (origin !== false) {
|
|
680
|
+
if (!partialOrigins) {
|
|
681
|
+
partialOrigins = new Map();
|
|
682
|
+
}
|
|
683
|
+
partialOrigins.set(sectionKey, origin);
|
|
684
|
+
}
|
|
685
|
+
};
|
|
672
686
|
let attributes = ((untagged.attributes && untagged.attributes[1]) || []);
|
|
673
687
|
for (let i = 0, len = attributes.length; i < len; i++) {
|
|
674
688
|
let attribute = attributes[i];
|
|
675
689
|
if (i % 2 === 0) {
|
|
690
|
+
origin = false;
|
|
676
691
|
key = (await compiler({
|
|
677
692
|
attributes: [attribute]
|
|
678
693
|
}))
|
|
679
694
|
.toString()
|
|
680
695
|
.toLowerCase()
|
|
681
|
-
.replace(
|
|
696
|
+
.replace(/<(\d+)(\.\d+)?>$/, (match, start) => {
|
|
697
|
+
origin = parseUintValue(start, MAX_UINT32_DIGITS);
|
|
698
|
+
return '';
|
|
699
|
+
});
|
|
682
700
|
continue;
|
|
683
701
|
}
|
|
684
702
|
/* c8 ignore start */ // defensive: key is always a string produced by the compiler above
|
|
@@ -723,6 +741,7 @@ export async function formatMessageResponse(untagged, mailbox, idHashAlgorithm)
|
|
|
723
741
|
case 'body[]':
|
|
724
742
|
case 'binary[]':
|
|
725
743
|
map.source = getBuffer(attribute);
|
|
744
|
+
recordOrigin('');
|
|
726
745
|
break;
|
|
727
746
|
case 'uid':
|
|
728
747
|
// A UID feeds mailbox.uidNext one line below, and from there every range
|
|
@@ -769,7 +788,8 @@ export async function formatMessageResponse(untagged, mailbox, idHashAlgorithm)
|
|
|
769
788
|
map.threadId = getString(attribute);
|
|
770
789
|
break;
|
|
771
790
|
case 'x-gm-labels':
|
|
772
|
-
|
|
791
|
+
// labels are mailbox names, modified UTF-7 unless UTF-8 is enabled
|
|
792
|
+
map.labels = new Set(getArray(attribute).map(label => (connection ? decodePath(connection, label) : label)));
|
|
773
793
|
break;
|
|
774
794
|
case 'rfc822.size':
|
|
775
795
|
map.size = getUint(attribute) || 0;
|
|
@@ -815,6 +835,7 @@ export async function formatMessageResponse(untagged, mailbox, idHashAlgorithm)
|
|
|
815
835
|
map.bodyParts = new Map();
|
|
816
836
|
}
|
|
817
837
|
map.bodyParts.set(partKey, value);
|
|
838
|
+
recordOrigin(partKey);
|
|
818
839
|
if (match[1].toLowerCase() === 'binary') {
|
|
819
840
|
// The part arrived via FETCH BINARY (RFC 3516, FETCH side folded
|
|
820
841
|
// into IMAP4rev2), so the server has already removed the
|
|
@@ -856,6 +877,10 @@ export async function formatMessageResponse(untagged, mailbox, idHashAlgorithm)
|
|
|
856
877
|
.update([path, mailbox.uidValidity?.toString() || '', map.uid.toString()].join(':'))
|
|
857
878
|
.digest('hex');
|
|
858
879
|
}
|
|
880
|
+
if (partialOrigins) {
|
|
881
|
+
// non-enumerable, so it stays out of logged and serialized fetch results
|
|
882
|
+
Object.defineProperty(map, 'partialOrigins', { value: partialOrigins, writable: true, configurable: true });
|
|
883
|
+
}
|
|
859
884
|
if (map.flags) {
|
|
860
885
|
let flagColor = getFlagColor(map.flags);
|
|
861
886
|
if (flagColor) {
|
|
@@ -1298,14 +1323,22 @@ export function formatDateTime(value) {
|
|
|
1298
1323
|
let timeStr = date.toISOString().substring(11, 19);
|
|
1299
1324
|
return `${dateStr} ${timeStr} +0000`;
|
|
1300
1325
|
}
|
|
1326
|
+
// the memoized ATOM-CHAR set of RFC 9051 section 9
|
|
1327
|
+
const atomChars = imapFormalSyntax['ATOM-CHAR'];
|
|
1301
1328
|
/**
|
|
1302
|
-
* Normalizes a flag string. Returns false for non-settable flags (e.g. \Recent)
|
|
1303
|
-
* and capitalizes system flags properly.
|
|
1329
|
+
* Normalizes a flag string. Returns false for non-settable flags (e.g. \Recent) and for
|
|
1330
|
+
* values that are not valid flags (keywords must be atoms), and capitalizes system flags properly.
|
|
1304
1331
|
*
|
|
1305
1332
|
* @param flag - Flag string to normalize
|
|
1306
1333
|
* @returns Normalized flag string, or false if the flag cannot be set
|
|
1307
1334
|
*/
|
|
1308
1335
|
export function formatFlag(flag) {
|
|
1336
|
+
// RFC 9051 section 9: flag-keyword is an atom and flag-extension is "\\" atom, the same check
|
|
1337
|
+
// the compiler uses to decide what it can send unquoted
|
|
1338
|
+
let atom = flag.charAt(0) === '\\' ? flag.slice(1) : flag;
|
|
1339
|
+
if (!atom || imapFormalSyntax.verify(atom, atomChars()) >= 0) {
|
|
1340
|
+
return false;
|
|
1341
|
+
}
|
|
1309
1342
|
switch (flag.toLowerCase()) {
|
|
1310
1343
|
case '\\recent':
|
|
1311
1344
|
// can not set or remove
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "imapflow",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.8",
|
|
4
4
|
"description": "IMAP Client for Node",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/cjs/imap-flow.js",
|
|
@@ -51,7 +51,8 @@
|
|
|
51
51
|
"lint": "npm run typecheck && eslint .",
|
|
52
52
|
"lint:fix": "eslint . --fix",
|
|
53
53
|
"prepare": "npm run build",
|
|
54
|
-
"update": "rm -rf node_modules package-lock.json && ncu -u && npm install"
|
|
54
|
+
"update": "rm -rf node_modules package-lock.json && ncu -u && npm install",
|
|
55
|
+
"test:james": "bash test/integration/run-james-tests.sh"
|
|
55
56
|
},
|
|
56
57
|
"repository": {
|
|
57
58
|
"type": "git",
|
|
@@ -74,12 +75,13 @@
|
|
|
74
75
|
"eslint": "10.12.0",
|
|
75
76
|
"eslint-config-prettier": "10.1.8",
|
|
76
77
|
"globals": "17.13.0",
|
|
78
|
+
"imapkit": "4.1.0",
|
|
77
79
|
"prettier": "3.9.9",
|
|
78
80
|
"tsx": "4.23.15",
|
|
79
81
|
"types-node-legacy": "npm:@types/node@20.0.0",
|
|
80
82
|
"typescript": "6.0.3",
|
|
81
83
|
"typescript-eslint": "8.71.1",
|
|
82
|
-
"wrangler": "4.
|
|
84
|
+
"wrangler": "4.148.0"
|
|
83
85
|
},
|
|
84
86
|
"dependencies": {
|
|
85
87
|
"@zone-eu/mailsplit": "5.4.20",
|