imapflow 1.6.3 → 1.6.5
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 +14 -0
- package/lib/commands/compress.js +29 -2
- package/lib/commands/search.js +10 -0
- package/lib/handler/imap-compiler.js +146 -46
- package/lib/handler/imap-parser.js +7 -0
- package/lib/handler/imap-stream.js +25 -5
- package/lib/handler/parser-instance.js +22 -3
- package/lib/imap-flow.d.ts +2 -0
- package/lib/imap-flow.js +119 -36
- package/lib/search-compiler.js +28 -17
- package/lib/tools.js +18 -4
- package/package.json +1 -1
- package/test/commands-integration-test.js +188 -5
- package/test/idle-polling-test.js +81 -0
- package/test/imap-compiler-test.js +168 -0
- package/test/imap-flow-internals-test.js +134 -0
- package/test/imap-flow-secure-test.js +154 -81
- package/test/imap-flow-server-test.js +162 -0
- package/test/imap-parser-test.js +51 -0
- package/test/imap-stream-edge-cases-test.js +72 -0
- package/test/integration/rev2-live-test.js +67 -3
- package/test/search-compiler-test.js +90 -3
- package/test/search-test.js +32 -3
- package/test/tools-test.js +28 -2
package/lib/imap-flow.js
CHANGED
|
@@ -643,22 +643,40 @@ class ImapFlow extends EventEmitter {
|
|
|
643
643
|
}
|
|
644
644
|
|
|
645
645
|
if (typeof options.onSend === 'function') {
|
|
646
|
-
|
|
646
|
+
// The command is already on the wire, so a throwing onSend callback must not
|
|
647
|
+
// reach trySend()'s catch - that would reject the request and dispatch the
|
|
648
|
+
// next command into the server's pending state for this one.
|
|
649
|
+
try {
|
|
650
|
+
options.onSend();
|
|
651
|
+
} catch (err) {
|
|
652
|
+
this.log.warn({ err, cid: this.id });
|
|
653
|
+
}
|
|
647
654
|
}
|
|
648
655
|
}
|
|
649
656
|
|
|
650
657
|
async trySend() {
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
}
|
|
654
|
-
this.currentRequest = this.requestQueue.shift();
|
|
658
|
+
while (!this.currentRequest && this.requestQueue.length) {
|
|
659
|
+
this.currentRequest = this.requestQueue.shift();
|
|
655
660
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
661
|
+
try {
|
|
662
|
+
await this.send({
|
|
663
|
+
tag: this.currentRequest.tag,
|
|
664
|
+
command: this.currentRequest.command,
|
|
665
|
+
attributes: this.currentRequest.attributes,
|
|
666
|
+
options: this.currentRequest.options
|
|
667
|
+
});
|
|
668
|
+
return;
|
|
669
|
+
} catch (err) {
|
|
670
|
+
// A failure here (most likely the compiler refusing an invalid
|
|
671
|
+
// user-supplied value) belongs to the command that was being dispatched.
|
|
672
|
+
// Without this the shifted request would stay currentRequest forever:
|
|
673
|
+
// nothing reached the wire, so no tagged response ever clears it, and
|
|
674
|
+
// every later command would queue behind it until the socket timeout.
|
|
675
|
+
// Reject the failed command and keep draining the queue.
|
|
676
|
+
this.commandParts = [];
|
|
677
|
+
this.rejectCurrentRequest(err);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
662
680
|
}
|
|
663
681
|
|
|
664
682
|
exec(command, attributes, options) {
|
|
@@ -685,10 +703,10 @@ class ImapFlow extends EventEmitter {
|
|
|
685
703
|
let promise = new Promise((resolve, reject) => {
|
|
686
704
|
this.requestTagMap.set(tag, { command, attributes, options, resolve, reject });
|
|
687
705
|
this.requestQueue.push({ tag, command, attributes, options });
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
});
|
|
706
|
+
// trySend() settles dispatch failures itself, by rejecting the affected
|
|
707
|
+
// command through requestTagMap; this catch exists only so a throw from the
|
|
708
|
+
// dispatch machinery itself can never surface as a floating rejection.
|
|
709
|
+
this.trySend().catch(err => this.log.warn({ err, cid: this.id }));
|
|
692
710
|
});
|
|
693
711
|
|
|
694
712
|
// Prevent unhandled promise rejection if close() rejects this request
|
|
@@ -834,6 +852,42 @@ class ImapFlow extends EventEmitter {
|
|
|
834
852
|
}
|
|
835
853
|
}
|
|
836
854
|
|
|
855
|
+
/**
|
|
856
|
+
* Fails the in-flight command when a line that could not be parsed was addressed to its tag.
|
|
857
|
+
* Only the leading tag is read from the raw payload - the rest of the line is by definition
|
|
858
|
+
* not trustworthy - and only the command that is actually on the wire may be settled this way,
|
|
859
|
+
* the same invariant the parsed tagged-response path enforces.
|
|
860
|
+
*
|
|
861
|
+
* @param {Buffer} payload - Raw bytes of the line that failed to parse.
|
|
862
|
+
* @param {Error} parserError - The error the parser raised.
|
|
863
|
+
*/
|
|
864
|
+
rejectUnparsedCompletion(payload, parserError) {
|
|
865
|
+
if (!this.currentRequest || !this.currentRequest.sent) {
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// Prefer the tag the parser had already extracted before it failed - it went
|
|
870
|
+
// through the same leading-NUL workaround as every parsed response. Fall back
|
|
871
|
+
// to the raw bytes for lines whose tag itself was unparseable: skip the NUL
|
|
872
|
+
// padding buggy servers prepend and stop at the first byte a tag cannot contain.
|
|
873
|
+
let tag = parserError && parserError.parsedTag;
|
|
874
|
+
if (!tag) {
|
|
875
|
+
// eslint-disable-next-line no-control-regex
|
|
876
|
+
let match = payload.toString('latin1', 0, 64).match(/^\0*([^\s\x00-\x1f\x7f]+)/);
|
|
877
|
+
tag = match && match[1];
|
|
878
|
+
}
|
|
879
|
+
if (!tag || tag !== this.currentRequest.tag) {
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
let err = new Error('Failed to parse the server response for this command');
|
|
884
|
+
err.code = parserError.code || 'ParserError';
|
|
885
|
+
err.parserError = parserError;
|
|
886
|
+
this.rejectCurrentRequest(err);
|
|
887
|
+
|
|
888
|
+
this.trySend().catch(sendErr => this.log.warn({ err: sendErr, cid: this.id }));
|
|
889
|
+
}
|
|
890
|
+
|
|
837
891
|
/**
|
|
838
892
|
* Handles a single parsed server response: telemetry, continuation requests, response-code
|
|
839
893
|
* section handlers, untagged handlers and tagged command completion.
|
|
@@ -846,26 +900,42 @@ class ImapFlow extends EventEmitter {
|
|
|
846
900
|
|
|
847
901
|
try {
|
|
848
902
|
parsed = await parser(data.payload, { literals: data.literals });
|
|
849
|
-
if (parsed.tag && !['*', '+'].includes(parsed.tag) && parsed.command) {
|
|
850
|
-
let payload = { response: parsed.command };
|
|
851
|
-
|
|
852
|
-
if (
|
|
853
|
-
parsed.attributes &&
|
|
854
|
-
parsed.attributes[0] &&
|
|
855
|
-
parsed.attributes[0].section &&
|
|
856
|
-
parsed.attributes[0].section[0] &&
|
|
857
|
-
parsed.attributes[0].section[0].type === 'ATOM'
|
|
858
|
-
) {
|
|
859
|
-
payload.code = parsed.attributes[0].section[0].value;
|
|
860
|
-
}
|
|
861
|
-
this.emit('response', payload);
|
|
862
|
-
}
|
|
863
903
|
} catch (err) {
|
|
864
904
|
// can not make sense of this
|
|
865
905
|
this.log.error({ src: 's', msg: data.payload.toString(), err, cid: this.id });
|
|
906
|
+
// An unparseable untagged line is junk that can be skipped, but the line may
|
|
907
|
+
// have been the in-flight command's tagged completion. Dropping that one
|
|
908
|
+
// silently strands the command: currentRequest is never cleared, so trySend()
|
|
909
|
+
// stops dispatching and every later command queues behind it until the socket
|
|
910
|
+
// timeout fires. The tag is recovered from the raw bytes (a tag is
|
|
911
|
+
// ASTRING-CHAR only, so it survives whatever made the rest unparseable) and
|
|
912
|
+
// the command is failed with the parser error instead of hanging.
|
|
913
|
+
this.rejectUnparsedCompletion(data.payload, err);
|
|
866
914
|
return true;
|
|
867
915
|
}
|
|
868
916
|
|
|
917
|
+
if (parsed.tag && !['*', '+'].includes(parsed.tag) && parsed.command) {
|
|
918
|
+
let payload = { response: parsed.command };
|
|
919
|
+
|
|
920
|
+
if (
|
|
921
|
+
parsed.attributes &&
|
|
922
|
+
parsed.attributes[0] &&
|
|
923
|
+
parsed.attributes[0].section &&
|
|
924
|
+
parsed.attributes[0].section[0] &&
|
|
925
|
+
parsed.attributes[0].section[0].type === 'ATOM'
|
|
926
|
+
) {
|
|
927
|
+
payload.code = parsed.attributes[0].section[0].value;
|
|
928
|
+
}
|
|
929
|
+
// Outside the parse try/catch on purpose: a throwing user 'response' listener
|
|
930
|
+
// is not a parse failure and must not settle the in-flight command or fail the
|
|
931
|
+
// connection - the same contract untagged handlers get.
|
|
932
|
+
try {
|
|
933
|
+
this.emit('response', payload);
|
|
934
|
+
} catch (err) {
|
|
935
|
+
this.log.warn({ err, cid: this.id });
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
869
939
|
let logCompiled = await compiler(parsed, {
|
|
870
940
|
isLogging: true
|
|
871
941
|
});
|
|
@@ -1629,13 +1699,15 @@ class ImapFlow extends EventEmitter {
|
|
|
1629
1699
|
this.writeSocket = this.socket;
|
|
1630
1700
|
});
|
|
1631
1701
|
|
|
1632
|
-
if (upgraded
|
|
1633
|
-
//
|
|
1634
|
-
//
|
|
1635
|
-
//
|
|
1636
|
-
//
|
|
1637
|
-
|
|
1638
|
-
|
|
1702
|
+
if (upgraded) {
|
|
1703
|
+
// RFC 9051 section 6.2.1: once TLS is started the client MUST discard the
|
|
1704
|
+
// cached capabilities and reissue CAPABILITY, because everything learned
|
|
1705
|
+
// before the handshake was plaintext an active attacker could rewrite.
|
|
1706
|
+
// Unconditional on purpose: a server that stamps [CAPABILITY ...] on the
|
|
1707
|
+
// STARTTLS OK itself clears expectCapabilityUpdate, so keying the discard
|
|
1708
|
+
// on that flag would keep exactly the pre-TLS list an attacker controls -
|
|
1709
|
+
// the list that then picks the AUTH mechanism and answers LOGINDISABLED.
|
|
1710
|
+
this.clearCapabilities();
|
|
1639
1711
|
await this.run('CAPABILITY');
|
|
1640
1712
|
}
|
|
1641
1713
|
|
|
@@ -1780,6 +1852,17 @@ class ImapFlow extends EventEmitter {
|
|
|
1780
1852
|
this.state = this.states.LOGOUT;
|
|
1781
1853
|
}
|
|
1782
1854
|
|
|
1855
|
+
// Drops every capability-derived field together - the counterpart of
|
|
1856
|
+
// updateCapabilitiesFromRaw() below, which sets them together. rawCapabilities is
|
|
1857
|
+
// public surface external consumers read, so a discard (RFC 9051 6.2.1 requires
|
|
1858
|
+
// one after STARTTLS) that missed it would leave the stale list visible if the
|
|
1859
|
+
// re-fetch fails.
|
|
1860
|
+
clearCapabilities() {
|
|
1861
|
+
this.capabilities.clear();
|
|
1862
|
+
this.authCapabilities.clear();
|
|
1863
|
+
this.rawCapabilities = null;
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1783
1866
|
updateCapabilitiesFromRaw(rawCapabilities) {
|
|
1784
1867
|
this.rawCapabilities = rawCapabilities;
|
|
1785
1868
|
this.capabilities = updateCapabilities(rawCapabilities);
|
package/lib/search-compiler.js
CHANGED
|
@@ -30,6 +30,17 @@ let setBoolOpt = (attributes, term, value) => {
|
|
|
30
30
|
attributes.push({ type: 'ATOM', value: term.toUpperCase() });
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Normalizes a user-supplied sequence set (string, number, bigint, or an array of
|
|
35
|
+
* them) into the single string value of a SEQUENCE token. An array is one
|
|
36
|
+
* comma-joined set: separate tokens would be parsed by the server as extra
|
|
37
|
+
* sequence-number search keys ANDed to the query, not as part of the set.
|
|
38
|
+
*
|
|
39
|
+
* @param {*} value - The sequence set value(s)
|
|
40
|
+
* @returns {string} The joined sequence set string
|
|
41
|
+
*/
|
|
42
|
+
let toSequenceValue = value => [].concat(value).join(',');
|
|
43
|
+
|
|
33
44
|
/**
|
|
34
45
|
* Adds a search option with its value(s) to the attributes array.
|
|
35
46
|
* Handles NOT operations and array values.
|
|
@@ -37,23 +48,20 @@ let setBoolOpt = (attributes, term, value) => {
|
|
|
37
48
|
* @param {Array} attributes - Array to append the attribute to
|
|
38
49
|
* @param {string} term - The search term (e.g., 'FROM', 'SUBJECT')
|
|
39
50
|
* @param {*} value - The value for the search term (string, array, or falsy for NOT)
|
|
40
|
-
* @param {string} [type='ATOM'] - The attribute type
|
|
41
51
|
*/
|
|
42
|
-
let setOpt = (attributes, term, value
|
|
43
|
-
type = type || 'ATOM';
|
|
44
|
-
|
|
52
|
+
let setOpt = (attributes, term, value) => {
|
|
45
53
|
// Handle NOT operations for false or null values
|
|
46
54
|
if (value === false || value === null) {
|
|
47
|
-
attributes.push({ type, value: 'NOT' });
|
|
55
|
+
attributes.push({ type: 'ATOM', value: 'NOT' });
|
|
48
56
|
}
|
|
49
57
|
|
|
50
|
-
attributes.push({ type, value: term.toUpperCase() });
|
|
58
|
+
attributes.push({ type: 'ATOM', value: term.toUpperCase() });
|
|
51
59
|
|
|
52
|
-
// Handle array values (e.g
|
|
60
|
+
// Handle array values (e.g. HEADER name/value pairs)
|
|
53
61
|
if (Array.isArray(value)) {
|
|
54
|
-
value.forEach(entry => attributes.push({ type, value: (entry || '').toString() }));
|
|
62
|
+
value.forEach(entry => attributes.push({ type: 'ATOM', value: (entry || '').toString() }));
|
|
55
63
|
} else {
|
|
56
|
-
attributes.push({ type, value: value.toString() });
|
|
64
|
+
attributes.push({ type: 'ATOM', value: value.toString() });
|
|
57
65
|
}
|
|
58
66
|
};
|
|
59
67
|
|
|
@@ -158,12 +166,12 @@ module.exports.searchCompiler = (connection, query) => {
|
|
|
158
166
|
// Custom sequence range support (non-standard)
|
|
159
167
|
case 'SEQ':
|
|
160
168
|
{
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (
|
|
169
|
+
// Passed through as a SEQUENCE token: the compiler validates the
|
|
170
|
+
// set grammar and throws a coded error. An invalid value used to
|
|
171
|
+
// be dropped silently here, which turned a bad filter into an
|
|
172
|
+
// unrestricted search that matched every message.
|
|
173
|
+
let value = params[term] || params[term] === 0 ? toSequenceValue(params[term]) : '';
|
|
174
|
+
if (value) {
|
|
167
175
|
attributes.push({ type: 'SEQUENCE', value });
|
|
168
176
|
}
|
|
169
177
|
}
|
|
@@ -233,10 +241,13 @@ module.exports.searchCompiler = (connection, query) => {
|
|
|
233
241
|
}
|
|
234
242
|
break;
|
|
235
243
|
|
|
236
|
-
// UID sequences
|
|
244
|
+
// UID sequences. The key stays an ATOM and only the value is a
|
|
245
|
+
// SEQUENCE token, so the compiler validates the sequence set
|
|
246
|
+
// itself rather than the "UID" keyword in front of it.
|
|
237
247
|
case 'UID':
|
|
238
248
|
if (params[term]) {
|
|
239
|
-
|
|
249
|
+
attributes.push({ type: 'ATOM', value: 'UID' });
|
|
250
|
+
attributes.push({ type: 'SEQUENCE', value: toSequenceValue(params[term]) });
|
|
240
251
|
}
|
|
241
252
|
break;
|
|
242
253
|
|
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({
|
|
@@ -2819,6 +2910,40 @@ module.exports['Commands: compress handles error'] = async test => {
|
|
|
2819
2910
|
test.done();
|
|
2820
2911
|
};
|
|
2821
2912
|
|
|
2913
|
+
module.exports['Commands: compress fails the connection on trailing data'] = async test => {
|
|
2914
|
+
// Per RFC 4978 the server switches to DEFLATE at its tagged OK, so data already
|
|
2915
|
+
// buffered behind the OK was consumed as cleartext and the deflate stream is
|
|
2916
|
+
// truncated. Declining the upgrade is not a protocol option at that point - the
|
|
2917
|
+
// session is unrecoverable in both directions and must fail closed.
|
|
2918
|
+
let closeAfterCalled = false;
|
|
2919
|
+
let nextCalled = false;
|
|
2920
|
+
const connection = createMockConnection({
|
|
2921
|
+
capabilities: new Map([['COMPRESS=DEFLATE', true]]),
|
|
2922
|
+
closeAfter: () => {
|
|
2923
|
+
closeAfterCalled = true;
|
|
2924
|
+
},
|
|
2925
|
+
exec: async () => ({
|
|
2926
|
+
hasTrailingData: true,
|
|
2927
|
+
next: () => {
|
|
2928
|
+
nextCalled = true;
|
|
2929
|
+
test.ok(closeAfterCalled, 'teardown must be scheduled before parser backpressure is released');
|
|
2930
|
+
}
|
|
2931
|
+
})
|
|
2932
|
+
});
|
|
2933
|
+
|
|
2934
|
+
let err = null;
|
|
2935
|
+
try {
|
|
2936
|
+
await compressCommand(connection);
|
|
2937
|
+
} catch (e) {
|
|
2938
|
+
err = e;
|
|
2939
|
+
}
|
|
2940
|
+
test.ok(err, 'compress must throw');
|
|
2941
|
+
test.equal(err && err.code, 'COMPRESS_TRAILING_DATA');
|
|
2942
|
+
test.ok(closeAfterCalled, 'the connection must be closed');
|
|
2943
|
+
test.ok(nextCalled, 'parser backpressure must still be released');
|
|
2944
|
+
test.done();
|
|
2945
|
+
};
|
|
2946
|
+
|
|
2822
2947
|
// ============================================
|
|
2823
2948
|
// STARTTLS Command Tests
|
|
2824
2949
|
// ============================================
|
|
@@ -3804,6 +3929,52 @@ module.exports['Commands: list runs separate INBOX query when using namespace']
|
|
|
3804
3929
|
test.done();
|
|
3805
3930
|
};
|
|
3806
3931
|
|
|
3932
|
+
module.exports['Commands: list INBOX fixup propagates non-reducible failures'] = async test => {
|
|
3933
|
+
// Both sides of the fixup's retry guard: a NO on the extended call is an operational
|
|
3934
|
+
// failure (not a RETURN options rejection), and a BAD on a call that already ran plain
|
|
3935
|
+
// has no options left to reduce - neither may trigger the plain retry
|
|
3936
|
+
for (let { capabilities, status } of [
|
|
3937
|
+
{
|
|
3938
|
+
capabilities: [
|
|
3939
|
+
['IMAP4rev1', true],
|
|
3940
|
+
['LIST-EXTENDED', true]
|
|
3941
|
+
],
|
|
3942
|
+
status: 'NO'
|
|
3943
|
+
},
|
|
3944
|
+
{ capabilities: [['IMAP4rev1', true]], status: 'BAD' }
|
|
3945
|
+
]) {
|
|
3946
|
+
let listCalls = 0;
|
|
3947
|
+
let lsubCalls = 0;
|
|
3948
|
+
const connection = createMockConnection({
|
|
3949
|
+
state: 3,
|
|
3950
|
+
capabilities: new Map(capabilities),
|
|
3951
|
+
exec: async (cmd, attrs) => {
|
|
3952
|
+
if (cmd === 'LSUB') {
|
|
3953
|
+
lsubCalls++;
|
|
3954
|
+
}
|
|
3955
|
+
if (cmd === 'LIST') {
|
|
3956
|
+
listCalls++;
|
|
3957
|
+
if (attrs[1] === 'INBOX') {
|
|
3958
|
+
throw commandError('Command failed', status);
|
|
3959
|
+
}
|
|
3960
|
+
}
|
|
3961
|
+
return { next: () => {} };
|
|
3962
|
+
}
|
|
3963
|
+
});
|
|
3964
|
+
|
|
3965
|
+
try {
|
|
3966
|
+
await listCommand(connection, 'Mail/', '*');
|
|
3967
|
+
test.ok(false, 'Should have thrown');
|
|
3968
|
+
} catch (err) {
|
|
3969
|
+
test.equal(err.responseStatus, status);
|
|
3970
|
+
}
|
|
3971
|
+
// Main listing and the failed INBOX call - no plain retry, no LSUB merge
|
|
3972
|
+
test.equal(listCalls, 2);
|
|
3973
|
+
test.equal(lsubCalls, 0);
|
|
3974
|
+
}
|
|
3975
|
+
test.done();
|
|
3976
|
+
};
|
|
3977
|
+
|
|
3807
3978
|
module.exports['Commands: list handles LSUB merging'] = async test => {
|
|
3808
3979
|
const connection = createMockConnection({
|
|
3809
3980
|
state: 3,
|
|
@@ -4760,18 +4931,28 @@ module.exports['Commands: list discards partial results from a rejected INBOX fi
|
|
|
4760
4931
|
state: 3,
|
|
4761
4932
|
capabilities: new Map([
|
|
4762
4933
|
['IMAP4rev1', true],
|
|
4763
|
-
['LIST-EXTENDED', true]
|
|
4934
|
+
['LIST-EXTENDED', true],
|
|
4935
|
+
['SPECIAL-USE', true]
|
|
4764
4936
|
]),
|
|
4765
4937
|
exec: async (cmd, attrs, opts) => {
|
|
4766
4938
|
if (cmd === 'LIST') {
|
|
4767
4939
|
listAttempts.push(JSON.stringify(attrs));
|
|
4768
4940
|
if (listAttempts.length === 1) {
|
|
4769
4941
|
await opts.untagged.LIST({
|
|
4770
|
-
attributes: [
|
|
4942
|
+
attributes: [
|
|
4943
|
+
[{ value: '\\Subscribed' }, { value: '\\HasNoChildren' }, { value: '\\Sent' }],
|
|
4944
|
+
{ value: '.' },
|
|
4945
|
+
{ value: 'Prefix.Folder1' }
|
|
4946
|
+
]
|
|
4771
4947
|
});
|
|
4772
4948
|
} else if (listAttempts.length === 2) {
|
|
4773
|
-
// The fixup attempt streams
|
|
4774
|
-
//
|
|
4949
|
+
// The fixup attempt streams untagged lines and THEN gets the tagged
|
|
4950
|
+
// BAD - the partial lines must not survive the retry. "Aliased" also
|
|
4951
|
+
// claims \Sent and sorts ahead of the main run's Prefix.Folder1, so
|
|
4952
|
+
// it would steal the special-use slot if it were not rolled back
|
|
4953
|
+
await opts.untagged.LIST({
|
|
4954
|
+
attributes: [[{ value: '\\Sent' }], { value: '.' }, { value: 'Aliased' }]
|
|
4955
|
+
});
|
|
4775
4956
|
await opts.untagged.LIST({
|
|
4776
4957
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '.' }, { value: 'INBOX' }]
|
|
4777
4958
|
});
|
|
@@ -4790,7 +4971,9 @@ module.exports['Commands: list discards partial results from a rejected INBOX fi
|
|
|
4790
4971
|
test.equal(listAttempts.length, 3);
|
|
4791
4972
|
// Exactly one INBOX entry - the rejected attempt's partial line was discarded
|
|
4792
4973
|
test.equal(result.filter(e => e.path === 'INBOX').length, 1);
|
|
4793
|
-
test.ok(result.
|
|
4974
|
+
test.ok(!result.some(e => e.path === 'Aliased'), 'the rejected attempt entry is gone');
|
|
4975
|
+
// The main run's special-use match survived the rollback of the rejected attempt
|
|
4976
|
+
test.equal(result.find(e => e.path === 'Prefix.Folder1').specialUse, '\\Sent');
|
|
4794
4977
|
test.done();
|
|
4795
4978
|
};
|
|
4796
4979
|
|
|
@@ -347,3 +347,84 @@ module.exports['Polling: no polling session without a saved select command'] = a
|
|
|
347
347
|
test.equal(connection.idling, false, 'idling untouched');
|
|
348
348
|
test.done();
|
|
349
349
|
};
|
|
350
|
+
|
|
351
|
+
module.exports['Polling: a falsy STATUS result stops the loop as PollFailed'] = async test => {
|
|
352
|
+
await withFakeTimers(async timers => {
|
|
353
|
+
let warnings = [];
|
|
354
|
+
let connection = createConnection({
|
|
355
|
+
missingIdleCommand: 'STATUS',
|
|
356
|
+
// Only STATUS is ever polled; a failure without a NO status makes the
|
|
357
|
+
// real STATUS implementation swallow the error and return false
|
|
358
|
+
respond: async () => {
|
|
359
|
+
throw new Error('Command failed');
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
connection.log.warn = entry => warnings.push(entry);
|
|
363
|
+
|
|
364
|
+
let idlePromise = idleCommand(connection, 60000);
|
|
365
|
+
await timers.drain();
|
|
366
|
+
await idlePromise;
|
|
367
|
+
|
|
368
|
+
test.ok(
|
|
369
|
+
warnings.some(entry => entry && entry.err && entry.err.code === 'PollFailed'),
|
|
370
|
+
'the falsy STATUS result surfaced as a PollFailed error'
|
|
371
|
+
);
|
|
372
|
+
test.equal(connection.idling, false, 'idling reset after the failed poll');
|
|
373
|
+
test.equal(connection.preCheck, false, 'preCheck released');
|
|
374
|
+
test.equal(timers.count(), 0, 'polling stopped');
|
|
375
|
+
test.done();
|
|
376
|
+
});
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
module.exports['Polling: a repeated break call is a no-op'] = async test => {
|
|
380
|
+
await withFakeTimers(async timers => {
|
|
381
|
+
let connection = createConnection();
|
|
382
|
+
|
|
383
|
+
let idlePromise = idleCommand(connection, 60000);
|
|
384
|
+
await timers.drain();
|
|
385
|
+
test.deepEqual(connection.commands, ['NOOP'], 'the immediate first poll ran');
|
|
386
|
+
|
|
387
|
+
// A caller may hold on to the break function and invoke it more than once
|
|
388
|
+
let preCheck = connection.preCheck;
|
|
389
|
+
await preCheck();
|
|
390
|
+
await preCheck();
|
|
391
|
+
await idlePromise;
|
|
392
|
+
|
|
393
|
+
test.equal(connection.idling, false, 'idling reset');
|
|
394
|
+
test.equal(connection.preCheck, false, 'preCheck released');
|
|
395
|
+
test.equal(timers.count(), 0, 'no timer left armed');
|
|
396
|
+
|
|
397
|
+
await timers.fire();
|
|
398
|
+
test.deepEqual(connection.commands, ['NOOP'], 'no further poll after the duplicate break');
|
|
399
|
+
test.done();
|
|
400
|
+
});
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
module.exports['Polling: a break in the initiation tick prevents the first poll'] = async test => {
|
|
404
|
+
await withFakeTimers(async timers => {
|
|
405
|
+
let connection = createConnection();
|
|
406
|
+
// Simulate a break request (e.g. a command being queued) landing in the same
|
|
407
|
+
// tick the loop is initiated: trap the loop installing its own preCheck and
|
|
408
|
+
// break through it immediately, before the first poll has started
|
|
409
|
+
let installedPreCheck = connection.preCheck;
|
|
410
|
+
Object.defineProperty(connection, 'preCheck', {
|
|
411
|
+
get: () => installedPreCheck,
|
|
412
|
+
set: value => {
|
|
413
|
+
installedPreCheck = value;
|
|
414
|
+
if (typeof value === 'function') {
|
|
415
|
+
value();
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
let idlePromise = idleCommand(connection, 60000);
|
|
421
|
+
await timers.drain();
|
|
422
|
+
await idlePromise;
|
|
423
|
+
|
|
424
|
+
test.deepEqual(connection.commands, [], 'the already-cancelled session never polled');
|
|
425
|
+
test.equal(connection.idling, false, 'idling reset');
|
|
426
|
+
test.equal(connection.preCheck, false, 'preCheck released');
|
|
427
|
+
test.equal(timers.count(), 0, 'no timer left armed');
|
|
428
|
+
test.done();
|
|
429
|
+
});
|
|
430
|
+
};
|