imapflow 1.4.8 → 1.5.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 (44) hide show
  1. package/.github/workflows/test.yml +20 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +15 -0
  4. package/CLAUDE.md +12 -5
  5. package/Gruntfile.js +3 -1
  6. package/lib/commands/authenticate.js +8 -3
  7. package/lib/commands/enable.js +13 -4
  8. package/lib/commands/expunge.js +2 -2
  9. package/lib/commands/fetch.js +18 -14
  10. package/lib/commands/idle.js +6 -3
  11. package/lib/commands/list.js +241 -61
  12. package/lib/commands/move.js +2 -2
  13. package/lib/commands/namespace.js +3 -1
  14. package/lib/commands/search.js +88 -13
  15. package/lib/commands/status.js +19 -26
  16. package/lib/handler/imap-compiler.js +12 -9
  17. package/lib/handler/token-parser.js +7 -0
  18. package/lib/imap-flow.d.ts +19 -3
  19. package/lib/imap-flow.js +58 -9
  20. package/lib/search-compiler.js +15 -1
  21. package/lib/tools.js +173 -9
  22. package/package.json +3 -2
  23. package/test/commands-branches-test.js +11 -4
  24. package/test/commands-integration-test.js +1528 -108
  25. package/test/connection-edge-cases-test.js +4 -40
  26. package/test/fixtures/test-tls.js +2 -2
  27. package/test/handler-branches-test.js +4 -3
  28. package/test/imap-compiler-test.js +85 -0
  29. package/test/imap-flow-coverage-test.js +8 -1
  30. package/test/imap-flow-fetch-download-test.js +57 -4
  31. package/test/imap-flow-internals-test.js +2 -2
  32. package/test/imap-flow-methods-test.js +65 -6
  33. package/test/imap-flow-secure-test.js +25 -11
  34. package/test/imap-flow-server-test.js +80 -0
  35. package/test/imap-parser-test.js +113 -3
  36. package/test/imap-stream-test.js +46 -0
  37. package/test/integration/README.md +52 -0
  38. package/test/integration/dovecot-test.conf +27 -0
  39. package/test/integration/rev2-live-test.js +367 -0
  40. package/test/integration/run-rev2-tests.sh +75 -0
  41. package/test/reliability-improvements-test.js +4 -1
  42. package/test/search-compiler-test.js +36 -0
  43. package/test/search-test.js +52 -54
  44. package/test/tools-test.js +176 -19
@@ -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({
@@ -3,6 +3,6 @@
3
3
  // Self-signed certificate + key for in-process TLS/STARTTLS mock servers in tests.
4
4
  // Generated once with: openssl req -x509 -newkey rsa:2048 -nodes -subj "/CN=localhost"
5
5
  module.exports = {
6
- cert: "-----BEGIN CERTIFICATE-----\nMIIDCTCCAfGgAwIBAgIUQSc0WP7RlcOqGCdsMaxzCA97mfkwDQYJKoZIhvcNAQEL\nBQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDYwODA0Mzg1NFoXDTM2MDYw\nNTA0Mzg1NFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF\nAAOCAQ8AMIIBCgKCAQEA4TYnyK2nEn6wXEgVeEI3I9CDlONvWDO1Y51mslOaUwZR\nfuZp3YMUZDINNdZuFrREkD1WJfClRZtDKEurw9FfjEfA0gsM9RPvkQYXZ2zzhXgW\nx86li0Ra/Zx2RmajsWc+THTEpdOK0nqOqMMKFxiYJ7E+KiJY0X6HqKjdaB9CpZv7\nA1ROJoLjTnsdDG/NT9MCQxFbw05N2Bbbq+McKyXIEzxiZguYLIXsMKCi9MQOozvY\nT8X0AFWpFJCa8mRCW7LQngeSRQQckDONzHyELXnbvgIskr/WcmBEXMraTts3Z3QW\nouee6HFnYzi/pgCtVKX1Wu3oZm/nMblsWtopqg/YDwIDAQABo1MwUTAdBgNVHQ4E\nFgQUF5oycZ3ZaPs1fTaxZb666FQt+KcwHwYDVR0jBBgwFoAUF5oycZ3ZaPs1fTax\nZb666FQt+KcwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAWFk0\nMGw2pdkeU/s5j0T9GL8GjbOVC0G7H8tlx92XhQICmt/9GNHeb5nm84DgqRbkiTwS\nOXPoMsSvVEubnX0j/sqcbi5yQ6/YBG6Vx5E9CnfUbo4m9x1GofDkLdl+e6l636Mj\nWB5kJpNCH9sm2xPdLpl47pv/lwvyYjdy1DaATHn5nMGoM73IeEt0d4xoNN8Bso7B\nTTkjKRJ1QWgI2EjgU93x8tG4AqTj0CeH8Z6DDmfLhvJ6E0hSws4OQSnxWudh2aX8\niN+FkHgRobPS1GVCtibr+FxH1DtBv/grXhAbs4xP3Nf3m3Pn6AE0/lMtto3e67As\np4lf866gU9nbRkODqw==\n-----END CERTIFICATE-----\n",
7
- key: "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDhNifIracSfrBc\nSBV4Qjcj0IOU429YM7VjnWayU5pTBlF+5mndgxRkMg011m4WtESQPVYl8KVFm0Mo\nS6vD0V+MR8DSCwz1E++RBhdnbPOFeBbHzqWLRFr9nHZGZqOxZz5MdMSl04rSeo6o\nwwoXGJgnsT4qIljRfoeoqN1oH0Klm/sDVE4mguNOex0Mb81P0wJDEVvDTk3YFtur\n4xwrJcgTPGJmC5gshewwoKL0xA6jO9hPxfQAVakUkJryZEJbstCeB5JFBByQM43M\nfIQtedu+AiySv9ZyYERcytpO2zdndBai557ocWdjOL+mAK1UpfVa7ehmb+cxuWxa\n2imqD9gPAgMBAAECggEACgBGqgG+0/JuUIV0S/UyJP6t098kYwKrgR33S99XO21M\nvIRCRUT8kyp/La81D6sNCb6/jvXDJnaA9/l29OjdA7TCr6aFT8jahafYmDjdY5rz\n9Vw25nAFqRIuqm0FGJa2Id0O8oeUuEjFrGGPltD6c3Yf2u0li+rA3tDeQYZ0hjge\nsVXzgI58SbO1Z1XCAXcdbcJOqjpEJ3w99GB5J/B1dDui3KR5Q6+L34HfoW8XmY/X\nCLe+Jcuk06Xa31iZ+0NEMIPOhVoCW//PfgEWD/dfG3jXhk84/Pb8DXaGahOvRPTY\nH1AVrqRBqW6PezR4qU4PdUfU3sp5NEBZl3oaW7/weQKBgQD8CKI7ek00KUhc9j5m\nHGuSR+lUzLziXRBt7xw0JXLa1rfok2RGDeTdIEGSjBr8xsNOe5aga843geoLptNe\n7LRrtJajgHl39NXnblroNidufmXjt9CRgjUsQuIREpjq9NyUGIh8o26/uZLB/Knq\nJ7Uum9YUHI/eEztmI/CZDbnUbQKBgQDkwXaf5UxkY9tsdbw8jHhV5Ypy/hwLXAcI\n90sCI9/mSQAOA9qNa+944MbRfReTseM+OFXHJRHXfuMXPPOrtObtVN94ECwFVePO\n2MI5CHc/SYZ/4Icf+jpPY+TAJtEW/39muwak43cmLhmrSmb9ExMhwfDoQotFBDOQ\nLyPPfMo46wKBgFwpWBHB1kt1sSV1UjoKuHdkXnmI6KUp/OEGZ9zVuNs3W0br5uYL\nHJ6Ttga9vvyiPjcL0HfXFDCODCS4m2CB1DPydIdTapen6zRW0V1KuD6ab+3GgLlE\n0ObfikVN9KcuZR6unfJd3Ri05mqTVxrt7D4f9DekfiNqCvV9vPRq5YI9AoGBAMeS\nYUOucK9EzzIYpVjN1E5cLCWWvDDM4PZZiP8c/UGTfpC7m/i1qf7zW4F4eRFJqM+n\npm5PUPmIoeasLoonETENUskUktM008KqKQhI8oAsTUNTR5AGZsjMhc1+D/zYevXN\nRUaljFWq8ooWJzOzaIWTBfIb3BUiTToFZmCmpmZxAoGAH2W3eDhuKrsE50ntofhZ\nx9Uy0OD30nztyecen26kuljcX7qdWh564mBob75wGSAoYzGSePZjJ/tQ4FznXUes\n6F4gM8Y58d3YZOjNq3xnT7AfaJHNnnjxA4pfnGRVr3wA2vwyrD3McgAPXf+mnB23\nl8nz6wxk97WnaIyo7Ofi/+A=\n-----END PRIVATE KEY-----\n"
6
+ cert: '-----BEGIN CERTIFICATE-----\nMIIDCTCCAfGgAwIBAgIUQSc0WP7RlcOqGCdsMaxzCA97mfkwDQYJKoZIhvcNAQEL\nBQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDYwODA0Mzg1NFoXDTM2MDYw\nNTA0Mzg1NFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF\nAAOCAQ8AMIIBCgKCAQEA4TYnyK2nEn6wXEgVeEI3I9CDlONvWDO1Y51mslOaUwZR\nfuZp3YMUZDINNdZuFrREkD1WJfClRZtDKEurw9FfjEfA0gsM9RPvkQYXZ2zzhXgW\nx86li0Ra/Zx2RmajsWc+THTEpdOK0nqOqMMKFxiYJ7E+KiJY0X6HqKjdaB9CpZv7\nA1ROJoLjTnsdDG/NT9MCQxFbw05N2Bbbq+McKyXIEzxiZguYLIXsMKCi9MQOozvY\nT8X0AFWpFJCa8mRCW7LQngeSRQQckDONzHyELXnbvgIskr/WcmBEXMraTts3Z3QW\nouee6HFnYzi/pgCtVKX1Wu3oZm/nMblsWtopqg/YDwIDAQABo1MwUTAdBgNVHQ4E\nFgQUF5oycZ3ZaPs1fTaxZb666FQt+KcwHwYDVR0jBBgwFoAUF5oycZ3ZaPs1fTax\nZb666FQt+KcwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAWFk0\nMGw2pdkeU/s5j0T9GL8GjbOVC0G7H8tlx92XhQICmt/9GNHeb5nm84DgqRbkiTwS\nOXPoMsSvVEubnX0j/sqcbi5yQ6/YBG6Vx5E9CnfUbo4m9x1GofDkLdl+e6l636Mj\nWB5kJpNCH9sm2xPdLpl47pv/lwvyYjdy1DaATHn5nMGoM73IeEt0d4xoNN8Bso7B\nTTkjKRJ1QWgI2EjgU93x8tG4AqTj0CeH8Z6DDmfLhvJ6E0hSws4OQSnxWudh2aX8\niN+FkHgRobPS1GVCtibr+FxH1DtBv/grXhAbs4xP3Nf3m3Pn6AE0/lMtto3e67As\np4lf866gU9nbRkODqw==\n-----END CERTIFICATE-----\n',
7
+ key: '-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDhNifIracSfrBc\nSBV4Qjcj0IOU429YM7VjnWayU5pTBlF+5mndgxRkMg011m4WtESQPVYl8KVFm0Mo\nS6vD0V+MR8DSCwz1E++RBhdnbPOFeBbHzqWLRFr9nHZGZqOxZz5MdMSl04rSeo6o\nwwoXGJgnsT4qIljRfoeoqN1oH0Klm/sDVE4mguNOex0Mb81P0wJDEVvDTk3YFtur\n4xwrJcgTPGJmC5gshewwoKL0xA6jO9hPxfQAVakUkJryZEJbstCeB5JFBByQM43M\nfIQtedu+AiySv9ZyYERcytpO2zdndBai557ocWdjOL+mAK1UpfVa7ehmb+cxuWxa\n2imqD9gPAgMBAAECggEACgBGqgG+0/JuUIV0S/UyJP6t098kYwKrgR33S99XO21M\nvIRCRUT8kyp/La81D6sNCb6/jvXDJnaA9/l29OjdA7TCr6aFT8jahafYmDjdY5rz\n9Vw25nAFqRIuqm0FGJa2Id0O8oeUuEjFrGGPltD6c3Yf2u0li+rA3tDeQYZ0hjge\nsVXzgI58SbO1Z1XCAXcdbcJOqjpEJ3w99GB5J/B1dDui3KR5Q6+L34HfoW8XmY/X\nCLe+Jcuk06Xa31iZ+0NEMIPOhVoCW//PfgEWD/dfG3jXhk84/Pb8DXaGahOvRPTY\nH1AVrqRBqW6PezR4qU4PdUfU3sp5NEBZl3oaW7/weQKBgQD8CKI7ek00KUhc9j5m\nHGuSR+lUzLziXRBt7xw0JXLa1rfok2RGDeTdIEGSjBr8xsNOe5aga843geoLptNe\n7LRrtJajgHl39NXnblroNidufmXjt9CRgjUsQuIREpjq9NyUGIh8o26/uZLB/Knq\nJ7Uum9YUHI/eEztmI/CZDbnUbQKBgQDkwXaf5UxkY9tsdbw8jHhV5Ypy/hwLXAcI\n90sCI9/mSQAOA9qNa+944MbRfReTseM+OFXHJRHXfuMXPPOrtObtVN94ECwFVePO\n2MI5CHc/SYZ/4Icf+jpPY+TAJtEW/39muwak43cmLhmrSmb9ExMhwfDoQotFBDOQ\nLyPPfMo46wKBgFwpWBHB1kt1sSV1UjoKuHdkXnmI6KUp/OEGZ9zVuNs3W0br5uYL\nHJ6Ttga9vvyiPjcL0HfXFDCODCS4m2CB1DPydIdTapen6zRW0V1KuD6ab+3GgLlE\n0ObfikVN9KcuZR6unfJd3Ri05mqTVxrt7D4f9DekfiNqCvV9vPRq5YI9AoGBAMeS\nYUOucK9EzzIYpVjN1E5cLCWWvDDM4PZZiP8c/UGTfpC7m/i1qf7zW4F4eRFJqM+n\npm5PUPmIoeasLoonETENUskUktM008KqKQhI8oAsTUNTR5AGZsjMhc1+D/zYevXN\nRUaljFWq8ooWJzOzaIWTBfIb3BUiTToFZmCmpmZxAoGAH2W3eDhuKrsE50ntofhZ\nx9Uy0OD30nztyecen26kuljcX7qdWh564mBob75wGSAoYzGSePZjJ/tQ4FznXUes\n6F4gM8Y58d3YZOjNq3xnT7AfaJHNnnjxA4pfnGRVr3wA2vwyrD3McgAPXf+mnB23\nl8nz6wxk97WnaIyo7Ofi/+A=\n-----END PRIVATE KEY-----\n'
8
8
  };
@@ -115,8 +115,9 @@ module.exports['imap-compiler: synchronizing literal with non-buffer value seeds
115
115
  {
116
116
  tag: '*',
117
117
  command: 'CMD',
118
- // value is an object that is neither string/number/Buffer but has a numeric
119
- // length, so literalLength resolves to 10 while formatRespEntry(value, true)
118
+ // value is an object that is neither string/number/Buffer, so the size
119
+ // marker derives from the UTF-8 byte length of its stringified form
120
+ // ('[object Object]', 15 bytes) while formatRespEntry(value, true)
120
121
  // returns null -> the seeded resp falls back to [] (line 156 right side).
121
122
  attributes: [{ type: 'LITERAL', value: { length: 10 } }]
122
123
  },
@@ -125,7 +126,7 @@ module.exports['imap-compiler: synchronizing literal with non-buffer value seeds
125
126
  ).map(entry => entry.toString());
126
127
  // Only the header segment is emitted; the empty seeded data segment is dropped
127
128
  // because `if (resp.length)` is false at the end.
128
- test.deepEqual(out, ['* CMD {10}\r\n']);
129
+ test.deepEqual(out, ['* CMD {15}\r\n']);
129
130
  });
130
131
 
131
132
  // STRING node with falsy value -> `(node.value || '')` right side (line 165).
@@ -362,6 +362,91 @@ module.exports['IMAP Compiler: LITERAL literal8'] = test =>
362
362
  )
363
363
  );
364
364
 
365
+ // RFC 7888 (folded into IMAP4rev2): with LITERAL- a non-synchronizing literal
366
+ // marker {n+} is only allowed for literals of up to 4096 bytes - anything larger
367
+ // must use the synchronizing {n} form, in every compilation mode
368
+ module.exports['IMAP Compiler: LITERAL- oversized literal stays synchronizing in single-buffer mode'] = test =>
369
+ asyncWrapper(test, async test => {
370
+ let payload = 'a'.repeat(4097);
371
+ let compiled = (
372
+ await compiler(
373
+ {
374
+ tag: '*',
375
+ command: 'CMD',
376
+ attributes: [
377
+ {
378
+ type: 'LITERAL',
379
+ value: payload
380
+ }
381
+ ]
382
+ },
383
+ { literalMinus: true }
384
+ )
385
+ ).toString();
386
+ test.ok(compiled.startsWith('* CMD {4097}\r\n'), `must not use a non-synchronizing marker: ${compiled.slice(0, 20)}`);
387
+ });
388
+
389
+ module.exports['IMAP Compiler: LITERAL- boundary: exactly 4096 bytes is non-synchronizing'] = test =>
390
+ asyncWrapper(test, async test => {
391
+ let payload = 'a'.repeat(4096);
392
+ let parts = (
393
+ await compiler(
394
+ {
395
+ tag: '*',
396
+ command: 'CMD',
397
+ attributes: [
398
+ {
399
+ type: 'LITERAL',
400
+ value: payload
401
+ }
402
+ ]
403
+ },
404
+ { asArray: true, literalMinus: true }
405
+ )
406
+ ).map(entry => entry.toString());
407
+ test.equal(parts.length, 1, 'literal must be appended inline without a continuation break');
408
+ test.ok(parts[0].startsWith('* CMD {4096+}\r\n'));
409
+ });
410
+
411
+ module.exports['IMAP Compiler: LITERAL- boundary: 4097 bytes falls back to synchronizing'] = test =>
412
+ asyncWrapper(test, async test => {
413
+ let payload = 'a'.repeat(4097);
414
+ let parts = (
415
+ await compiler(
416
+ {
417
+ tag: '*',
418
+ command: 'CMD',
419
+ attributes: [
420
+ {
421
+ type: 'LITERAL',
422
+ value: payload
423
+ }
424
+ ]
425
+ },
426
+ { asArray: true, literalMinus: true }
427
+ )
428
+ ).map(entry => entry.toString());
429
+ test.equal(parts.length, 2, 'literal must wait for a continuation response');
430
+ test.ok(parts[0].endsWith('{4097}\r\n'), `marker must be synchronizing: ${parts[0].slice(-12)}`);
431
+ });
432
+
433
+ // The literal size marker counts octets, not UTF-16 code units - a unicode string
434
+ // value must declare its UTF-8 byte length
435
+ module.exports['IMAP Compiler: LITERAL declares byte length for unicode string values'] = test =>
436
+ asyncWrapper(test, async test => {
437
+ let compiled = await compiler({
438
+ tag: '*',
439
+ command: 'CMD',
440
+ attributes: [
441
+ {
442
+ type: 'LITERAL',
443
+ value: 'Sõnumid'
444
+ }
445
+ ]
446
+ });
447
+ test.equal(compiled.toString(), '* CMD {8}\r\nSõnumid');
448
+ });
449
+
365
450
  module.exports['IMAP Compiler: LITERAL array 1'] = test =>
366
451
  asyncWrapper(test, async test =>
367
452
  test.deepEqual(
@@ -553,7 +553,14 @@ module.exports['Coverage: reader handles compiler failure when building executed
553
553
  client.requestTagMap.set('A1', {
554
554
  command: 'NOOP',
555
555
  // a circular/odd attribute that makes the logging compiler throw
556
- attributes: [{ type: 'LITERAL', get value() { throw new Error('compile boom'); } }],
556
+ attributes: [
557
+ {
558
+ type: 'LITERAL',
559
+ get value() {
560
+ throw new Error('compile boom');
561
+ }
562
+ }
563
+ ],
557
564
  options: {},
558
565
  resolve: () => {},
559
566
  reject: err => {
@@ -300,6 +300,62 @@ module.exports['Download: base64 body part is decoded'] = async test => {
300
300
  test.done();
301
301
  };
302
302
 
303
+ module.exports['Download: binary-fetched part skips the client-side transfer decoding'] = async test => {
304
+ let client = makeClient();
305
+ // BINARY fetch (RFC 3516, FETCH side folded into IMAP4rev2): the server
306
+ // decodes the content-transfer-encoding itself, so the client gets raw bytes
307
+ // even though the MIME headers still declare base64 - decoding again would
308
+ // corrupt the data. formatMessageResponse records the parts that arrived via
309
+ // BINARY[...] in the binaryParts set, which the mock mirrors here.
310
+ let mime = Buffer.from('Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\n');
311
+ let decoded = Buffer.from('Hello World');
312
+ client.fetchOne = async () => {
313
+ let bodyParts = new Map();
314
+ bodyParts.set('2.mime', mime);
315
+ bodyParts.set('2', decoded);
316
+ return { uid: 1, size: decoded.length, bodyParts, binaryParts: new Set(['2']) };
317
+ };
318
+ let { meta, content } = await client.download('1', '2', { chunkSize: 1024, binary: true });
319
+ test.equal(meta.encoding, 'base64');
320
+ let data = await collect(content);
321
+ test.equal(data.toString(), 'Hello World');
322
+ test.done();
323
+ };
324
+
325
+ module.exports['DownloadMany: binary-fetched parts skip the client-side transfer decoding'] = async test => {
326
+ let client = makeClient();
327
+ let mime = Buffer.from('Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\n');
328
+ client.fetchOne = async () => {
329
+ let bodyParts = new Map();
330
+ bodyParts.set('2.mime', mime);
331
+ bodyParts.set('2', Buffer.from('part two'));
332
+ return { uid: 1, bodyParts, binaryParts: new Set(['2']) };
333
+ };
334
+ let res = await client.downloadMany('1', ['2'], { binary: true });
335
+ test.equal(res['2'].content.toString(), 'part two');
336
+ test.equal(res['2'].meta.encoding, 'base64');
337
+ test.done();
338
+ };
339
+
340
+ module.exports['Download: BODY-answered part is still decoded even when binary was requested'] = async test => {
341
+ let client = makeClient();
342
+ // RFC 3516 lets a server answer a BINARY request with a NO or a BODY response
343
+ // (e.g. [UNKNOWN-CTE]) - decoding must follow what actually arrived, so a part
344
+ // absent from binaryParts gets the client-side decoder despite options.binary
345
+ let mime = Buffer.from('Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\n');
346
+ let encoded = Buffer.from(libbase64.encode(Buffer.from('Hello World')));
347
+ client.fetchOne = async () => {
348
+ let bodyParts = new Map();
349
+ bodyParts.set('2.mime', mime);
350
+ bodyParts.set('2', encoded);
351
+ return { uid: 1, size: encoded.length, bodyParts };
352
+ };
353
+ let { content } = await client.download('1', '2', { chunkSize: 1024, binary: true });
354
+ let data = await collect(content);
355
+ test.equal(data.toString(), 'Hello World');
356
+ test.done();
357
+ };
358
+
303
359
  module.exports['Download: quoted-printable inline text with charset + filename'] = async test => {
304
360
  let client = makeClient();
305
361
  let mime = Buffer.from(
@@ -624,10 +680,7 @@ module.exports['DownloadMany: returns {response:false} when no bodyParts'] = asy
624
680
 
625
681
  module.exports['DownloadMany: parses charset, flowed and name params'] = async test => {
626
682
  let client = makeClient();
627
- let mime = Buffer.from(
628
- 'Content-Type: text/plain; charset=iso-8859-1; format=flowed; delsp=yes; name="x.txt"\r\n' +
629
- 'Content-Disposition: inline\r\n\r\n'
630
- );
683
+ let mime = Buffer.from('Content-Type: text/plain; charset=iso-8859-1; format=flowed; delsp=yes; name="x.txt"\r\nContent-Disposition: inline\r\n\r\n');
631
684
  client.fetchOne = async () => {
632
685
  let bodyParts = new Map();
633
686
  bodyParts.set('2.mime', mime);
@@ -355,7 +355,7 @@ module.exports['Internals: secure connection defaults to port 993'] = test => {
355
355
 
356
356
  module.exports['Internals: getUntaggedHandler ignores non-string type token'] = test => {
357
357
  let client = makeClient();
358
- client.untaggedHandlers = { '5': () => 'numeric-keyword-handler' };
358
+ client.untaggedHandlers = { 5: () => 'numeric-keyword-handler' };
359
359
  // numeric prefix but attributes[0].value is not a string -> keyword stays '5'
360
360
  let handler = client.getUntaggedHandler('5', [{ type: 'ATOM', value: 12345 }]);
361
361
  test.equal(handler(), 'numeric-keyword-handler');
@@ -409,7 +409,7 @@ module.exports['Internals: unbind falls back to socket when writeSocket missing'
409
409
  };
410
410
  client.socket = raw;
411
411
  client.writeSocket = null;
412
- client.streamer = { /* unused here */ };
412
+ client.streamer = {/* unused here */};
413
413
  let result = client.unbind();
414
414
  test.equal(result.writeSocket, raw);
415
415
  test.done();
@@ -96,6 +96,19 @@ module.exports['Methods: listTree returns tree structure'] = async test => {
96
96
  test.done();
97
97
  };
98
98
 
99
+ module.exports['Methods: listTree passes through inline status objects'] = async test => {
100
+ let client = makeClient();
101
+ recordRun(client, [
102
+ { path: 'INBOX', name: 'INBOX', delimiter: '/', parent: [], flags: new Set(), status: { path: 'INBOX', messages: 5, unseen: 2 } },
103
+ { path: 'INBOX/Sub', name: 'Sub', delimiter: '/', parent: ['INBOX'], flags: new Set() }
104
+ ]);
105
+ let tree = await client.listTree({ statusQuery: { messages: true, unseen: true } });
106
+ // The StatusObject must survive into the tree node - it used to be coerced
107
+ // into a boolean by the tree builder
108
+ test.deepEqual(tree.folders[0].status, { path: 'INBOX', messages: 5, unseen: 2 });
109
+ test.done();
110
+ };
111
+
99
112
  module.exports['Methods: noop dispatches NOOP'] = async test => {
100
113
  let client = makeClient();
101
114
  let calls = recordRun(client, true);
@@ -721,12 +734,7 @@ module.exports['Methods: untaggedFetch emits flags event'] = async test => {
721
734
  command: '1',
722
735
  attributes: [
723
736
  { type: 'ATOM', value: 'FETCH' },
724
- [
725
- { type: 'ATOM', value: 'UID' },
726
- { type: 'ATOM', value: '100' },
727
- { type: 'ATOM', value: 'FLAGS' },
728
- [{ type: 'ATOM', value: '\\Seen' }]
729
- ]
737
+ [{ type: 'ATOM', value: 'UID' }, { type: 'ATOM', value: '100' }, { type: 'ATOM', value: 'FLAGS' }, [{ type: 'ATOM', value: '\\Seen' }]]
730
738
  ]
731
739
  };
732
740
  await client.untaggedFetch(untagged);
@@ -736,3 +744,54 @@ module.exports['Methods: untaggedFetch emits flags event'] = async test => {
736
744
  test.ok(evt.flags.has('\\Seen'));
737
745
  test.done();
738
746
  };
747
+
748
+ // ============================================
749
+ // autoEnable
750
+ // ============================================
751
+
752
+ module.exports['Methods: autoEnable requests IMAP4rev2 alongside the base extensions'] = async test => {
753
+ let client = makeClient();
754
+ let calls = recordRun(client, new Set(['CONDSTORE', 'IMAP4REV2']));
755
+ await client.autoEnable();
756
+ test.equal(calls.length, 1);
757
+ test.deepEqual(calls[0], ['ENABLE', ['CONDSTORE', 'UTF8=ACCEPT', 'IMAP4rev2']]);
758
+ test.done();
759
+ };
760
+
761
+ module.exports['Methods: autoEnable honors disableIMAP4rev2'] = async test => {
762
+ let client = makeClient({ disableIMAP4rev2: true });
763
+ let calls = recordRun(client, new Set(['CONDSTORE']));
764
+ await client.autoEnable();
765
+ test.equal(calls.length, 1);
766
+ test.deepEqual(calls[0], ['ENABLE', ['CONDSTORE', 'UTF8=ACCEPT']]);
767
+ test.done();
768
+ };
769
+
770
+ module.exports['Methods: autoEnable includes QRESYNC when requested'] = async test => {
771
+ let client = makeClient({ qresync: true });
772
+ let calls = recordRun(client, new Set(['CONDSTORE', 'QRESYNC', 'IMAP4REV2']));
773
+ await client.autoEnable();
774
+ test.equal(calls.length, 1);
775
+ test.deepEqual(calls[0], ['ENABLE', ['CONDSTORE', 'UTF8=ACCEPT', 'QRESYNC', 'IMAP4rev2']]);
776
+ test.done();
777
+ };
778
+
779
+ module.exports['Methods: autoEnable retries without IMAP4rev2 when the whole command fails'] = async test => {
780
+ let client = makeClient();
781
+ // RFC 5161 requires unknown ENABLE arguments to be ignored, but a broken
782
+ // server may reject the whole command over IMAP4rev2 - the retry keeps
783
+ // CONDSTORE/UTF8=ACCEPT from being lost as collateral damage
784
+ let calls = recordRun(client, (cmd, list) => (list.includes('IMAP4rev2') ? false : new Set(['CONDSTORE'])));
785
+ await client.autoEnable();
786
+ test.equal(calls.length, 2);
787
+ test.deepEqual(calls[1], ['ENABLE', ['CONDSTORE', 'UTF8=ACCEPT']]);
788
+ test.done();
789
+ };
790
+
791
+ module.exports['Methods: autoEnable does not retry when disableIMAP4rev2 already omitted it'] = async test => {
792
+ let client = makeClient({ disableIMAP4rev2: true });
793
+ let calls = recordRun(client, () => false);
794
+ await client.autoEnable();
795
+ test.equal(calls.length, 1);
796
+ test.done();
797
+ };
@@ -13,13 +13,17 @@ const CAPS = 'IMAP4rev1 ID ENABLE NAMESPACE';
13
13
 
14
14
  // Shared per-connection IMAP line handler used by both the plaintext and the
15
15
  // upgraded TLS phases. Returns responses for the minimal session commands.
16
- const handleLine = (sock, line, onStartTls) => {
16
+ // `caps` is the full capability list to advertise for this phase - the STARTTLS
17
+ // test uses different pre- and post-TLS sets to prove the client discards the
18
+ // plaintext capabilities and re-fetches them over TLS (RFC 9051 6.2.1).
19
+ const handleLine = (sock, line, onStartTls, caps) => {
20
+ caps = caps || `${CAPS} STARTTLS`;
17
21
  let parts = line.split(' ');
18
22
  let tag = parts[0];
19
23
  let cmd = (parts[1] || '').toUpperCase();
20
24
  switch (cmd) {
21
25
  case 'CAPABILITY':
22
- sock.write(`* CAPABILITY ${CAPS} STARTTLS\r\n${tag} OK CAPABILITY done\r\n`);
26
+ sock.write(`* CAPABILITY ${caps}\r\n${tag} OK CAPABILITY done\r\n`);
23
27
  break;
24
28
  case 'STARTTLS':
25
29
  sock.write(`${tag} OK Begin TLS\r\n`);
@@ -81,17 +85,23 @@ module.exports['Secure: STARTTLS upgrade completes a session'] = async test => {
81
85
 
82
86
  let detachPlain;
83
87
  detachPlain = lineReader(rawSocket, line => {
84
- handleLine(rawSocket, line, () => {
85
- // Upgrade: stop reading plaintext, wrap the socket in TLS
86
- detachPlain();
87
- let tlsSocket = new tls.TLSSocket(rawSocket, { isServer: true, key, cert });
88
- tlsSocket.on('error', () => {});
89
- tlsSocket.on('secure', () => {});
90
- lineReader(tlsSocket, l => handleLine(tlsSocket, l));
91
- });
88
+ handleLine(
89
+ rawSocket,
90
+ line,
91
+ () => {
92
+ // Upgrade: stop reading plaintext, wrap the socket in TLS
93
+ detachPlain();
94
+ let tlsSocket = new tls.TLSSocket(rawSocket, { isServer: true, key, cert });
95
+ tlsSocket.on('error', () => {});
96
+ tlsSocket.on('secure', () => {});
97
+ // post-TLS phase advertises a different capability set
98
+ lineReader(tlsSocket, l => handleLine(tlsSocket, l, null, `${CAPS} POSTTLS-ONLY`));
99
+ },
100
+ `${CAPS} STARTTLS PRETLS-ONLY`
101
+ );
92
102
  });
93
103
 
94
- rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS] ready\r\n`);
104
+ rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS PRETLS-ONLY] ready\r\n`);
95
105
  });
96
106
 
97
107
  let port = await listen(server);
@@ -113,6 +123,10 @@ module.exports['Secure: STARTTLS upgrade completes a session'] = async test => {
113
123
  test.ok(client.secureConnection, 'connection upgraded to TLS');
114
124
  test.ok(client.authenticated, 'authenticated over TLS');
115
125
  test.ok(client.usable);
126
+ // RFC 9051 6.2.1: capabilities cached before STARTTLS MUST be discarded and
127
+ // re-fetched over the TLS channel
128
+ test.ok(client.capabilities.has('POSTTLS-ONLY'), 'post-TLS capabilities were re-fetched');
129
+ test.ok(!client.capabilities.has('PRETLS-ONLY'), 'pre-TLS capabilities were discarded');
116
130
 
117
131
  await client.noop();
118
132
  await client.logout();
@@ -797,6 +797,8 @@ module.exports['Server: PREAUTH greeting skips login'] = async test => {
797
797
  await client.connect();
798
798
  test.equal(client.state, client.states.AUTHENTICATED);
799
799
  test.ok(client.usable);
800
+ // documented contract: `true` if the connection was authenticated by PREAUTH
801
+ test.strictEqual(client.authenticated, true);
800
802
 
801
803
  await client.logout();
802
804
  client.close();
@@ -845,6 +847,84 @@ module.exports['Server: unsolicited EXISTS and VANISHED reach the untagged handl
845
847
  test.done();
846
848
  };
847
849
 
850
+ module.exports['Server: rev2-shaped SELECT response is tolerated through the full pipeline'] = async test => {
851
+ // RFC 9051 requires rev2 servers to include an untagged LIST in the SELECT
852
+ // response and a CLOSED response code when another mailbox was selected, and
853
+ // to omit RECENT/UNSEEN. The client must consume such a response cleanly.
854
+ let server = createServer({
855
+ capabilities: 'IMAP4rev2 ID ENABLE NAMESPACE',
856
+ handlers: {
857
+ SELECT(ctx) {
858
+ ctx.write('* OK [CLOSED] Previous mailbox closed\r\n');
859
+ ctx.write('* 3 EXISTS\r\n');
860
+ ctx.write('* LIST () "/" INBOX\r\n');
861
+ ctx.write('* FLAGS (\\Seen \\Answered \\Flagged \\Deleted \\Draft)\r\n');
862
+ ctx.write('* OK [PERMANENTFLAGS (\\Seen \\*)] Limited\r\n');
863
+ ctx.write('* OK [UIDVALIDITY 12345] UIDs valid\r\n');
864
+ ctx.write('* OK [UIDNEXT 100] Predicted next UID\r\n');
865
+ ctx.ok('[READ-WRITE] SELECT completed');
866
+ }
867
+ }
868
+ });
869
+ let port = await listen(server);
870
+ let client = makeClient(port);
871
+ let errors = [];
872
+ client.on('error', err => errors.push(err));
873
+
874
+ await client.connect();
875
+ test.ok(client.capabilities.has('IMAP4rev2'));
876
+
877
+ let mailbox = await client.mailboxOpen('INBOX');
878
+ test.equal(mailbox.path, 'INBOX');
879
+ test.equal(mailbox.exists, 3);
880
+ test.equal(mailbox.uidNext, 100);
881
+ test.equal(mailbox.uidValidity, 12345n);
882
+
883
+ // re-select drives the CLOSED response code through the live pipeline again
884
+ let again = await client.mailboxOpen('INBOX');
885
+ test.equal(again.exists, 3);
886
+ test.equal(errors.length, 0, 'no errors from the rev2-shaped response');
887
+
888
+ await client.logout();
889
+ client.close();
890
+ server.close();
891
+ test.done();
892
+ };
893
+
894
+ module.exports['Server: unsolicited STATUS for another mailbox is tolerated'] = async test => {
895
+ // RFC 9051 (Appendix E item 20): with rev2, servers may push updates that are
896
+ // unrelated to the selected mailbox (e.g. a STATUS for another mailbox during
897
+ // IDLE). The client must ignore them without corrupting the selected state.
898
+ let server = createServer({
899
+ handlers: {
900
+ NOOP(ctx) {
901
+ ctx.write('* STATUS "Other" (MESSAGES 5 UNSEEN 1)\r\n');
902
+ ctx.ok('NOOP completed');
903
+ }
904
+ }
905
+ });
906
+ let port = await listen(server);
907
+ let client = makeClient(port);
908
+ let errors = [];
909
+ client.on('error', err => errors.push(err));
910
+
911
+ await client.connect();
912
+ await client.mailboxOpen('INBOX');
913
+ test.equal(client.mailbox.exists, 3);
914
+
915
+ await client.noop();
916
+ await new Promise(r => setTimeout(r, 20));
917
+
918
+ test.equal(client.mailbox.path, 'INBOX', 'selected mailbox unchanged');
919
+ test.equal(client.mailbox.exists, 3, 'selected mailbox message count unchanged');
920
+ test.equal(errors.length, 0, 'unsolicited STATUS must not raise errors');
921
+
922
+ await client.logout();
923
+ client.close();
924
+ server.close();
925
+ test.done();
926
+ };
927
+
848
928
  module.exports['Server: invalid tagged response rejects with InvalidResponse'] = async test => {
849
929
  let server = createServer({
850
930
  handlers: {