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.
Files changed (48) hide show
  1. package/.github/workflows/test.yml +20 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +22 -0
  4. package/CLAUDE.md +3 -5
  5. package/lib/commands/fetch.js +18 -14
  6. package/lib/commands/idle.js +197 -104
  7. package/lib/commands/list.js +19 -8
  8. package/lib/commands/quota.js +3 -0
  9. package/lib/commands/select.js +5 -0
  10. package/lib/commands/status.js +10 -1
  11. package/lib/connection-deadline.js +98 -0
  12. package/lib/handler/imap-compiler.js +20 -14
  13. package/lib/handler/imap-stream.js +141 -50
  14. package/lib/handler/limits.js +43 -0
  15. package/lib/handler/token-parser.js +38 -1
  16. package/lib/imap-flow.d.ts +47 -5
  17. package/lib/imap-flow.js +594 -283
  18. package/lib/proxy-connection.js +393 -98
  19. package/lib/special-use.js +660 -51
  20. package/lib/tools.js +52 -3
  21. package/package.json +2 -2
  22. package/test/commands-branches-test.js +17 -1
  23. package/test/commands-integration-test.js +353 -2
  24. package/test/connection-edge-cases-test.js +4 -40
  25. package/test/fixtures/fake-timers.js +115 -0
  26. package/test/handler-branches-test.js +4 -28
  27. package/test/idle-polling-test.js +349 -0
  28. package/test/imap-compiler-test.js +85 -0
  29. package/test/imap-flow-compress-test.js +12 -0
  30. package/test/imap-flow-coverage-test.js +3 -3
  31. package/test/imap-flow-fetch-download-test.js +56 -0
  32. package/test/imap-flow-internals-test.js +23 -0
  33. package/test/imap-flow-proxy-paths-test.js +151 -0
  34. package/test/imap-flow-secure-test.js +182 -9
  35. package/test/imap-flow-server-test.js +229 -0
  36. package/test/imap-parser-test.js +112 -1
  37. package/test/imap-stream-test.js +46 -0
  38. package/test/integration/README.md +17 -5
  39. package/test/integration/rev2-live-test.js +125 -0
  40. package/test/integration/run-rev2-tests.sh +14 -0
  41. package/test/parser-limits-test.js +274 -0
  42. package/test/proxy-connection-test.js +553 -442
  43. package/test/reliability-improvements-test.js +87 -0
  44. package/test/search-compiler-test.js +17 -0
  45. package/test/special-use-test.js +337 -0
  46. package/test/tag-correlation-test.js +333 -0
  47. package/test/timer-policy-test.js +214 -0
  48. 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
- // and the BINARY consumers have safe fallbacks of their own. The set mirrors the
23
- // Appendix E list in full, including entries no call site consults yet, so any
24
- // future capability check gets the rev2 folding for free.
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.4.9",
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
- return {
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
- return {
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
- module.exports['Connection Edge: Capability update after STARTTLS'] = test => {
612
- let client = new ImapFlow({
613
- host: 'imap.example.com',
614
- port: 143,
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({