imapflow 1.6.3 → 1.6.4
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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/lib/commands/compress.js +16 -0
- package/lib/commands/search.js +10 -0
- package/lib/handler/imap-compiler.js +76 -7
- package/lib/handler/imap-stream.js +13 -5
- package/lib/handler/parser-instance.js +22 -3
- package/lib/imap-flow.d.ts +2 -0
- package/lib/imap-flow.js +43 -5
- package/lib/search-compiler.js +5 -2
- package/lib/tools.js +18 -4
- package/package.json +1 -1
- package/test/commands-integration-test.js +91 -0
- package/test/imap-compiler-test.js +98 -0
- package/test/imap-flow-secure-test.js +56 -0
- package/test/imap-flow-server-test.js +36 -0
- package/test/imap-parser-test.js +26 -0
- package/test/imap-stream-edge-cases-test.js +48 -0
- package/test/integration/rev2-live-test.js +37 -3
- package/test/search-test.js +32 -3
- package/test/tools-test.js +28 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.6.4](https://github.com/postalsys/imapflow/compare/v1.6.3...v1.6.4) (2026-07-29)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* block IMAP command injection and harden rev2 protocol handling ([e107292](https://github.com/postalsys/imapflow/commit/e107292bc907b29d94b0d810dc01e9680f820bfa))
|
|
9
|
+
|
|
3
10
|
## [1.6.3](https://github.com/postalsys/imapflow/compare/v1.6.2...v1.6.3) (2026-07-28)
|
|
4
11
|
|
|
5
12
|
|
package/lib/commands/compress.js
CHANGED
|
@@ -19,7 +19,23 @@ module.exports = async connection => {
|
|
|
19
19
|
let response;
|
|
20
20
|
try {
|
|
21
21
|
response = await connection.exec('COMPRESS', [{ type: 'ATOM', value: 'DEFLATE' }]);
|
|
22
|
+
// Everything after the tagged OK is already deflate-framed (RFC 4978 section 4).
|
|
23
|
+
// The socket stays piped into the plaintext parser until this call returns, so
|
|
24
|
+
// bytes that arrived in the same chunk as the OK have been consumed as cleartext
|
|
25
|
+
// and are missing from the head of the deflate stream - the inflater would then
|
|
26
|
+
// fail and take the connection with it. Rare (it needs the server to write again
|
|
27
|
+
// before we re-pipe), and staying uncompressed is a cheaper outcome than a dead
|
|
28
|
+
// connection, so decline the upgrade instead.
|
|
29
|
+
// This only covers the bytes the parser had already buffered when the OK was
|
|
30
|
+
// handled. Closing the remaining window, and keeping compression rather than
|
|
31
|
+
// dropping it, needs the stream to hand back its unconsumed tail on unpipe so
|
|
32
|
+
// the transport can feed it into the inflater - not something a command module
|
|
33
|
+
// can reach from here.
|
|
22
34
|
response.next();
|
|
35
|
+
if (response.hasTrailingData) {
|
|
36
|
+
connection.log.warn({ msg: 'Server sent data immediately after the COMPRESS response, skipping compression', cid: connection.id });
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
23
39
|
return true;
|
|
24
40
|
} catch (err) {
|
|
25
41
|
connection.log.warn({ err, cid: connection.id });
|
package/lib/commands/search.js
CHANGED
|
@@ -27,6 +27,8 @@ const stripEsearchPrefix = attrs => {
|
|
|
27
27
|
*
|
|
28
28
|
* ALL and PARTIAL.messages are kept as compact sequence-set strings.
|
|
29
29
|
* Use expandRange() from tools.js if you need to expand them.
|
|
30
|
+
* MODSEQ (RFC 7162, sent when the search used a MODSEQ criterion) is
|
|
31
|
+
* returned as a BigInt.
|
|
30
32
|
*
|
|
31
33
|
* @param {Array} attrs - Attribute array from the IMAP parser
|
|
32
34
|
* @returns {Object} ESearchResult object
|
|
@@ -61,6 +63,14 @@ function parseEsearchResponse(attrs) {
|
|
|
61
63
|
if (!isNaN(n)) result.max = n;
|
|
62
64
|
break;
|
|
63
65
|
}
|
|
66
|
+
case 'MODSEQ': {
|
|
67
|
+
// RFC 7162 section 3.1.5: present when the SEARCH used a MODSEQ
|
|
68
|
+
// criterion on a CONDSTORE-enabled session. BigInt because
|
|
69
|
+
// mod-sequence values are unsigned 63-bit
|
|
70
|
+
const value = attrs[++i]?.value;
|
|
71
|
+
if (typeof value === 'string' && /^\d+$/.test(value)) result.modseq = BigInt(value);
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
64
74
|
case 'ALL': {
|
|
65
75
|
const allToken = attrs[++i];
|
|
66
76
|
if (allToken && typeof allToken.value === 'string') {
|
|
@@ -4,6 +4,39 @@
|
|
|
4
4
|
|
|
5
5
|
const imapFormalSyntax = require('./imap-formal-syntax');
|
|
6
6
|
|
|
7
|
+
// A sequence-set as defined by the RFC 9051 grammar: comma-separated numbers and
|
|
8
|
+
// ranges, where "*" stands for the largest number in use. Digit strings are not
|
|
9
|
+
// range-checked here (a server rejects "0" or an overlong number on its own); the
|
|
10
|
+
// point of the check is that nothing outside this alphabet can reach the wire.
|
|
11
|
+
const SEQUENCE_SET = /^(\d+|\*)(:(\d+|\*))?(,(\d+|\*)(:(\d+|\*))?)*$/;
|
|
12
|
+
|
|
13
|
+
// Characters that cannot appear in an IMAP quoted string: CR and LF terminate a
|
|
14
|
+
// command line, and NUL is outside the CHAR production entirely. A value carrying
|
|
15
|
+
// any of them has to be sent as a literal, so quoting it is never correct.
|
|
16
|
+
const NOT_QUOTABLE = /[\r\n\0]/;
|
|
17
|
+
|
|
18
|
+
// A line terminator ends an IMAP command, so no token may carry one to the wire.
|
|
19
|
+
const CRLF = /[\r\n]/;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Quotes a value as an IMAP quoted string. Only DQUOTE and backslash are escaped -
|
|
23
|
+
* the IMAP grammar defines no other escape sequence, so JSON-style escaping (which
|
|
24
|
+
* turns a tab into a literal backslash-t and a control character into \\uXXXX) would
|
|
25
|
+
* silently change the value the server receives.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} value - The value to quote.
|
|
28
|
+
* @returns {string} The quoted string, ready to be written to the wire.
|
|
29
|
+
* @throws {Error} If the value contains CR, LF or NUL, which a quoted string cannot carry.
|
|
30
|
+
*/
|
|
31
|
+
const quoteString = value => {
|
|
32
|
+
if (NOT_QUOTABLE.test(value)) {
|
|
33
|
+
let error = new Error('Unquotable character in IMAP string value');
|
|
34
|
+
error.code = 'InvalidStringValue';
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
return '"' + value.replace(/["\\]/g, char => '\\' + char) + '"';
|
|
38
|
+
};
|
|
39
|
+
|
|
7
40
|
/**
|
|
8
41
|
* Formats a response entry into a Buffer.
|
|
9
42
|
*
|
|
@@ -111,7 +144,7 @@ module.exports = async (response, options) => {
|
|
|
111
144
|
if (isLogging && node.length > 100) {
|
|
112
145
|
resp.push(formatRespEntry('"(* ' + node.length + 'B string *)"'));
|
|
113
146
|
} else {
|
|
114
|
-
resp.push(formatRespEntry(JSON.stringify(node.toString())));
|
|
147
|
+
resp.push(formatRespEntry(isLogging ? JSON.stringify(node.toString()) : quoteString(node.toString())));
|
|
115
148
|
}
|
|
116
149
|
return;
|
|
117
150
|
}
|
|
@@ -168,19 +201,51 @@ module.exports = async (response, options) => {
|
|
|
168
201
|
if (isLogging && node.value.length > 100) {
|
|
169
202
|
resp.push(formatRespEntry('"(* ' + node.value.length + 'B string *)"'));
|
|
170
203
|
} else {
|
|
171
|
-
|
|
204
|
+
val = (node.value || '').toString();
|
|
205
|
+
resp.push(formatRespEntry(isLogging ? JSON.stringify(val) : quoteString(val)));
|
|
172
206
|
}
|
|
173
207
|
break;
|
|
174
208
|
|
|
175
|
-
case 'TEXT':
|
|
176
209
|
case 'SEQUENCE':
|
|
210
|
+
// Sequence sets are written verbatim - they are the one token type with
|
|
211
|
+
// no quoting to fall back on. Callers build them from user-supplied
|
|
212
|
+
// ranges, so validate here, at the single point every outgoing sequence
|
|
213
|
+
// set passes through, rather than trusting each command module. Values
|
|
214
|
+
// the parser produced for a server response are already within this
|
|
215
|
+
// alphabet, so re-compiling a response (logging, error text) is unaffected.
|
|
216
|
+
if (typeof node.value === 'string' || typeof node.value === 'number' || Buffer.isBuffer(node.value)) {
|
|
217
|
+
val = node.value.toString();
|
|
218
|
+
if (val && !SEQUENCE_SET.test(val)) {
|
|
219
|
+
let error = new Error('Invalid sequence set value');
|
|
220
|
+
error.code = 'InvalidSequenceSet';
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
177
224
|
if (node.value) {
|
|
178
225
|
resp.push(formatRespEntry(node.value));
|
|
179
226
|
}
|
|
180
227
|
break;
|
|
181
228
|
|
|
229
|
+
case 'TEXT':
|
|
230
|
+
// Response text is written verbatim. Only the parser produces it today, for
|
|
231
|
+
// incoming lines, so this is a re-encoding path rather than a command-building
|
|
232
|
+
// one - but the choke point is only worth anything if it holds for every token
|
|
233
|
+
// type, so a line terminator is refused here too.
|
|
234
|
+
if (node.value) {
|
|
235
|
+
if (!isLogging && CRLF.test(node.value.toString())) {
|
|
236
|
+
let error = new Error('Line terminator in IMAP text value');
|
|
237
|
+
error.code = 'InvalidTextValue';
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
240
|
+
resp.push(formatRespEntry(node.value));
|
|
241
|
+
}
|
|
242
|
+
break;
|
|
243
|
+
|
|
182
244
|
case 'NUMBER':
|
|
183
|
-
|
|
245
|
+
// Coerced rather than written through: formatRespEntry passes a string or
|
|
246
|
+
// Buffer straight to the wire, so a numeric token carrying a string value
|
|
247
|
+
// would be another verbatim channel
|
|
248
|
+
resp.push(formatRespEntry(Math.round(Number(node.value)) || 0));
|
|
184
249
|
break;
|
|
185
250
|
|
|
186
251
|
case 'ATOM':
|
|
@@ -192,7 +257,7 @@ module.exports = async (response, options) => {
|
|
|
192
257
|
// Strip a leading backslash before checking (system flags like \Seen start with '\').
|
|
193
258
|
// If any character fails verification, quote-escape the entire value with JSON.stringify.
|
|
194
259
|
if (node.value === '' || imapFormalSyntax.verify(val.charAt(0) === '\\' ? val.substr(1) : val, imapFormalSyntax['ATOM-CHAR']()) >= 0) {
|
|
195
|
-
val = JSON.stringify(val);
|
|
260
|
+
val = isLogging ? JSON.stringify(val) : quoteString(val);
|
|
196
261
|
}
|
|
197
262
|
|
|
198
263
|
resp.push(formatRespEntry(val));
|
|
@@ -209,9 +274,13 @@ module.exports = async (response, options) => {
|
|
|
209
274
|
|
|
210
275
|
resp.push(formatRespEntry(']'));
|
|
211
276
|
}
|
|
212
|
-
// Partial range: emit <origin.length> after the section brackets
|
|
277
|
+
// Partial range: emit <origin.length> after the section brackets. Coerced
|
|
278
|
+
// rather than joined as-is: this is the last token component written
|
|
279
|
+
// verbatim, and the choke point is only worth relying on if it holds for
|
|
280
|
+
// all of them. Every producer already passes numbers, so nothing changes
|
|
281
|
+
// for them.
|
|
213
282
|
if (node.partial) {
|
|
214
|
-
resp.push(formatRespEntry(`<${node.partial.join('.')}>`));
|
|
283
|
+
resp.push(formatRespEntry(`<${node.partial.map(entry => Number(entry) || 0).join('.')}>`));
|
|
215
284
|
}
|
|
216
285
|
break;
|
|
217
286
|
}
|
|
@@ -165,16 +165,24 @@ class ImapStream extends Transform {
|
|
|
165
165
|
pos--;
|
|
166
166
|
|
|
167
167
|
// Scan backwards through the line to find an IMAP literal marker: {size}\r\n
|
|
168
|
-
// The format is: '{' followed by one or more ASCII digits followed by '}'
|
|
169
|
-
|
|
168
|
+
// The format is: '{' followed by one or more ASCII digits followed by '}'.
|
|
169
|
+
// Only the digit run's bounds are tracked, and the run is capped: the size is
|
|
170
|
+
// a number64 at most, so anything longer cannot be a valid marker. Collecting
|
|
171
|
+
// the digits into a growing array instead would make a line of n digits cost
|
|
172
|
+
// O(n^2), letting a few hundred KB of digits block the event loop for seconds
|
|
173
|
+
// before the size is even known - well inside the default line-length cap.
|
|
174
|
+
const MAX_SIZE_DIGITS = 19;
|
|
175
|
+
let digitsEnd = pos;
|
|
170
176
|
for (; pos >= 0; pos--) {
|
|
171
177
|
let c = line[pos];
|
|
172
178
|
if (c >= NUM_0 && c <= NUM_9) {
|
|
173
|
-
|
|
179
|
+
if (digitsEnd - pos >= MAX_SIZE_DIGITS) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
174
182
|
continue;
|
|
175
183
|
}
|
|
176
|
-
if (c === CURLY_OPEN &&
|
|
177
|
-
const literalSize = Number(
|
|
184
|
+
if (c === CURLY_OPEN && pos < digitsEnd) {
|
|
185
|
+
const literalSize = Number(line.toString('latin1', pos + 1, digitsEnd + 1));
|
|
178
186
|
|
|
179
187
|
if (literalSize > this.maxLiteralSize) {
|
|
180
188
|
return this.failStream(createLiteralTooLargeError(literalSize, this.maxLiteralSize));
|
|
@@ -77,8 +77,13 @@ class ParserInstance {
|
|
|
77
77
|
{
|
|
78
78
|
let match = this.remainder.match(/^\s+\[/);
|
|
79
79
|
if (match) {
|
|
80
|
+
// Find the ']' that closes the response code. Inner brackets are
|
|
81
|
+
// tracked because servers do put bracketed values inside a code
|
|
82
|
+
// (e.g. a "[css3-page]" keyword in a PERMANENTFLAGS list), which a
|
|
83
|
+
// first-']' scan would cut in half.
|
|
80
84
|
let nesting = 1;
|
|
81
|
-
|
|
85
|
+
let end = -1;
|
|
86
|
+
for (let i = match[0].length; i < this.remainder.length; i++) {
|
|
82
87
|
let c = this.remainder[i];
|
|
83
88
|
|
|
84
89
|
if (c === '[') {
|
|
@@ -87,11 +92,25 @@ class ParserInstance {
|
|
|
87
92
|
nesting--;
|
|
88
93
|
}
|
|
89
94
|
if (!nesting) {
|
|
90
|
-
|
|
91
|
-
this.remainder = this.remainder.substring(0, i + 1);
|
|
95
|
+
end = i;
|
|
92
96
|
break;
|
|
93
97
|
}
|
|
94
98
|
}
|
|
99
|
+
|
|
100
|
+
// Unbalanced '[' inside the code: the RFC 9051 free-text form
|
|
101
|
+
// (`atom [SP 1*<any TEXT-CHAR except "]">]`) permits '[' but not
|
|
102
|
+
// ']', so the code really does end at the first ']' here. Without
|
|
103
|
+
// this fallback the scan finds no closing bracket at all and the
|
|
104
|
+
// human-readable text - what every error message is built from -
|
|
105
|
+
// is swallowed into the response code.
|
|
106
|
+
if (end < 0) {
|
|
107
|
+
end = this.remainder.indexOf(']', match[0].length);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (end >= 0) {
|
|
111
|
+
this.humanReadable = this.remainder.substring(end + 1).trim();
|
|
112
|
+
this.remainder = this.remainder.substring(0, end + 1);
|
|
113
|
+
}
|
|
95
114
|
} else {
|
|
96
115
|
this.humanReadable = this.remainder.trim();
|
|
97
116
|
this.remainder = '';
|
package/lib/imap-flow.d.ts
CHANGED
|
@@ -699,6 +699,8 @@ export interface ESearchResult {
|
|
|
699
699
|
/** Matching UIDs in that range as compact sequence-set */
|
|
700
700
|
messages: string;
|
|
701
701
|
};
|
|
702
|
+
/** Highest mod-sequence of the matching messages (RFC 7162, present when the search used a modseq criterion on a CONDSTORE session) */
|
|
703
|
+
modseq?: bigint;
|
|
702
704
|
}
|
|
703
705
|
|
|
704
706
|
export class AuthenticationFailure extends Error {
|
package/lib/imap-flow.js
CHANGED
|
@@ -834,6 +834,33 @@ class ImapFlow extends EventEmitter {
|
|
|
834
834
|
}
|
|
835
835
|
}
|
|
836
836
|
|
|
837
|
+
/**
|
|
838
|
+
* Fails the in-flight command when a line that could not be parsed was addressed to its tag.
|
|
839
|
+
* Only the leading tag is read from the raw payload - the rest of the line is by definition
|
|
840
|
+
* not trustworthy - and only the command that is actually on the wire may be settled this way,
|
|
841
|
+
* the same invariant the parsed tagged-response path enforces.
|
|
842
|
+
*
|
|
843
|
+
* @param {Buffer} payload - Raw bytes of the line that failed to parse.
|
|
844
|
+
* @param {Error} parserError - The error the parser raised.
|
|
845
|
+
*/
|
|
846
|
+
rejectUnparsedCompletion(payload, parserError) {
|
|
847
|
+
if (!this.currentRequest || !this.currentRequest.sent) {
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
let tag = payload.toString('latin1', 0, 64).match(/^(\S+)/);
|
|
852
|
+
if (!tag || tag[1] !== this.currentRequest.tag) {
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
let err = new Error('Failed to parse the server response for this command');
|
|
857
|
+
err.code = parserError.code || 'ParserError';
|
|
858
|
+
err.parserError = parserError;
|
|
859
|
+
this.rejectCurrentRequest(err);
|
|
860
|
+
|
|
861
|
+
this.trySend().catch(sendErr => this.log.warn({ err: sendErr, cid: this.id }));
|
|
862
|
+
}
|
|
863
|
+
|
|
837
864
|
/**
|
|
838
865
|
* Handles a single parsed server response: telemetry, continuation requests, response-code
|
|
839
866
|
* section handlers, untagged handlers and tagged command completion.
|
|
@@ -863,6 +890,14 @@ class ImapFlow extends EventEmitter {
|
|
|
863
890
|
} catch (err) {
|
|
864
891
|
// can not make sense of this
|
|
865
892
|
this.log.error({ src: 's', msg: data.payload.toString(), err, cid: this.id });
|
|
893
|
+
// An unparseable untagged line is junk that can be skipped, but the line may
|
|
894
|
+
// have been the in-flight command's tagged completion. Dropping that one
|
|
895
|
+
// silently strands the command: currentRequest is never cleared, so trySend()
|
|
896
|
+
// stops dispatching and every later command queues behind it until the socket
|
|
897
|
+
// timeout fires. The tag is recovered from the raw bytes (a tag is
|
|
898
|
+
// ASTRING-CHAR only, so it survives whatever made the rest unparseable) and
|
|
899
|
+
// the command is failed with the parser error instead of hanging.
|
|
900
|
+
this.rejectUnparsedCompletion(data.payload, err);
|
|
866
901
|
return true;
|
|
867
902
|
}
|
|
868
903
|
|
|
@@ -1629,11 +1664,14 @@ class ImapFlow extends EventEmitter {
|
|
|
1629
1664
|
this.writeSocket = this.socket;
|
|
1630
1665
|
});
|
|
1631
1666
|
|
|
1632
|
-
if (upgraded
|
|
1633
|
-
//
|
|
1634
|
-
//
|
|
1635
|
-
//
|
|
1636
|
-
//
|
|
1667
|
+
if (upgraded) {
|
|
1668
|
+
// RFC 9051 section 6.2.1: once TLS is started the client MUST discard the
|
|
1669
|
+
// cached capabilities and reissue CAPABILITY, because everything learned
|
|
1670
|
+
// before the handshake was plaintext an active attacker could rewrite.
|
|
1671
|
+
// Unconditional on purpose: a server that stamps [CAPABILITY ...] on the
|
|
1672
|
+
// STARTTLS OK itself clears expectCapabilityUpdate, so keying the discard
|
|
1673
|
+
// on that flag would keep exactly the pre-TLS list an attacker controls -
|
|
1674
|
+
// the list that then picks the AUTH mechanism and answers LOGINDISABLED.
|
|
1637
1675
|
this.capabilities.clear();
|
|
1638
1676
|
this.authCapabilities.clear();
|
|
1639
1677
|
await this.run('CAPABILITY');
|
package/lib/search-compiler.js
CHANGED
|
@@ -233,10 +233,13 @@ module.exports.searchCompiler = (connection, query) => {
|
|
|
233
233
|
}
|
|
234
234
|
break;
|
|
235
235
|
|
|
236
|
-
// UID sequences
|
|
236
|
+
// UID sequences. The key stays an ATOM and only the value is a
|
|
237
|
+
// SEQUENCE token, so the compiler validates the sequence set
|
|
238
|
+
// itself rather than the "UID" keyword in front of it.
|
|
237
239
|
case 'UID':
|
|
238
240
|
if (params[term]) {
|
|
239
|
-
|
|
241
|
+
attributes.push({ type: 'ATOM', value: 'UID' });
|
|
242
|
+
[].concat(params[term]).forEach(entry => attributes.push({ type: 'SEQUENCE', value: (entry ?? '').toString() }));
|
|
240
243
|
}
|
|
241
244
|
break;
|
|
242
245
|
|
package/lib/tools.js
CHANGED
|
@@ -20,9 +20,13 @@ const EXPANDED_RANGE_LIMIT = 0x1000000;
|
|
|
20
20
|
// When IMAP4rev2 is active, these are available even without their own capability
|
|
21
21
|
// token. BINARY is deliberately excluded - RFC 9051 only folds in the FETCH side,
|
|
22
22
|
// which fetch.js handles with its own isRev2Active check, while the APPEND side
|
|
23
|
-
// stays gated on the BINARY token.
|
|
24
|
-
//
|
|
25
|
-
//
|
|
23
|
+
// stays gated on the BINARY token. SPECIAL-USE is a partial fold: Appendix E only
|
|
24
|
+
// folds in the special-use mailbox attributes, not the RFC 6154 LIST selection and
|
|
25
|
+
// RETURN options - the only call sites that act on this entry are in list.js,
|
|
26
|
+
// where a staged retry ladder recovers if a rev2-only server rejects the RETURN
|
|
27
|
+
// option. The set mirrors the rest of the Appendix E list in full, including
|
|
28
|
+
// entries no call site consults yet, so any future capability check gets the
|
|
29
|
+
// rev2 folding for free.
|
|
26
30
|
const IMAP4REV2_FOLDED_CAPABILITIES = new Set([
|
|
27
31
|
'ENABLE',
|
|
28
32
|
'ESEARCH',
|
|
@@ -308,7 +312,17 @@ const tools = {
|
|
|
308
312
|
return false;
|
|
309
313
|
}
|
|
310
314
|
|
|
311
|
-
|
|
315
|
+
try {
|
|
316
|
+
return (await compiler(response)).toString();
|
|
317
|
+
} catch {
|
|
318
|
+
// The wire encoder refuses values that cannot be expressed as a valid IMAP
|
|
319
|
+
// string, which is what keeps user-supplied data from breaking out of a
|
|
320
|
+
// command. A server response is not held to that: the parser deliberately
|
|
321
|
+
// tolerates stray bytes inside an OK/NO/BAD atom, and those bytes then have
|
|
322
|
+
// no valid re-encoding. This text is diagnostic, so fall back to the logging
|
|
323
|
+
// encoder rather than replacing the server's error with an encoding failure.
|
|
324
|
+
return (await compiler(response, { isLogging: true })).toString();
|
|
325
|
+
}
|
|
312
326
|
},
|
|
313
327
|
|
|
314
328
|
/**
|
package/package.json
CHANGED
|
@@ -606,6 +606,97 @@ module.exports['Commands: search drops * from ESEARCH UID results'] = async test
|
|
|
606
606
|
test.done();
|
|
607
607
|
};
|
|
608
608
|
|
|
609
|
+
module.exports['Commands: search discards invalid single values in an ESEARCH ALL set'] = async test => {
|
|
610
|
+
const connection = createMockConnection({
|
|
611
|
+
state: 3,
|
|
612
|
+
capabilities: new Map([['IMAP4rev2', true]]),
|
|
613
|
+
mailbox: { path: 'INBOX', exists: 5 },
|
|
614
|
+
exec: async (cmd, attrs, opts) => {
|
|
615
|
+
// '0' is not a valid nz-number and 'foo' is garbage - both single
|
|
616
|
+
// values must be dropped while the valid one survives
|
|
617
|
+
await opts.untagged.ESEARCH({
|
|
618
|
+
attributes: [
|
|
619
|
+
{ type: 'ATOM', value: 'ALL' },
|
|
620
|
+
{ type: 'ATOM', value: '0,foo,4' }
|
|
621
|
+
]
|
|
622
|
+
});
|
|
623
|
+
return { next: () => {} };
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
const result = await searchCommand(connection, true, {});
|
|
628
|
+
test.deepEqual(result, [4]);
|
|
629
|
+
test.done();
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
module.exports['Commands: search truncates single-value ESEARCH ALL entries at the mailbox size'] = async test => {
|
|
633
|
+
const connection = createMockConnection({
|
|
634
|
+
state: 3,
|
|
635
|
+
capabilities: new Map([['IMAP4rev2', true]]),
|
|
636
|
+
mailbox: { path: 'INBOX', exists: 2 },
|
|
637
|
+
exec: async (cmd, attrs, opts) => {
|
|
638
|
+
// More single values than the mailbox holds - the walk must stop at
|
|
639
|
+
// the EXISTS budget instead of collecting the excess
|
|
640
|
+
await opts.untagged.ESEARCH({
|
|
641
|
+
attributes: [
|
|
642
|
+
{ type: 'ATOM', value: 'ALL' },
|
|
643
|
+
{ type: 'ATOM', value: '1,2,3,4' }
|
|
644
|
+
]
|
|
645
|
+
});
|
|
646
|
+
return { next: () => {} };
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
const result = await searchCommand(connection, true, {});
|
|
651
|
+
test.deepEqual(result, [1, 2]);
|
|
652
|
+
test.done();
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
module.exports['Commands: search ignores an ESEARCH reply without attributes on the plain path'] = async test => {
|
|
656
|
+
const connection = createMockConnection({
|
|
657
|
+
state: 3,
|
|
658
|
+
capabilities: new Map([['IMAP4rev2', true]]),
|
|
659
|
+
exec: async (cmd, attrs, opts) => {
|
|
660
|
+
// A degenerate untagged ESEARCH with no attributes must not crash
|
|
661
|
+
// the collector or contribute results
|
|
662
|
+
await opts.untagged.ESEARCH({ attributes: null });
|
|
663
|
+
await opts.untagged.ESEARCH({
|
|
664
|
+
attributes: [
|
|
665
|
+
{ type: 'ATOM', value: 'ALL' },
|
|
666
|
+
{ type: 'ATOM', value: '2' }
|
|
667
|
+
]
|
|
668
|
+
});
|
|
669
|
+
return { next: () => {} };
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
const result = await searchCommand(connection, true, {});
|
|
674
|
+
test.deepEqual(result, [2]);
|
|
675
|
+
test.done();
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
module.exports['Commands: search treats an ESEARCH ALL set without a mailbox size as empty'] = async test => {
|
|
679
|
+
const connection = createMockConnection({
|
|
680
|
+
state: 3,
|
|
681
|
+
capabilities: new Map([['IMAP4rev2', true]]),
|
|
682
|
+
// No exists value at all - the budget is zero, nothing may be collected
|
|
683
|
+
mailbox: { path: 'INBOX' },
|
|
684
|
+
exec: async (cmd, attrs, opts) => {
|
|
685
|
+
await opts.untagged.ESEARCH({
|
|
686
|
+
attributes: [
|
|
687
|
+
{ type: 'ATOM', value: 'ALL' },
|
|
688
|
+
{ type: 'ATOM', value: '1:3' }
|
|
689
|
+
]
|
|
690
|
+
});
|
|
691
|
+
return { next: () => {} };
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
const result = await searchCommand(connection, true, {});
|
|
696
|
+
test.deepEqual(result, []);
|
|
697
|
+
test.done();
|
|
698
|
+
};
|
|
699
|
+
|
|
609
700
|
module.exports['Commands: search with UID option'] = async test => {
|
|
610
701
|
let execCmd = null;
|
|
611
702
|
const connection = createMockConnection({
|
|
@@ -639,3 +639,101 @@ module.exports['IMAP Compiler: partial range in SECTION'] = test =>
|
|
|
639
639
|
).toString();
|
|
640
640
|
test.ok(compiled.includes('<0.50>'), 'should contain partial range <0.50>');
|
|
641
641
|
});
|
|
642
|
+
|
|
643
|
+
// Returns the error a compile attempt raised, or undefined when it succeeded.
|
|
644
|
+
// Several cases below assert that a token can never reach the wire, and the
|
|
645
|
+
// try/catch is the only interesting part of each.
|
|
646
|
+
const compileError = async attributes => {
|
|
647
|
+
try {
|
|
648
|
+
await compiler({ tag: 'A', command: 'CMD', attributes });
|
|
649
|
+
} catch (err) {
|
|
650
|
+
return err;
|
|
651
|
+
}
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
module.exports['IMAP Compiler: SEQUENCE rejects values that are not sequence sets'] = test =>
|
|
655
|
+
asyncWrapper(test, async test => {
|
|
656
|
+
// Sequence sets are written verbatim, so a range string that reached the
|
|
657
|
+
// compiler unvalidated would put a second command on the wire
|
|
658
|
+
for (let value of ['1\r\nZZ1 LOGOUT', '1 2', '1;2', 'ALL', '1:2)', '1\t2', Buffer.from('1\r\nZZ NOOP')]) {
|
|
659
|
+
let err = await compileError([{ type: 'SEQUENCE', value }]);
|
|
660
|
+
test.equal(err && err.code, 'InvalidSequenceSet', `${JSON.stringify(value.toString())} must be rejected`);
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
module.exports['IMAP Compiler: SEQUENCE accepts every valid sequence-set form'] = test =>
|
|
665
|
+
asyncWrapper(test, async test => {
|
|
666
|
+
for (let value of ['1', '*', '1:*', '*:1', '1,3,5', '1:3,7,9:*', '4294967295']) {
|
|
667
|
+
const compiled = (await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'SEQUENCE', value }] })).toString();
|
|
668
|
+
test.equal(compiled, `A FETCH ${value}`);
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
module.exports['IMAP Compiler: quoted strings use IMAP escaping, not JSON escaping'] = test =>
|
|
673
|
+
asyncWrapper(test, async test => {
|
|
674
|
+
// Only DQUOTE and backslash have escapes in the IMAP grammar - a tab must
|
|
675
|
+
// survive as a raw byte rather than becoming a literal backslash-t
|
|
676
|
+
const compiled = (await compiler({ tag: 'A', command: 'CMD', attributes: [{ type: 'STRING', value: 'a\tb"c\\d' }] })).toString();
|
|
677
|
+
test.equal(compiled, 'A CMD "a\tb\\"c\\\\d"');
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
module.exports['IMAP Compiler: quoted strings reject CR, LF and NUL'] = test =>
|
|
681
|
+
asyncWrapper(test, async test => {
|
|
682
|
+
for (let value of ['a\rb', 'a\nb', 'a\0b']) {
|
|
683
|
+
let err = await compileError([{ type: 'STRING', value }]);
|
|
684
|
+
test.equal(err && err.code, 'InvalidStringValue', `${JSON.stringify(value)} cannot be sent as a quoted string`);
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
module.exports['IMAP Compiler: a mailbox name with CRLF cannot reach the wire'] = test =>
|
|
689
|
+
asyncWrapper(test, async test => {
|
|
690
|
+
// Paths travel as ATOM tokens and get quoted when they fall outside ATOM-CHAR
|
|
691
|
+
let err = await compileError([{ type: 'ATOM', value: 'INBOX\r\nZZ LOGOUT' }]);
|
|
692
|
+
test.equal(err && err.code, 'InvalidStringValue');
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
module.exports['IMAP Compiler: response text cannot carry a line terminator'] = test =>
|
|
696
|
+
asyncWrapper(test, async test => {
|
|
697
|
+
// TEXT is written verbatim as well, so it gets the same guarantee even though
|
|
698
|
+
// only the parser produces it today
|
|
699
|
+
let err = await compileError([{ type: 'TEXT', value: 'oops\r\nZZ NOOP' }]);
|
|
700
|
+
test.equal(err && err.code, 'InvalidTextValue');
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
module.exports['IMAP Compiler: NUMBER coerces its value instead of writing it through'] = test =>
|
|
704
|
+
asyncWrapper(test, async test => {
|
|
705
|
+
const compiled = (await compiler({ tag: 'A', command: 'CMD', attributes: [{ type: 'NUMBER', value: '1\r\nZZ NOOP' }] })).toString();
|
|
706
|
+
test.equal(compiled, 'A CMD 0', 'a non-numeric value must not reach the wire verbatim');
|
|
707
|
+
test.equal((await compiler({ tag: 'A', command: 'CMD', attributes: [{ type: 'NUMBER', value: '42' }] })).toString(), 'A CMD 42');
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
module.exports['IMAP Compiler: partial range coerces its elements'] = test =>
|
|
711
|
+
asyncWrapper(test, async test => {
|
|
712
|
+
// The partial range is the last token component written verbatim, so it is
|
|
713
|
+
// coerced rather than joined as-is
|
|
714
|
+
const attributes = [{ type: 'ATOM', value: 'BODY.PEEK', section: [], partial: ['0>\r\nZZ NOOP'] }];
|
|
715
|
+
const compiled = (await compiler({ tag: 'A', command: 'FETCH', attributes })).toString();
|
|
716
|
+
test.equal(compiled, 'A FETCH BODY.PEEK[]<0>');
|
|
717
|
+
|
|
718
|
+
const normal = (
|
|
719
|
+
await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'ATOM', value: 'BODY.PEEK', section: [], partial: [0, 1024] }] })
|
|
720
|
+
).toString();
|
|
721
|
+
test.equal(normal, 'A FETCH BODY.PEEK[]<0.1024>');
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
module.exports['IMAP Compiler: logging output never throws on unsendable values'] = test =>
|
|
725
|
+
asyncWrapper(test, async test => {
|
|
726
|
+
// The logging pass must survive whatever the wire pass refuses, so a rejected
|
|
727
|
+
// command can still be logged
|
|
728
|
+
const compiled = (
|
|
729
|
+
await compiler(
|
|
730
|
+
{
|
|
731
|
+
tag: 'A',
|
|
732
|
+
command: 'LOGIN',
|
|
733
|
+
attributes: [{ type: 'STRING', value: 'a\r\nb' }]
|
|
734
|
+
},
|
|
735
|
+
{ isLogging: true }
|
|
736
|
+
)
|
|
737
|
+
).toString();
|
|
738
|
+
test.ok(compiled.includes('\\r\\n'), 'control characters stay escaped for the log');
|
|
739
|
+
});
|
|
@@ -135,6 +135,62 @@ module.exports['Secure: STARTTLS upgrade completes a session'] = async test => {
|
|
|
135
135
|
test.done();
|
|
136
136
|
};
|
|
137
137
|
|
|
138
|
+
module.exports['Secure: STARTTLS discards capabilities even when the OK carries a CAPABILITY code'] = async test => {
|
|
139
|
+
// A server (or a MITM rewriting the plaintext stream) may stamp [CAPABILITY ...]
|
|
140
|
+
// on the STARTTLS OK itself. That marks the capability set as freshly updated, so
|
|
141
|
+
// a discard conditioned on "an update is still pending" would keep exactly the
|
|
142
|
+
// pre-TLS list an attacker controls - the list that then chooses the AUTH
|
|
143
|
+
// mechanism and answers LOGINDISABLED. RFC 9051 6.2.1 makes the discard mandatory.
|
|
144
|
+
let server = net.createServer(rawSocket => {
|
|
145
|
+
rawSocket.on('error', () => {});
|
|
146
|
+
|
|
147
|
+
let detachPlain;
|
|
148
|
+
detachPlain = lineReader(rawSocket, line => {
|
|
149
|
+
let parts = line.split(' ');
|
|
150
|
+
let tag = parts[0];
|
|
151
|
+
let cmd = (parts[1] || '').toUpperCase();
|
|
152
|
+
|
|
153
|
+
if (cmd === 'STARTTLS') {
|
|
154
|
+
rawSocket.write(`${tag} OK [CAPABILITY ${CAPS} PRETLS-ONLY] Begin TLS\r\n`);
|
|
155
|
+
detachPlain();
|
|
156
|
+
let tlsSocket = new tls.TLSSocket(rawSocket, { isServer: true, key, cert });
|
|
157
|
+
tlsSocket.on('error', () => {});
|
|
158
|
+
lineReader(tlsSocket, l => handleLine(tlsSocket, l, null, `${CAPS} POSTTLS-ONLY`));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
handleLine(rawSocket, line, null, `${CAPS} STARTTLS PRETLS-ONLY`);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS PRETLS-ONLY] ready\r\n`);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
let port = await listen(server);
|
|
169
|
+
let client = new ImapFlow({
|
|
170
|
+
host: '127.0.0.1',
|
|
171
|
+
port,
|
|
172
|
+
secure: false,
|
|
173
|
+
doSTARTTLS: true,
|
|
174
|
+
servername: 'localhost',
|
|
175
|
+
tls: { rejectUnauthorized: false },
|
|
176
|
+
disableAutoIdle: true,
|
|
177
|
+
disableCompression: true,
|
|
178
|
+
logger: false,
|
|
179
|
+
auth: { user: 'test', pass: 'secret' }
|
|
180
|
+
});
|
|
181
|
+
client.on('error', () => {});
|
|
182
|
+
|
|
183
|
+
await client.connect();
|
|
184
|
+
test.ok(client.secureConnection, 'connection upgraded to TLS');
|
|
185
|
+
test.ok(client.capabilities.has('POSTTLS-ONLY'), 'post-TLS capabilities were re-fetched');
|
|
186
|
+
test.ok(!client.capabilities.has('PRETLS-ONLY'), 'pre-TLS capabilities were discarded');
|
|
187
|
+
|
|
188
|
+
await client.logout();
|
|
189
|
+
client.close();
|
|
190
|
+
server.close();
|
|
191
|
+
test.done();
|
|
192
|
+
};
|
|
193
|
+
|
|
138
194
|
// Builds the STARTTLS happy-path server used by the watchdog and cleanup tests below.
|
|
139
195
|
const createStartTlsServer = () =>
|
|
140
196
|
net.createServer(rawSocket => {
|
|
@@ -1310,3 +1310,39 @@ module.exports['Server: socket close triggers close handling'] = async test => {
|
|
|
1310
1310
|
client.close();
|
|
1311
1311
|
test.done();
|
|
1312
1312
|
};
|
|
1313
|
+
|
|
1314
|
+
module.exports['Server: an unparseable tagged completion fails the command instead of stalling'] = async test => {
|
|
1315
|
+
// A tagged line the parser cannot make sense of used to be logged and dropped.
|
|
1316
|
+
// currentRequest then stayed set forever, so trySend() stopped dispatching and
|
|
1317
|
+
// every later command queued behind a promise that never settled.
|
|
1318
|
+
let server = createServer({
|
|
1319
|
+
handlers: {
|
|
1320
|
+
SELECT(ctx) {
|
|
1321
|
+
// A control character inside the response code is not parseable
|
|
1322
|
+
ctx.write(`${ctx.tag} OK [\x01BAD-CODE] SELECT completed\r\n`);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
});
|
|
1326
|
+
let port = await listen(server);
|
|
1327
|
+
let client = makeClient(port);
|
|
1328
|
+
client.on('error', () => {});
|
|
1329
|
+
|
|
1330
|
+
await client.connect();
|
|
1331
|
+
|
|
1332
|
+
let selectErr = null;
|
|
1333
|
+
try {
|
|
1334
|
+
await client.mailboxOpen('INBOX');
|
|
1335
|
+
} catch (err) {
|
|
1336
|
+
selectErr = err;
|
|
1337
|
+
}
|
|
1338
|
+
test.ok(selectErr, 'the command whose completion could not be parsed must reject');
|
|
1339
|
+
|
|
1340
|
+
// The connection has to keep working: the next command still gets dispatched
|
|
1341
|
+
let folders = await client.list();
|
|
1342
|
+
test.ok(Array.isArray(folders) && folders.length, 'later commands still run');
|
|
1343
|
+
|
|
1344
|
+
await client.logout();
|
|
1345
|
+
client.close();
|
|
1346
|
+
server.close();
|
|
1347
|
+
test.done();
|
|
1348
|
+
};
|
package/test/imap-parser-test.js
CHANGED
|
@@ -1421,3 +1421,29 @@ module.exports['IMAP Parser, ATOM with <, [, ]'] = test =>
|
|
|
1421
1421
|
]
|
|
1422
1422
|
});
|
|
1423
1423
|
});
|
|
1424
|
+
|
|
1425
|
+
module.exports['IMAP Parser: unbalanced bracket in a response code keeps the human-readable text'] = test =>
|
|
1426
|
+
asyncWrapper(test, async test => {
|
|
1427
|
+
// RFC 9051 lets a response code carry free text containing '[' but not ']'.
|
|
1428
|
+
// Counting that '[' as a nested bracket loses the whole human-readable text,
|
|
1429
|
+
// which is what NO/BAD error messages are built from.
|
|
1430
|
+
const parsed = await parser('A1 NO [XFOO see bar[baz] mailbox is busy');
|
|
1431
|
+
const text = (parsed.attributes || [])
|
|
1432
|
+
.filter(entry => entry && entry.type === 'TEXT')
|
|
1433
|
+
.map(entry => entry.value)
|
|
1434
|
+
.join('');
|
|
1435
|
+
test.equal(text, 'mailbox is busy');
|
|
1436
|
+
});
|
|
1437
|
+
|
|
1438
|
+
module.exports['IMAP Parser: balanced brackets inside a response code are still tolerated'] = test =>
|
|
1439
|
+
asyncWrapper(test, async test => {
|
|
1440
|
+
// Servers do put bracketed values inside a code, so a plain first-']' scan
|
|
1441
|
+
// would cut the code in half
|
|
1442
|
+
const parsed = await parser('* OK [PERMANENTFLAGS ([css3-page] \\*)] Flags permitted.');
|
|
1443
|
+
const text = (parsed.attributes || [])
|
|
1444
|
+
.filter(entry => entry && entry.type === 'TEXT')
|
|
1445
|
+
.map(entry => entry.value)
|
|
1446
|
+
.join('');
|
|
1447
|
+
test.equal(text, 'Flags permitted.');
|
|
1448
|
+
test.equal(parsed.attributes[0].section[0].value, 'PERMANENTFLAGS');
|
|
1449
|
+
});
|
|
@@ -456,3 +456,51 @@ module.exports['ImapStream: _destroy drains pending input queue callbacks'] = te
|
|
|
456
456
|
test.done();
|
|
457
457
|
});
|
|
458
458
|
};
|
|
459
|
+
|
|
460
|
+
module.exports['Literal marker scan stays linear on a long digit run'] = test => {
|
|
461
|
+
// A backwards scan that accumulated digits one at a time cost O(n^2), so a few
|
|
462
|
+
// hundred KB of digits blocked the event loop for seconds before the size was
|
|
463
|
+
// even known - all inside the default line-length budget
|
|
464
|
+
const stream = new ImapStream({ cid: 'test' });
|
|
465
|
+
stream.on('error', () => {});
|
|
466
|
+
stream.resume();
|
|
467
|
+
|
|
468
|
+
const line = Buffer.concat([Buffer.from('* OK {'), Buffer.from('9'.repeat(200000)), Buffer.from('}\r\n')]);
|
|
469
|
+
|
|
470
|
+
const started = process.hrtime.bigint();
|
|
471
|
+
stream.checkLiteralMarker(line);
|
|
472
|
+
const elapsedMs = Number(process.hrtime.bigint() - started) / 1e6;
|
|
473
|
+
|
|
474
|
+
test.ok(elapsedMs < 250, `scan should stay cheap, took ${elapsedMs.toFixed(1)}ms`);
|
|
475
|
+
test.done();
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
module.exports['Literal marker rejects an oversized digit run without parsing it'] = test => {
|
|
479
|
+
const stream = new ImapStream({ cid: 'test' });
|
|
480
|
+
stream.on('error', () => {});
|
|
481
|
+
stream.resume();
|
|
482
|
+
|
|
483
|
+
// Longer than any number64 can be, so it cannot be a valid marker
|
|
484
|
+
const line = Buffer.concat([Buffer.from('* OK {'), Buffer.from('1'.repeat(40)), Buffer.from('}\r\n')]);
|
|
485
|
+
|
|
486
|
+
test.equal(stream.checkLiteralMarker(line), false, 'an impossible size must not start literal mode');
|
|
487
|
+
test.done();
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
module.exports['Literal marker still accepts sizes at the digit-length bound'] = test => {
|
|
491
|
+
const stream = new ImapStream({ cid: 'test', maxLiteralSize: Number.MAX_SAFE_INTEGER });
|
|
492
|
+
stream.on('error', () => {});
|
|
493
|
+
stream.resume();
|
|
494
|
+
|
|
495
|
+
// 19 digits is the widest a number64 gets, so it must still be recognized
|
|
496
|
+
const line = Buffer.from(`* OK {${'9'.repeat(19)}}\r\n`);
|
|
497
|
+
|
|
498
|
+
test.equal(stream.checkLiteralMarker(line), false, 'a size beyond the configured maximum fails the stream rather than parsing');
|
|
499
|
+
|
|
500
|
+
const ok = new ImapStream({ cid: 'test' });
|
|
501
|
+
ok.on('error', () => {});
|
|
502
|
+
ok.resume();
|
|
503
|
+
test.equal(ok.checkLiteralMarker(Buffer.from('* OK {1024}\r\n')), true);
|
|
504
|
+
test.equal(ok.literalWaiting, 1024);
|
|
505
|
+
test.done();
|
|
506
|
+
};
|
|
@@ -150,7 +150,12 @@ module.exports['Live rev2: statusQuery is answered inline via LIST-STATUS'] = as
|
|
|
150
150
|
|
|
151
151
|
const folders = await client.list({ statusQuery: { messages: true, unseen: true } });
|
|
152
152
|
|
|
153
|
-
|
|
153
|
+
// Pin the inline LIST-STATUS behavior: the STATUS request must ride on the
|
|
154
|
+
// LIST command itself, and no standalone STATUS command may be issued -
|
|
155
|
+
// a plain substring check would also pass on the fallback path
|
|
156
|
+
const listLine = wireLines(logs).find(entry => entry.src === 'c' && /^\S+ LIST /.test(entry.msg));
|
|
157
|
+
test.ok(listLine && listLine.msg.includes('RETURN') && listLine.msg.includes('STATUS'), 'LIST should carry RETURN (STATUS ...)');
|
|
158
|
+
test.ok(!wireLines(logs).some(entry => entry.src === 'c' && /^\S+ STATUS /.test(entry.msg)), 'no standalone STATUS command should be needed');
|
|
154
159
|
const inbox = folders.find(folder => folder.path === 'INBOX');
|
|
155
160
|
test.equal(inbox.status.messages, 1, 'inline STATUS should report the appended message');
|
|
156
161
|
} finally {
|
|
@@ -207,6 +212,26 @@ module.exports['Live rev2: returnOptions search is answered via a real ESEARCH r
|
|
|
207
212
|
test.done();
|
|
208
213
|
};
|
|
209
214
|
|
|
215
|
+
module.exports['Live rev2: MODSEQ search criterion surfaces modseq from the ESEARCH response'] = async test => {
|
|
216
|
+
const client = await connectClient();
|
|
217
|
+
try {
|
|
218
|
+
await client.append('INBOX', Buffer.from('Subject: first\r\n\r\nfirst\r\n'));
|
|
219
|
+
await client.append('INBOX', Buffer.from('Subject: second\r\n\r\nsecond\r\n'));
|
|
220
|
+
|
|
221
|
+
await client.mailboxOpen('INBOX');
|
|
222
|
+
// RFC 7162: a MODSEQ criterion on a CONDSTORE session makes the server
|
|
223
|
+
// append MODSEQ to the ESEARCH response
|
|
224
|
+
const result = await client.search({ modseq: 1 }, { returnOptions: ['ALL', 'COUNT'] });
|
|
225
|
+
|
|
226
|
+
test.equal(result.count, 2);
|
|
227
|
+
test.equal(result.all, '1:2');
|
|
228
|
+
test.ok(typeof result.modseq === 'bigint' && result.modseq > 0n, 'modseq should surface as a positive BigInt');
|
|
229
|
+
} finally {
|
|
230
|
+
await client.logout();
|
|
231
|
+
}
|
|
232
|
+
test.done();
|
|
233
|
+
};
|
|
234
|
+
|
|
210
235
|
module.exports['Live rev2: STATUS reports SIZE and DELETED'] = async test => {
|
|
211
236
|
const logs = [];
|
|
212
237
|
const client = await connectClient(null, logs);
|
|
@@ -253,8 +278,17 @@ module.exports['Live rev2: SELECT response carries an untagged LIST and re-selec
|
|
|
253
278
|
test.equal(mailbox.path, 'INBOX');
|
|
254
279
|
|
|
255
280
|
// RFC 9051 6.3.1: the SELECT response includes an untagged LIST for the
|
|
256
|
-
// selected mailbox - the client must consume it without issue
|
|
257
|
-
|
|
281
|
+
// selected mailbox - the client must consume it without issue. Scoped to
|
|
282
|
+
// the SELECT exchange itself: mailboxOpen() also issues its own LIST
|
|
283
|
+
// command first, whose untagged replies would satisfy a global check
|
|
284
|
+
// even if the SELECT response omitted the LIST
|
|
285
|
+
const lines = wireLines(logs);
|
|
286
|
+
const selectIdx = lines.findIndex(entry => entry.src === 'c' && /^\S+ SELECT /.test(entry.msg));
|
|
287
|
+
test.ok(selectIdx >= 0, 'SELECT command should be on the wire');
|
|
288
|
+
const selectTag = lines[selectIdx].msg.split(' ')[0];
|
|
289
|
+
const doneIdx = lines.findIndex((entry, i) => i > selectIdx && entry.src === 's' && entry.msg.startsWith(`${selectTag} `));
|
|
290
|
+
const listInSelect = lines.some((entry, i) => i > selectIdx && i < doneIdx && entry.src === 's' && /^\* LIST( |$)/.test(entry.msg));
|
|
291
|
+
test.ok(listInSelect, 'rev2 SELECT response should include an untagged LIST response');
|
|
258
292
|
|
|
259
293
|
// switching mailboxes must produce a CLOSED response code for the old one
|
|
260
294
|
await client.mailboxOpen('Closer');
|
package/test/search-test.js
CHANGED
|
@@ -212,16 +212,45 @@ module.exports['ESEARCH: command path returns false on exec error'] = test => {
|
|
|
212
212
|
};
|
|
213
213
|
|
|
214
214
|
module.exports['ESEARCH: parseEsearchResponse ignores unknown keywords'] = test => {
|
|
215
|
-
//
|
|
215
|
+
// Unknown result keywords must be skipped with their value so the
|
|
216
|
+
// key/value stream stays aligned for the entries that follow
|
|
217
|
+
const attrs = [
|
|
218
|
+
{ type: 'ATOM', value: 'RELEVANCY' },
|
|
219
|
+
{ type: 'ATOM', value: '87' },
|
|
220
|
+
{ type: 'ATOM', value: 'COUNT' },
|
|
221
|
+
{ type: 'ATOM', value: '5' }
|
|
222
|
+
];
|
|
223
|
+
const result = parseEsearchResponse(attrs);
|
|
224
|
+
test.equal(result.count, 5);
|
|
225
|
+
test.equal(result.relevancy, undefined, 'unknown keys should not appear in result');
|
|
226
|
+
test.done();
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
module.exports['ESEARCH: parseEsearchResponse parses MODSEQ as BigInt'] = test => {
|
|
230
|
+
// RFC 7162: CONDSTORE sessions append MODSEQ to ESEARCH responses when the
|
|
231
|
+
// search used a MODSEQ criterion
|
|
216
232
|
const attrs = [
|
|
217
233
|
{ type: 'ATOM', value: 'COUNT' },
|
|
218
234
|
{ type: 'ATOM', value: '5' },
|
|
219
235
|
{ type: 'ATOM', value: 'MODSEQ' },
|
|
220
|
-
{ type: 'ATOM', value: '
|
|
236
|
+
{ type: 'ATOM', value: '9007199254740993' }
|
|
221
237
|
];
|
|
222
238
|
const result = parseEsearchResponse(attrs);
|
|
223
239
|
test.equal(result.count, 5);
|
|
224
|
-
test.
|
|
240
|
+
test.strictEqual(result.modseq, 9007199254740993n, 'modseq should be an exact BigInt');
|
|
241
|
+
test.done();
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
module.exports['ESEARCH: parseEsearchResponse drops non-numeric MODSEQ'] = test => {
|
|
245
|
+
const attrs = [
|
|
246
|
+
{ type: 'ATOM', value: 'MODSEQ' },
|
|
247
|
+
{ type: 'ATOM', value: 'bogus' },
|
|
248
|
+
{ type: 'ATOM', value: 'COUNT' },
|
|
249
|
+
{ type: 'ATOM', value: '3' }
|
|
250
|
+
];
|
|
251
|
+
const result = parseEsearchResponse(attrs);
|
|
252
|
+
test.equal(result.modseq, undefined, 'invalid modseq must be dropped');
|
|
253
|
+
test.equal(result.count, 3, 'stream must stay aligned after a dropped value');
|
|
225
254
|
test.done();
|
|
226
255
|
};
|
|
227
256
|
|
package/test/tools-test.js
CHANGED
|
@@ -234,8 +234,23 @@ module.exports['Tools: hasCapability with advertised token'] = test => {
|
|
|
234
234
|
|
|
235
235
|
module.exports['Tools: hasCapability folds extensions into active rev2'] = test => {
|
|
236
236
|
let connection = createMockConnection({ capabilities: [['IMAP4rev2', true]] });
|
|
237
|
-
// RFC 9051 Appendix E folds these into base IMAP4rev2
|
|
238
|
-
for (let capability of [
|
|
237
|
+
// RFC 9051 Appendix E folds these into base IMAP4rev2 - the complete set
|
|
238
|
+
for (let capability of [
|
|
239
|
+
'ENABLE',
|
|
240
|
+
'ESEARCH',
|
|
241
|
+
'IDLE',
|
|
242
|
+
'LIST-EXTENDED',
|
|
243
|
+
'LIST-STATUS',
|
|
244
|
+
'LITERAL-',
|
|
245
|
+
'MOVE',
|
|
246
|
+
'NAMESPACE',
|
|
247
|
+
'SASL-IR',
|
|
248
|
+
'SEARCHRES',
|
|
249
|
+
'SPECIAL-USE',
|
|
250
|
+
'STATUS=SIZE',
|
|
251
|
+
'UIDPLUS',
|
|
252
|
+
'UNSELECT'
|
|
253
|
+
]) {
|
|
239
254
|
test.equal(tools.hasCapability(connection, capability), true, `${capability} should be folded into rev2`);
|
|
240
255
|
}
|
|
241
256
|
// BINARY is intentionally not folded
|
|
@@ -336,6 +351,17 @@ module.exports['Tools: getErrorText with valid response'] = async test => {
|
|
|
336
351
|
test.done();
|
|
337
352
|
};
|
|
338
353
|
|
|
354
|
+
module.exports['Tools: getErrorText survives a response that cannot be re-encoded'] = async test => {
|
|
355
|
+
// The parser tolerates stray bytes inside an OK/NO/BAD atom, and those have no
|
|
356
|
+
// valid IMAP string encoding. The error text is diagnostic, so it must still be
|
|
357
|
+
// produced rather than replacing the server's error with an encoding failure.
|
|
358
|
+
let response = await parser(Buffer.from('A1 NO [SERVERBUG\x00X] it failed', 'binary'));
|
|
359
|
+
let result = await tools.getErrorText(response);
|
|
360
|
+
test.ok(typeof result === 'string', 'error text should still be produced');
|
|
361
|
+
test.ok(result.includes('it failed'), 'the human-readable part must survive');
|
|
362
|
+
test.done();
|
|
363
|
+
};
|
|
364
|
+
|
|
339
365
|
// ============================================
|
|
340
366
|
// getFlagColor tests
|
|
341
367
|
// ============================================
|