imapflow 1.4.9 → 1.6.0
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/.github/workflows/test.yml +20 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +22 -0
- package/CLAUDE.md +3 -5
- package/lib/commands/fetch.js +18 -14
- package/lib/commands/idle.js +197 -104
- package/lib/commands/list.js +19 -8
- package/lib/commands/quota.js +3 -0
- package/lib/commands/select.js +5 -0
- package/lib/commands/status.js +10 -1
- package/lib/connection-deadline.js +98 -0
- package/lib/handler/imap-compiler.js +20 -14
- package/lib/handler/imap-stream.js +141 -50
- package/lib/handler/limits.js +43 -0
- package/lib/handler/token-parser.js +38 -1
- package/lib/imap-flow.d.ts +47 -5
- package/lib/imap-flow.js +594 -283
- package/lib/proxy-connection.js +393 -98
- package/lib/special-use.js +660 -51
- package/lib/tools.js +52 -3
- package/package.json +2 -2
- package/test/commands-branches-test.js +17 -1
- package/test/commands-integration-test.js +353 -2
- package/test/connection-edge-cases-test.js +4 -40
- package/test/fixtures/fake-timers.js +115 -0
- package/test/handler-branches-test.js +4 -28
- package/test/idle-polling-test.js +349 -0
- package/test/imap-compiler-test.js +85 -0
- package/test/imap-flow-compress-test.js +12 -0
- package/test/imap-flow-coverage-test.js +3 -3
- package/test/imap-flow-fetch-download-test.js +56 -0
- package/test/imap-flow-internals-test.js +23 -0
- package/test/imap-flow-proxy-paths-test.js +151 -0
- package/test/imap-flow-secure-test.js +182 -9
- package/test/imap-flow-server-test.js +229 -0
- package/test/imap-parser-test.js +112 -1
- package/test/imap-stream-test.js +46 -0
- package/test/integration/README.md +17 -5
- package/test/integration/rev2-live-test.js +125 -0
- package/test/integration/run-rev2-tests.sh +14 -0
- package/test/parser-limits-test.js +274 -0
- package/test/proxy-connection-test.js +553 -442
- package/test/reliability-improvements-test.js +87 -0
- package/test/search-compiler-test.js +17 -0
- package/test/special-use-test.js +337 -0
- package/test/tag-correlation-test.js +333 -0
- package/test/timer-policy-test.js +214 -0
- package/test/tools-test.js +42 -4
package/lib/tools.js
CHANGED
|
@@ -19,9 +19,10 @@ const EXPANDED_RANGE_LIMIT = 0x1000000;
|
|
|
19
19
|
// Extensions that RFC 9051 (IMAP4rev2) folds into the base protocol (Appendix E).
|
|
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
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
22
|
+
// which fetch.js handles with its own isRev2Active check, while the APPEND side
|
|
23
|
+
// stays gated on the BINARY token. The set mirrors the Appendix E list in full,
|
|
24
|
+
// including entries no call site consults yet, so any future capability check
|
|
25
|
+
// gets the rev2 folding for free.
|
|
25
26
|
const IMAP4REV2_FOLDED_CAPABILITIES = new Set([
|
|
26
27
|
'ENABLE',
|
|
27
28
|
'ESEARCH',
|
|
@@ -47,6 +48,23 @@ class AuthenticationFailure extends Error {
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
const tools = {
|
|
51
|
+
/**
|
|
52
|
+
* Detaches a background timer from the event loop, so it cannot keep the process alive on its
|
|
53
|
+
* own. Applied to every background timer (auto-IDLE, IDLE restart, fallback polling, throttle
|
|
54
|
+
* back-off, held-lock diagnostics); connection and greeting deadlines are deliberately left
|
|
55
|
+
* attached, because a caller is waiting for connect() to settle.
|
|
56
|
+
*
|
|
57
|
+
* @param {Object} timer - Timer handle returned by setTimeout
|
|
58
|
+
* @returns {Object} The same timer handle
|
|
59
|
+
*/
|
|
60
|
+
unrefTimer(timer) {
|
|
61
|
+
/* c8 ignore next 3 */ // node timers always expose unref(); the guard covers replaced globals in tests
|
|
62
|
+
if (timer && typeof timer.unref === 'function') {
|
|
63
|
+
timer.unref();
|
|
64
|
+
}
|
|
65
|
+
return timer;
|
|
66
|
+
},
|
|
67
|
+
|
|
50
68
|
/**
|
|
51
69
|
* Checks whether IMAP4rev2 semantics are active for the connection: either the
|
|
52
70
|
* client enabled IMAP4rev2 explicitly, or the server is rev2-only (advertises
|
|
@@ -115,6 +133,24 @@ const tools = {
|
|
|
115
133
|
attributes.push({ type: 'ATOM', value: key.toUpperCase() });
|
|
116
134
|
}
|
|
117
135
|
break;
|
|
136
|
+
|
|
137
|
+
case 'SIZE':
|
|
138
|
+
// STATUS SIZE requires the STATUS=SIZE extension (RFC 8438), which
|
|
139
|
+
// RFC 9051 folds into base IMAP4rev2
|
|
140
|
+
if (tools.hasCapability(connection, 'STATUS=SIZE')) {
|
|
141
|
+
attributes.push({ type: 'ATOM', value: key.toUpperCase() });
|
|
142
|
+
}
|
|
143
|
+
break;
|
|
144
|
+
|
|
145
|
+
case 'DELETED':
|
|
146
|
+
// STATUS DELETED is a base IMAP4rev2 addition (RFC 9051 Appendix E
|
|
147
|
+
// item 3) with no standalone capability - requesting it from a plain
|
|
148
|
+
// rev1 server would get the whole STATUS request rejected. RFC 9208
|
|
149
|
+
// additionally makes it mandatory when QUOTA=RES-MESSAGE is advertised.
|
|
150
|
+
if (tools.isRev2Active(connection) || connection.capabilities.has('QUOTA=RES-MESSAGE')) {
|
|
151
|
+
attributes.push({ type: 'ATOM', value: key.toUpperCase() });
|
|
152
|
+
}
|
|
153
|
+
break;
|
|
118
154
|
}
|
|
119
155
|
});
|
|
120
156
|
|
|
@@ -591,6 +627,19 @@ const tools = {
|
|
|
591
627
|
map.bodyParts = new Map();
|
|
592
628
|
}
|
|
593
629
|
map.bodyParts.set(partKey, value);
|
|
630
|
+
|
|
631
|
+
if (match[1].toLowerCase() === 'binary') {
|
|
632
|
+
// The part arrived via FETCH BINARY (RFC 3516, FETCH side folded
|
|
633
|
+
// into IMAP4rev2), so the server has already removed the
|
|
634
|
+
// content-transfer-encoding - consumers must not decode it again.
|
|
635
|
+
// Recorded from the actual response, not predicted from the
|
|
636
|
+
// request, so it stays correct even if a server answers a BINARY
|
|
637
|
+
// request with a BODY response or vice versa.
|
|
638
|
+
if (!map.binaryParts) {
|
|
639
|
+
map.binaryParts = new Set();
|
|
640
|
+
}
|
|
641
|
+
map.binaryParts.add(partKey);
|
|
642
|
+
}
|
|
594
643
|
break;
|
|
595
644
|
}
|
|
596
645
|
break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "imapflow",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "IMAP Client for Node",
|
|
5
5
|
"main": "lib/imap-flow.js",
|
|
6
6
|
"types": "lib/imap-flow.d.ts",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"test:rev2": "bash test/integration/run-rev2-tests.sh",
|
|
11
11
|
"update": "rm -rf node_modules package-lock.json && ncu -u && npm install",
|
|
12
12
|
"format": "prettier --write \"**/*.{js,json,md,yml,yaml}\" --ignore-path .prettierignore",
|
|
13
|
+
"format:check": "prettier --check \"**/*.{js,json,md,yml,yaml}\" --ignore-path .prettierignore",
|
|
13
14
|
"lint": "eslint ."
|
|
14
15
|
},
|
|
15
16
|
"repository": {
|
|
@@ -49,7 +50,6 @@
|
|
|
49
50
|
"libbase64": "1.3.0",
|
|
50
51
|
"libmime": "5.4.1",
|
|
51
52
|
"libqp": "2.1.1",
|
|
52
|
-
"nodemailer": "9.0.3",
|
|
53
53
|
"pino": "10.3.1",
|
|
54
54
|
"socks": "2.8.9"
|
|
55
55
|
}
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
// The mock connection factory mirrors the one in commands-integration-test.js.
|
|
11
11
|
// ============================================================================
|
|
12
12
|
|
|
13
|
+
const imapCommands = require('../lib/imap-commands.js');
|
|
14
|
+
|
|
13
15
|
const createMockConnection = (overrides = {}) => {
|
|
14
16
|
const states = {
|
|
15
17
|
NOT_AUTHENTICATED: 1,
|
|
@@ -29,7 +31,7 @@ const createMockConnection = (overrides = {}) => {
|
|
|
29
31
|
noModseq: false
|
|
30
32
|
};
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
const connection = {
|
|
33
35
|
states,
|
|
34
36
|
state: overrides.state || states.SELECTED,
|
|
35
37
|
id: 'test-connection-id',
|
|
@@ -51,10 +53,22 @@ const createMockConnection = (overrides = {}) => {
|
|
|
51
53
|
emit: overrides.emit || (() => {}),
|
|
52
54
|
write: overrides.write || (() => {}),
|
|
53
55
|
currentSelectCommand: false,
|
|
56
|
+
// A live transport: command implementations that guard against polling or writing on a
|
|
57
|
+
// dead connection (idle.js) need this to look established.
|
|
58
|
+
socket: overrides.socket || { destroyed: false },
|
|
54
59
|
messageFlagsAdd: overrides.messageFlagsAdd || (async () => {}),
|
|
55
60
|
messageCopy: overrides.messageCopy || (async () => {}),
|
|
56
61
|
messageDelete: overrides.messageDelete || (async () => {}),
|
|
57
62
|
run: overrides.run || (async () => {}),
|
|
63
|
+
// Mirrors ImapFlow.runInternal(): dispatch through the command registry without the
|
|
64
|
+
// preCheck/auto-IDLE handshake that run() performs, so a fallback poll runs the real
|
|
65
|
+
// SELECT/STATUS implementation.
|
|
66
|
+
runInternal:
|
|
67
|
+
overrides.runInternal ||
|
|
68
|
+
(async (command, ...args) => {
|
|
69
|
+
let handler = imapCommands.get(command.toUpperCase());
|
|
70
|
+
return handler ? await handler(connection, ...args) : false;
|
|
71
|
+
}),
|
|
58
72
|
exec:
|
|
59
73
|
overrides.exec ||
|
|
60
74
|
(async () => ({
|
|
@@ -63,6 +77,8 @@ const createMockConnection = (overrides = {}) => {
|
|
|
63
77
|
})),
|
|
64
78
|
...overrides
|
|
65
79
|
};
|
|
80
|
+
|
|
81
|
+
return connection;
|
|
66
82
|
};
|
|
67
83
|
|
|
68
84
|
// ============================================================================
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// Mock Connection Factory
|
|
8
8
|
// ============================================
|
|
9
9
|
|
|
10
|
+
const imapCommands = require('../lib/imap-commands.js');
|
|
11
|
+
|
|
10
12
|
const createMockConnection = (overrides = {}) => {
|
|
11
13
|
const states = {
|
|
12
14
|
NOT_AUTHENTICATED: 1,
|
|
@@ -26,7 +28,7 @@ const createMockConnection = (overrides = {}) => {
|
|
|
26
28
|
noModseq: false
|
|
27
29
|
};
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
const connection = {
|
|
30
32
|
states,
|
|
31
33
|
state: overrides.state || states.SELECTED,
|
|
32
34
|
id: 'test-connection-id',
|
|
@@ -48,6 +50,9 @@ const createMockConnection = (overrides = {}) => {
|
|
|
48
50
|
},
|
|
49
51
|
close: overrides.close || (() => {}),
|
|
50
52
|
emit: overrides.emit || (() => {}),
|
|
53
|
+
// A live transport: command implementations that guard against polling or writing on a
|
|
54
|
+
// dead connection (idle.js) need this to look established.
|
|
55
|
+
socket: overrides.socket || { destroyed: false },
|
|
51
56
|
currentSelectCommand: false,
|
|
52
57
|
skipListSubscribedArg: false,
|
|
53
58
|
skipListStatusArgs: false,
|
|
@@ -55,6 +60,15 @@ const createMockConnection = (overrides = {}) => {
|
|
|
55
60
|
skipLsub: false,
|
|
56
61
|
messageFlagsAdd: overrides.messageFlagsAdd || (async () => {}),
|
|
57
62
|
run: overrides.run || (async () => {}),
|
|
63
|
+
// Mirrors ImapFlow.runInternal(): dispatch through the command registry without the
|
|
64
|
+
// preCheck/auto-IDLE handshake that run() performs, so a fallback poll runs the real
|
|
65
|
+
// SELECT/STATUS implementation.
|
|
66
|
+
runInternal:
|
|
67
|
+
overrides.runInternal ||
|
|
68
|
+
(async (command, ...args) => {
|
|
69
|
+
let handler = imapCommands.get(command.toUpperCase());
|
|
70
|
+
return handler ? await handler(connection, ...args) : false;
|
|
71
|
+
}),
|
|
58
72
|
exec:
|
|
59
73
|
overrides.exec ||
|
|
60
74
|
(async () => ({
|
|
@@ -63,6 +77,8 @@ const createMockConnection = (overrides = {}) => {
|
|
|
63
77
|
})),
|
|
64
78
|
...overrides
|
|
65
79
|
};
|
|
80
|
+
|
|
81
|
+
return connection;
|
|
66
82
|
};
|
|
67
83
|
|
|
68
84
|
// Decodes the base64 SASL payload that authenticate() hands to exec().
|
|
@@ -488,6 +504,33 @@ module.exports['Commands: search collects results from an ESEARCH reply to plain
|
|
|
488
504
|
test.done();
|
|
489
505
|
};
|
|
490
506
|
|
|
507
|
+
module.exports['Commands: search returns empty array for an ESEARCH reply without ALL'] = async test => {
|
|
508
|
+
const connection = createMockConnection({
|
|
509
|
+
state: 3,
|
|
510
|
+
capabilities: new Map([['IMAP4rev2', true]]),
|
|
511
|
+
exec: async (cmd, attrs, opts) => {
|
|
512
|
+
// RFC 9051: an ESEARCH response with no matches omits the ALL item
|
|
513
|
+
if (opts && opts.untagged && opts.untagged.ESEARCH) {
|
|
514
|
+
await opts.untagged.ESEARCH({
|
|
515
|
+
attributes: [
|
|
516
|
+
[
|
|
517
|
+
{ type: 'ATOM', value: 'TAG' },
|
|
518
|
+
{ type: 'STRING', value: 'A282' }
|
|
519
|
+
],
|
|
520
|
+
{ type: 'ATOM', value: 'COUNT' },
|
|
521
|
+
{ type: 'ATOM', value: '0' }
|
|
522
|
+
]
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
return { next: () => {} };
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
const result = await searchCommand(connection, true, {});
|
|
530
|
+
test.deepEqual(result, []);
|
|
531
|
+
test.done();
|
|
532
|
+
};
|
|
533
|
+
|
|
491
534
|
module.exports['Commands: search caps a hostile ESEARCH ALL range at the mailbox size'] = async test => {
|
|
492
535
|
const connection = createMockConnection({
|
|
493
536
|
state: 3,
|
|
@@ -693,6 +736,26 @@ module.exports['Commands: store add flags'] = async test => {
|
|
|
693
736
|
test.done();
|
|
694
737
|
};
|
|
695
738
|
|
|
739
|
+
module.exports['Commands: store drops the Recent flag from the wire'] = async test => {
|
|
740
|
+
let execArgs = null;
|
|
741
|
+
const connection = createMockConnection({
|
|
742
|
+
state: 3,
|
|
743
|
+
exec: async (cmd, attrs) => {
|
|
744
|
+
execArgs = { cmd, attrs };
|
|
745
|
+
return { next: () => {} };
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
// \Recent is owned by the server (and removed entirely in IMAP4rev2) - a
|
|
750
|
+
// client-side STORE must never try to set it
|
|
751
|
+
const result = await storeCommand(connection, '1:10', ['\\Seen', '\\Recent'], { operation: 'add' });
|
|
752
|
+
test.equal(result, true);
|
|
753
|
+
const attrsStr = JSON.stringify(execArgs.attrs);
|
|
754
|
+
test.ok(attrsStr.includes('\\\\Seen'));
|
|
755
|
+
test.ok(!attrsStr.toLowerCase().includes('recent'));
|
|
756
|
+
test.done();
|
|
757
|
+
};
|
|
758
|
+
|
|
696
759
|
module.exports['Commands: store remove flags'] = async test => {
|
|
697
760
|
let execArgs = null;
|
|
698
761
|
const connection = createMockConnection({
|
|
@@ -2975,6 +3038,97 @@ module.exports['Commands: fetch with BINARY capability'] = async test => {
|
|
|
2975
3038
|
test.done();
|
|
2976
3039
|
};
|
|
2977
3040
|
|
|
3041
|
+
module.exports['Commands: fetch with binary uses BINARY on rev2-only servers without the token'] = async test => {
|
|
3042
|
+
let queryAttrs = null;
|
|
3043
|
+
const connection = createMockConnection({
|
|
3044
|
+
state: 3,
|
|
3045
|
+
// rev2-only server: no BINARY token, but RFC 9051 folds the FETCH side of
|
|
3046
|
+
// the BINARY extension into base IMAP4rev2
|
|
3047
|
+
capabilities: new Map([['IMAP4rev2', true]]),
|
|
3048
|
+
exec: async (cmd, attrs) => {
|
|
3049
|
+
queryAttrs = attrs;
|
|
3050
|
+
return { next: () => {} };
|
|
3051
|
+
}
|
|
3052
|
+
});
|
|
3053
|
+
|
|
3054
|
+
await fetchCommand(connection, '1', { source: true }, { binary: true });
|
|
3055
|
+
test.ok(queryAttrs);
|
|
3056
|
+
test.ok(JSON.stringify(queryAttrs).includes('BINARY.PEEK'));
|
|
3057
|
+
test.done();
|
|
3058
|
+
};
|
|
3059
|
+
|
|
3060
|
+
module.exports['Commands: fetch with binary keeps BODY for non-numeric sections'] = async test => {
|
|
3061
|
+
let queryAttrs = null;
|
|
3062
|
+
const connection = createMockConnection({
|
|
3063
|
+
state: 3,
|
|
3064
|
+
capabilities: new Map([['BINARY', true]]),
|
|
3065
|
+
exec: async (cmd, attrs) => {
|
|
3066
|
+
queryAttrs = attrs;
|
|
3067
|
+
return { next: () => {} };
|
|
3068
|
+
}
|
|
3069
|
+
});
|
|
3070
|
+
|
|
3071
|
+
// RFC 3516/RFC 9051: section-binary only allows numeric part specifiers -
|
|
3072
|
+
// BINARY[HEADER], BINARY[TEXT] and BINARY[n.MIME] are invalid syntax that
|
|
3073
|
+
// servers reject, so those sections must stay BODY fetches even with
|
|
3074
|
+
// options.binary set
|
|
3075
|
+
await fetchCommand(connection, '1', { headers: true, bodyParts: ['TEXT', '1.MIME', '1.2'] }, { binary: true });
|
|
3076
|
+
test.ok(queryAttrs);
|
|
3077
|
+
const sections = [];
|
|
3078
|
+
const walk = list => {
|
|
3079
|
+
for (let entry of Array.isArray(list) ? list : [list]) {
|
|
3080
|
+
if (Array.isArray(entry)) {
|
|
3081
|
+
walk(entry);
|
|
3082
|
+
} else if (entry && entry.section) {
|
|
3083
|
+
sections.push({ value: entry.value, section: entry.section.length ? entry.section[0].value : '' });
|
|
3084
|
+
}
|
|
3085
|
+
}
|
|
3086
|
+
};
|
|
3087
|
+
walk(queryAttrs);
|
|
3088
|
+
|
|
3089
|
+
for (let entry of sections) {
|
|
3090
|
+
if (['HEADER', 'TEXT', '1.MIME'].includes(entry.section)) {
|
|
3091
|
+
test.equal(entry.value, 'BODY.PEEK', `${entry.section} must be fetched via BODY.PEEK`);
|
|
3092
|
+
}
|
|
3093
|
+
if (entry.section === '1.2') {
|
|
3094
|
+
test.equal(entry.value, 'BINARY.PEEK', 'numeric part specifiers may use BINARY.PEEK');
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
test.ok(
|
|
3098
|
+
sections.some(entry => entry.section === '1.2'),
|
|
3099
|
+
'numeric body part present'
|
|
3100
|
+
);
|
|
3101
|
+
test.ok(
|
|
3102
|
+
sections.some(entry => entry.section === 'TEXT'),
|
|
3103
|
+
'TEXT body part present'
|
|
3104
|
+
);
|
|
3105
|
+
test.done();
|
|
3106
|
+
};
|
|
3107
|
+
|
|
3108
|
+
module.exports['Commands: fetch with binary keeps BODY on unenabled dual rev1+rev2 servers'] = async test => {
|
|
3109
|
+
let queryAttrs = null;
|
|
3110
|
+
const connection = createMockConnection({
|
|
3111
|
+
state: 3,
|
|
3112
|
+
// dual server without ENABLE IMAP4rev2 - rev2 semantics are not active, so
|
|
3113
|
+
// the BINARY fold must not apply
|
|
3114
|
+
capabilities: new Map([
|
|
3115
|
+
['IMAP4rev1', true],
|
|
3116
|
+
['IMAP4rev2', true]
|
|
3117
|
+
]),
|
|
3118
|
+
exec: async (cmd, attrs) => {
|
|
3119
|
+
queryAttrs = attrs;
|
|
3120
|
+
return { next: () => {} };
|
|
3121
|
+
}
|
|
3122
|
+
});
|
|
3123
|
+
|
|
3124
|
+
await fetchCommand(connection, '1', { source: true }, { binary: true });
|
|
3125
|
+
test.ok(queryAttrs);
|
|
3126
|
+
const queryStr = JSON.stringify(queryAttrs);
|
|
3127
|
+
test.ok(queryStr.includes('BODY.PEEK'));
|
|
3128
|
+
test.ok(!queryStr.includes('BINARY.PEEK'));
|
|
3129
|
+
test.done();
|
|
3130
|
+
};
|
|
3131
|
+
|
|
2978
3132
|
module.exports['Commands: fetch with OBJECTID capability'] = async test => {
|
|
2979
3133
|
let queryAttrs = null;
|
|
2980
3134
|
const connection = createMockConnection({
|
|
@@ -3453,6 +3607,71 @@ module.exports['Commands: list with statusQuery'] = async test => {
|
|
|
3453
3607
|
test.done();
|
|
3454
3608
|
};
|
|
3455
3609
|
|
|
3610
|
+
module.exports['Commands: list statusQuery parses inline SIZE and DELETED on rev2 sessions'] = async test => {
|
|
3611
|
+
let listAttrs = null;
|
|
3612
|
+
const connection = createMockConnection({
|
|
3613
|
+
state: 3,
|
|
3614
|
+
capabilities: new Map([['IMAP4rev2', true]]),
|
|
3615
|
+
exec: async (cmd, attrs, opts) => {
|
|
3616
|
+
if (cmd === 'LIST') {
|
|
3617
|
+
listAttrs = attrs;
|
|
3618
|
+
if (opts && opts.untagged && opts.untagged.LIST) {
|
|
3619
|
+
await opts.untagged.LIST({
|
|
3620
|
+
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
|
|
3621
|
+
});
|
|
3622
|
+
}
|
|
3623
|
+
if (opts && opts.untagged && opts.untagged.STATUS) {
|
|
3624
|
+
await opts.untagged.STATUS({
|
|
3625
|
+
attributes: [
|
|
3626
|
+
{ value: 'INBOX' },
|
|
3627
|
+
[{ value: 'MESSAGES' }, { value: '10' }, { value: 'SIZE' }, { value: '12345678901234' }, { value: 'DELETED' }, { value: '3' }]
|
|
3628
|
+
]
|
|
3629
|
+
});
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
return { next: () => {} };
|
|
3633
|
+
}
|
|
3634
|
+
});
|
|
3635
|
+
|
|
3636
|
+
const result = await listCommand(connection, '', '*', {
|
|
3637
|
+
statusQuery: { messages: true, size: true, deleted: true }
|
|
3638
|
+
});
|
|
3639
|
+
const attrsStr = JSON.stringify(listAttrs);
|
|
3640
|
+
test.ok(attrsStr.includes('SIZE'));
|
|
3641
|
+
test.ok(attrsStr.includes('DELETED'));
|
|
3642
|
+
const inbox = result.find(entry => entry.path === 'INBOX');
|
|
3643
|
+
test.ok(inbox);
|
|
3644
|
+
test.equal(inbox.status.messages, 10);
|
|
3645
|
+
// STATUS SIZE is a number64 - values beyond 2^32 must survive
|
|
3646
|
+
test.strictEqual(inbox.status.size, 12345678901234);
|
|
3647
|
+
test.strictEqual(inbox.status.deleted, 3);
|
|
3648
|
+
test.done();
|
|
3649
|
+
};
|
|
3650
|
+
|
|
3651
|
+
module.exports['Commands: list tolerates an OLDNAME extended data item'] = async test => {
|
|
3652
|
+
const connection = createMockConnection({
|
|
3653
|
+
state: 3,
|
|
3654
|
+
capabilities: new Map([['IMAP4rev2', true]]),
|
|
3655
|
+
exec: async (cmd, attrs, opts) => {
|
|
3656
|
+
if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
|
|
3657
|
+
// RFC 9051 6.3.9.7: a LIST response may carry an OLDNAME extended
|
|
3658
|
+
// data item after a RENAME or name normalization - the client must
|
|
3659
|
+
// parse the response without choking on the extra attribute
|
|
3660
|
+
await opts.untagged.LIST({
|
|
3661
|
+
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'NewBox' }, [{ value: 'OLDNAME' }, [{ value: 'OldBox' }]]]
|
|
3662
|
+
});
|
|
3663
|
+
}
|
|
3664
|
+
return { next: () => {} };
|
|
3665
|
+
}
|
|
3666
|
+
});
|
|
3667
|
+
|
|
3668
|
+
const result = await listCommand(connection, '', '*');
|
|
3669
|
+
const entry = result.find(folder => folder.path === 'NewBox');
|
|
3670
|
+
test.ok(entry, 'mailbox with OLDNAME extended data must be listed');
|
|
3671
|
+
test.ok(entry.flags.has('\\HasNoChildren'));
|
|
3672
|
+
test.done();
|
|
3673
|
+
};
|
|
3674
|
+
|
|
3456
3675
|
module.exports['Commands: list with CONDSTORE status query'] = async test => {
|
|
3457
3676
|
let listAttrs = null;
|
|
3458
3677
|
const connection = createMockConnection({
|
|
@@ -5742,6 +5961,108 @@ module.exports['Commands: status merges synthetic recent into rev2 query results
|
|
|
5742
5961
|
test.done();
|
|
5743
5962
|
};
|
|
5744
5963
|
|
|
5964
|
+
module.exports['Commands: status requests and parses SIZE and DELETED on rev2 sessions'] = async test => {
|
|
5965
|
+
let queryAttrs = null;
|
|
5966
|
+
const connection = createMockConnection({
|
|
5967
|
+
state: 2,
|
|
5968
|
+
// rev2-only server: STATUS=SIZE is folded in and DELETED is a base rev2
|
|
5969
|
+
// status item (RFC 9051 Appendix E item 3)
|
|
5970
|
+
capabilities: new Map([['IMAP4rev2', true]]),
|
|
5971
|
+
exec: async (cmd, attrs, opts) => {
|
|
5972
|
+
queryAttrs = JSON.stringify(attrs);
|
|
5973
|
+
await opts.untagged.STATUS({
|
|
5974
|
+
attributes: [
|
|
5975
|
+
{ value: 'INBOX' },
|
|
5976
|
+
[{ value: 'MESSAGES' }, { value: '100' }, { value: 'SIZE' }, { value: '12345678901234' }, { value: 'DELETED' }, { value: '3' }]
|
|
5977
|
+
]
|
|
5978
|
+
});
|
|
5979
|
+
return { next: () => {} };
|
|
5980
|
+
}
|
|
5981
|
+
});
|
|
5982
|
+
|
|
5983
|
+
const result = await statusCommand(connection, 'INBOX', { messages: true, size: true, deleted: true });
|
|
5984
|
+
test.ok(queryAttrs.includes('SIZE'));
|
|
5985
|
+
test.ok(queryAttrs.includes('DELETED'));
|
|
5986
|
+
test.equal(result.messages, 100);
|
|
5987
|
+
// STATUS SIZE is a number64 - values beyond 2^32 must survive
|
|
5988
|
+
test.strictEqual(result.size, 12345678901234);
|
|
5989
|
+
test.strictEqual(result.deleted, 3);
|
|
5990
|
+
test.done();
|
|
5991
|
+
};
|
|
5992
|
+
|
|
5993
|
+
module.exports['Commands: status requests SIZE with the STATUS=SIZE token on rev1 sessions'] = async test => {
|
|
5994
|
+
let queryAttrs = null;
|
|
5995
|
+
const connection = createMockConnection({
|
|
5996
|
+
state: 2,
|
|
5997
|
+
// RFC 8438 server: SIZE is available via the capability token, DELETED is
|
|
5998
|
+
// rev2-only and must be dropped
|
|
5999
|
+
capabilities: new Map([
|
|
6000
|
+
['IMAP4rev1', true],
|
|
6001
|
+
['STATUS=SIZE', true]
|
|
6002
|
+
]),
|
|
6003
|
+
exec: async (cmd, attrs, opts) => {
|
|
6004
|
+
queryAttrs = JSON.stringify(attrs);
|
|
6005
|
+
await opts.untagged.STATUS({
|
|
6006
|
+
attributes: [{ value: 'INBOX' }, [{ value: 'SIZE' }, { value: '2048' }]]
|
|
6007
|
+
});
|
|
6008
|
+
return { next: () => {} };
|
|
6009
|
+
}
|
|
6010
|
+
});
|
|
6011
|
+
|
|
6012
|
+
const result = await statusCommand(connection, 'INBOX', { size: true, deleted: true });
|
|
6013
|
+
test.ok(queryAttrs.includes('SIZE'));
|
|
6014
|
+
test.ok(!queryAttrs.includes('DELETED'));
|
|
6015
|
+
test.strictEqual(result.size, 2048);
|
|
6016
|
+
test.done();
|
|
6017
|
+
};
|
|
6018
|
+
|
|
6019
|
+
module.exports['Commands: status requests DELETED with QUOTA=RES-MESSAGE on rev1 sessions'] = async test => {
|
|
6020
|
+
let queryAttrs = null;
|
|
6021
|
+
const connection = createMockConnection({
|
|
6022
|
+
state: 2,
|
|
6023
|
+
// RFC 9208: the DELETED status item is mandatory when QUOTA=RES-MESSAGE
|
|
6024
|
+
// is advertised, even without IMAP4rev2
|
|
6025
|
+
capabilities: new Map([
|
|
6026
|
+
['IMAP4rev1', true],
|
|
6027
|
+
['QUOTA=RES-MESSAGE', true]
|
|
6028
|
+
]),
|
|
6029
|
+
exec: async (cmd, attrs, opts) => {
|
|
6030
|
+
queryAttrs = JSON.stringify(attrs);
|
|
6031
|
+
await opts.untagged.STATUS({
|
|
6032
|
+
attributes: [{ value: 'INBOX' }, [{ value: 'DELETED' }, { value: '4' }]]
|
|
6033
|
+
});
|
|
6034
|
+
return { next: () => {} };
|
|
6035
|
+
}
|
|
6036
|
+
});
|
|
6037
|
+
|
|
6038
|
+
const result = await statusCommand(connection, 'INBOX', { deleted: true });
|
|
6039
|
+
test.ok(queryAttrs.includes('DELETED'));
|
|
6040
|
+
test.strictEqual(result.deleted, 4);
|
|
6041
|
+
test.done();
|
|
6042
|
+
};
|
|
6043
|
+
|
|
6044
|
+
module.exports['Commands: status drops SIZE and DELETED on rev1 sessions without support'] = async test => {
|
|
6045
|
+
let queryAttrs = null;
|
|
6046
|
+
const connection = createMockConnection({
|
|
6047
|
+
state: 2,
|
|
6048
|
+
exec: async (cmd, attrs, opts) => {
|
|
6049
|
+
queryAttrs = JSON.stringify(attrs);
|
|
6050
|
+
await opts.untagged.STATUS({
|
|
6051
|
+
attributes: [{ value: 'INBOX' }, [{ value: 'MESSAGES' }, { value: '100' }]]
|
|
6052
|
+
});
|
|
6053
|
+
return { next: () => {} };
|
|
6054
|
+
}
|
|
6055
|
+
});
|
|
6056
|
+
|
|
6057
|
+
// requesting them must not poison the whole STATUS command on a server that
|
|
6058
|
+
// does not know these items
|
|
6059
|
+
const result = await statusCommand(connection, 'INBOX', { messages: true, size: true, deleted: true });
|
|
6060
|
+
test.ok(!queryAttrs.includes('SIZE'));
|
|
6061
|
+
test.ok(!queryAttrs.includes('DELETED'));
|
|
6062
|
+
test.equal(result.messages, 100);
|
|
6063
|
+
test.done();
|
|
6064
|
+
};
|
|
6065
|
+
|
|
5745
6066
|
module.exports['Commands: status skips when all query values are false'] = async test => {
|
|
5746
6067
|
const connection = createMockConnection({ state: 2 });
|
|
5747
6068
|
|
|
@@ -7007,10 +7328,14 @@ module.exports['Commands: idle NOOP fallback uses STATUS when configured'] = asy
|
|
|
7007
7328
|
};
|
|
7008
7329
|
|
|
7009
7330
|
module.exports['Commands: idle NOOP fallback uses SELECT when configured'] = async test => {
|
|
7331
|
+
// SELECT polling goes through the real select implementation, so it applies the same
|
|
7332
|
+
// mailbox state transitions as a caller-issued select instead of replaying wire arguments.
|
|
7010
7333
|
let selectCalled = false;
|
|
7011
7334
|
const connection = createMockConnection({
|
|
7012
7335
|
state: 3,
|
|
7013
7336
|
capabilities: new Map(),
|
|
7337
|
+
// The mailbox is already open, so its folder metadata is cached (no LIST round trip)
|
|
7338
|
+
folders: new Map([['INBOX', { path: 'INBOX', delimiter: '/' }]]),
|
|
7014
7339
|
currentSelectCommand: { command: 'SELECT', arguments: [{ value: 'INBOX' }] },
|
|
7015
7340
|
missingIdleCommand: 'SELECT',
|
|
7016
7341
|
exec: async cmd => {
|
|
@@ -7020,12 +7345,14 @@ module.exports['Commands: idle NOOP fallback uses SELECT when configured'] = asy
|
|
|
7020
7345
|
await connection.preCheck();
|
|
7021
7346
|
}
|
|
7022
7347
|
}
|
|
7023
|
-
return { next: () => {} };
|
|
7348
|
+
return { next: () => {}, response: { attributes: [{ value: 'OK' }] } };
|
|
7024
7349
|
}
|
|
7025
7350
|
});
|
|
7026
7351
|
|
|
7027
7352
|
await idleCommand(connection);
|
|
7028
7353
|
test.equal(selectCalled, true);
|
|
7354
|
+
test.equal(connection.mailbox.path, 'INBOX', 'mailbox state was reapplied by the select implementation');
|
|
7355
|
+
test.equal(connection.mailbox.delimiter, '/', 'cached folder metadata was merged in, as with a normal SELECT');
|
|
7029
7356
|
test.done();
|
|
7030
7357
|
};
|
|
7031
7358
|
|
|
@@ -7591,6 +7918,30 @@ module.exports['Commands: namespace with NAMESPACE capability'] = async test =>
|
|
|
7591
7918
|
test.done();
|
|
7592
7919
|
};
|
|
7593
7920
|
|
|
7921
|
+
module.exports['Commands: namespace uses the real command on rev2-only servers without the token'] = async test => {
|
|
7922
|
+
const connection = createMockConnection({
|
|
7923
|
+
state: 2,
|
|
7924
|
+
// NAMESPACE is folded into base IMAP4rev2 (RFC 9051 Appendix E) - a
|
|
7925
|
+
// rev2-only server gets a real NAMESPACE command, not the LIST fallback
|
|
7926
|
+
capabilities: new Map([['IMAP4rev2', true]]),
|
|
7927
|
+
exec: async (cmd, args, opts) => {
|
|
7928
|
+
test.equal(cmd, 'NAMESPACE');
|
|
7929
|
+
if (opts && opts.untagged && opts.untagged.NAMESPACE) {
|
|
7930
|
+
await opts.untagged.NAMESPACE({
|
|
7931
|
+
attributes: [[[{ value: '' }, { value: '/' }]], null, null]
|
|
7932
|
+
});
|
|
7933
|
+
}
|
|
7934
|
+
return { next: () => {} };
|
|
7935
|
+
}
|
|
7936
|
+
});
|
|
7937
|
+
|
|
7938
|
+
const result = await namespaceCommand(connection);
|
|
7939
|
+
test.ok(result);
|
|
7940
|
+
test.equal(result.prefix, '');
|
|
7941
|
+
test.equal(result.delimiter, '/');
|
|
7942
|
+
test.done();
|
|
7943
|
+
};
|
|
7944
|
+
|
|
7594
7945
|
module.exports['Commands: namespace fallback without capability'] = async test => {
|
|
7595
7946
|
const connection = createMockConnection({
|
|
7596
7947
|
state: 2, // AUTHENTICATED
|
|
@@ -608,46 +608,10 @@ module.exports['Connection Edge: Race condition in mailbox lock'] = test => {
|
|
|
608
608
|
});
|
|
609
609
|
};
|
|
610
610
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
auth: { user: 'test', pass: 'test' }
|
|
616
|
-
});
|
|
617
|
-
|
|
618
|
-
// Mock capabilities
|
|
619
|
-
client.capabilities = new Map();
|
|
620
|
-
client.capabilities.set('STARTTLS', true);
|
|
621
|
-
|
|
622
|
-
// Mock run method
|
|
623
|
-
client.run = async command => {
|
|
624
|
-
if (command === 'STARTTLS') {
|
|
625
|
-
client.expectCapabilityUpdate = true;
|
|
626
|
-
return true;
|
|
627
|
-
}
|
|
628
|
-
if (command === 'CAPABILITY') {
|
|
629
|
-
return true;
|
|
630
|
-
}
|
|
631
|
-
};
|
|
632
|
-
|
|
633
|
-
// Mock socket upgrade
|
|
634
|
-
client.socket = new EventEmitter();
|
|
635
|
-
client.socket.unpipe = () => {};
|
|
636
|
-
client.streamer = new EventEmitter();
|
|
637
|
-
|
|
638
|
-
// Override the upgradeToSTARTTLS to test capability update
|
|
639
|
-
client
|
|
640
|
-
.upgradeToSTARTTLS()
|
|
641
|
-
.then(result => {
|
|
642
|
-
test.ok(result, 'Should successfully upgrade to TLS');
|
|
643
|
-
test.ok(client.expectCapabilityUpdate, 'Should expect capability update');
|
|
644
|
-
test.done();
|
|
645
|
-
})
|
|
646
|
-
.catch(() => {
|
|
647
|
-
// Expected for this mock setup
|
|
648
|
-
test.done();
|
|
649
|
-
});
|
|
650
|
-
};
|
|
611
|
+
// NB! the capability discard/re-fetch behavior around STARTTLS is asserted end to
|
|
612
|
+
// end in imap-flow-secure-test.js ('Secure: STARTTLS upgrade completes a session',
|
|
613
|
+
// PRETLS-ONLY/POSTTLS-ONLY markers) - a mocked upgradeToSTARTTLS cannot reach the
|
|
614
|
+
// re-fetch code without a real TLS handshake, so no vacuous variant is kept here.
|
|
651
615
|
|
|
652
616
|
module.exports['Connection Edge: Event handlers attached before piping'] = test => {
|
|
653
617
|
let client = new ImapFlow({
|