imapflow 1.4.8 → 1.4.9

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.
@@ -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
  };
@@ -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 => {
@@ -624,10 +624,7 @@ module.exports['DownloadMany: returns {response:false} when no bodyParts'] = asy
624
624
 
625
625
  module.exports['DownloadMany: parses charset, flowed and name params'] = async test => {
626
626
  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
- );
627
+ 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
628
  client.fetchOne = async () => {
632
629
  let bodyParts = new Map();
633
630
  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
+ };
@@ -341,8 +341,7 @@ module.exports['IMAP Parser: empty continuation (+) response'] = test =>
341
341
  test.equal(parsed.command, '');
342
342
  });
343
343
 
344
- module.exports['IMAP Parser: non-space after command throws ParserError5'] = test =>
345
- expectErrorCode(test, 'ParserError5', () => parser('TAG1 CMD\tx'));
344
+ module.exports['IMAP Parser: non-space after command throws ParserError5'] = test => expectErrorCode(test, 'ParserError5', () => parser('TAG1 CMD\tx'));
346
345
 
347
346
  module.exports['IMAP Parser: leading whitespace in attributes throws ParserError7'] = test =>
348
347
  expectErrorCode(test, 'ParserError7', () => parser('TAG1 CMD \tx'));
@@ -0,0 +1,40 @@
1
+ # Live IMAP4rev2 integration tests
2
+
3
+ Runs the ImapFlow client against a real IMAP4rev2 server - Dovecot 2.4+ in
4
+ Docker - instead of protocol mocks. Covers the ENABLE IMAP4rev2 negotiation,
5
+ LIST RETURN (SUBSCRIBED) without LSUB, subscription round-trips, UTF-8 mailbox
6
+ names, inline LIST-STATUS, ESEARCH responses to plain SEARCH, and a message
7
+ lifecycle smoke test with UID EXPUNGE.
8
+
9
+ ## Running
10
+
11
+ ```
12
+ npm run test:rev2
13
+ ```
14
+
15
+ Requires Docker. The script pulls `dovecot/dovecot:2.4.4`, starts a container
16
+ with the drop-in config from `dovecot-test.conf`, waits for the IMAP greeting
17
+ on `127.0.0.1:31143`, runs `rev2-live-test.js` with nodeunit, and always
18
+ removes the container afterwards.
19
+
20
+ These tests are intentionally not part of `npm test` - the Gruntfile nodeunit
21
+ config excludes `test/integration/**`, so CI and plain test runs stay
22
+ Docker-free.
23
+
24
+ ## Environment overrides
25
+
26
+ - `IMAPFLOW_DOVECOT_IMAGE` - image to run (default `dovecot/dovecot:2.4.4`;
27
+ any 2.4.2+ tag supports IMAP4rev2)
28
+ - `IMAPFLOW_DOVECOT_PLATFORM` - e.g. `linux/amd64`; defaults to the host
29
+ platform. Forcing `linux/amd64` on Apple Silicon does not work - Rosetta
30
+ cannot start Dovecot's privilege-separated login processes
31
+ (`rosetta error: mmap_anonymous_rw mmap failed`)
32
+ - `IMAPFLOW_TEST_PORT` - host port to publish (default 31143)
33
+
34
+ ## Test account model
35
+
36
+ The container uses Dovecot's static passdb (`USER_PASSWORD=pass`): any
37
+ username authenticates with the password `pass` and gets its own empty mail
38
+ home, so every test connects as a brand-new user and needs no cleanup between
39
+ runs. Special-use mailboxes (Sent/Drafts/Junk/Trash) are auto-created and
40
+ subscribed via `dovecot-test.conf`.
@@ -0,0 +1,27 @@
1
+ # Drop-in configuration for the ImapFlow IMAP4rev2 integration test container.
2
+ # Mounted into /etc/dovecot/conf.d/ of the dovecot/dovecot:2.4.x image, which
3
+ # already ships with imap4rev2_enable = yes and mail_utf8_extensions = yes.
4
+
5
+ # The test client connects over plain TCP on the container-internal IMAP port
6
+ # (31143), so cleartext logins must be allowed
7
+ auth_allow_cleartext = yes
8
+
9
+ # Special-use mailboxes, auto-created and subscribed for every test user
10
+ namespace inbox {
11
+ mailbox Sent {
12
+ special_use = \Sent
13
+ auto = subscribe
14
+ }
15
+ mailbox Drafts {
16
+ special_use = \Drafts
17
+ auto = subscribe
18
+ }
19
+ mailbox Junk {
20
+ special_use = \Junk
21
+ auto = subscribe
22
+ }
23
+ mailbox Trash {
24
+ special_use = \Trash
25
+ auto = subscribe
26
+ }
27
+ }
@@ -0,0 +1,242 @@
1
+ 'use strict';
2
+
3
+ // Live integration tests against a real IMAP4rev2 server (Dovecot 2.4+ in Docker).
4
+ // Started via `npm run test:rev2` (see run-rev2-tests.sh) - excluded from the
5
+ // Gruntfile nodeunit config so `npm test` stays Docker-free.
6
+ //
7
+ // The container uses a static passdb: any username logs in with the password
8
+ // "pass", and every user gets a fresh mail home, so each test connects with a
9
+ // unique user for isolation.
10
+
11
+ const { ImapFlow } = require('../../lib/imap-flow');
12
+
13
+ const HOST = process.env.IMAPFLOW_TEST_HOST || '127.0.0.1';
14
+ const PORT = Number(process.env.IMAPFLOW_TEST_PORT) || 31143;
15
+
16
+ let userCounter = 0;
17
+
18
+ const connectClient = async (options, logs) => {
19
+ let client = new ImapFlow(
20
+ Object.assign(
21
+ {
22
+ host: HOST,
23
+ port: PORT,
24
+ secure: false,
25
+ // The test container upgrades via STARTTLS with a self-signed cert
26
+ tls: { rejectUnauthorized: false },
27
+ auth: { user: `livetest-${Date.now()}-${++userCounter}`, pass: 'pass' },
28
+ logger: false,
29
+ emitLogs: !!logs
30
+ },
31
+ options || {}
32
+ )
33
+ );
34
+ if (logs) {
35
+ client.on('log', entry => logs.push(entry));
36
+ }
37
+ await client.connect();
38
+ return client;
39
+ };
40
+
41
+ const wireLines = logs => logs.filter(entry => entry && typeof entry.msg === 'string' && ['c', 's'].includes(entry.src));
42
+ const clientSent = (logs, needle) => wireLines(logs).some(entry => entry.src === 'c' && entry.msg.includes(needle));
43
+ // Matches only an untagged response for the given command ("* ESEARCH ...").
44
+ // A plain substring check would false-positive on capability listings - the
45
+ // login response advertises tokens like ESEARCH in its CAPABILITY list.
46
+ const serverSentUntagged = (logs, command) => {
47
+ let re = new RegExp(`^\\* ${command}( |$)`);
48
+ return wireLines(logs).some(entry => entry.src === 's' && re.test(entry.msg));
49
+ };
50
+
51
+ module.exports['Live rev2: connect negotiates ENABLE IMAP4rev2'] = async test => {
52
+ const client = await connectClient();
53
+ try {
54
+ test.ok(client.capabilities.has('IMAP4rev2'), 'server should advertise IMAP4rev2');
55
+ test.ok(client.enabled.has('IMAP4REV2'), 'IMAP4rev2 should be enabled');
56
+ // The single merged ENABLE call must not lose the other extensions
57
+ test.ok(client.enabled.has('CONDSTORE'), 'CONDSTORE should still be enabled');
58
+ } finally {
59
+ await client.logout();
60
+ }
61
+ test.done();
62
+ };
63
+
64
+ module.exports['Live rev2: list uses RETURN (SUBSCRIBED) and skips LSUB'] = async test => {
65
+ const logs = [];
66
+ const client = await connectClient(null, logs);
67
+ try {
68
+ const folders = await client.list();
69
+
70
+ test.ok(clientSent(logs, 'SUBSCRIBED'), 'LIST should request RETURN (SUBSCRIBED)');
71
+ test.ok(!clientSent(logs, 'LSUB'), 'LSUB must not be issued on a rev2 session');
72
+
73
+ const sent = folders.find(folder => folder.path === 'Sent');
74
+ test.ok(sent, 'Sent mailbox should be listed');
75
+ test.equal(sent.subscribed, true, 'Sent should be reported as subscribed');
76
+ test.equal(sent.specialUse, '\\Sent', 'special-use flag should be honored');
77
+ // \HasNoChildren must survive the extended LIST (RETURN (CHILDREN) requested)
78
+ test.ok(sent.flags.has('\\HasNoChildren'), 'child info should be present');
79
+ for (let folder of folders) {
80
+ test.ok(!folder.flags.has('\\Subscribed'), '\\Subscribed must be folded into the subscribed property');
81
+ }
82
+
83
+ const inbox = folders.find(folder => folder.path === 'INBOX');
84
+ test.ok(inbox, 'INBOX should be listed');
85
+ test.equal(inbox.subscribed, true, 'INBOX is always reported as subscribed');
86
+ } finally {
87
+ await client.logout();
88
+ }
89
+ test.done();
90
+ };
91
+
92
+ module.exports['Live rev2: subscription state round-trips'] = async test => {
93
+ const client = await connectClient();
94
+ try {
95
+ await client.mailboxCreate('RoundTrip');
96
+ await client.mailboxSubscribe('RoundTrip');
97
+ let folders = await client.list();
98
+ test.equal(folders.find(folder => folder.path === 'RoundTrip').subscribed, true);
99
+
100
+ await client.mailboxUnsubscribe('RoundTrip');
101
+ folders = await client.list();
102
+ test.ok(!folders.find(folder => folder.path === 'RoundTrip').subscribed);
103
+ } finally {
104
+ await client.logout();
105
+ }
106
+ test.done();
107
+ };
108
+
109
+ module.exports['Live rev2: deleted-but-subscribed folders stay out of listings'] = async test => {
110
+ const client = await connectClient();
111
+ try {
112
+ await client.mailboxCreate('Ghost');
113
+ await client.mailboxSubscribe('Ghost');
114
+ await client.mailboxDelete('Ghost');
115
+
116
+ const folders = await client.list();
117
+ // The RETURN (SUBSCRIBED) option must not resurrect phantom subscriptions
118
+ // the way a raw LSUB would
119
+ test.ok(!folders.find(folder => folder.path === 'Ghost'), 'deleted folder must not be listed');
120
+ } finally {
121
+ await client.logout();
122
+ }
123
+ test.done();
124
+ };
125
+
126
+ module.exports['Live rev2: UTF-8 mailbox names round-trip'] = async test => {
127
+ const client = await connectClient();
128
+ try {
129
+ const name = 'T\u00f5rva \u00f5un';
130
+ await client.mailboxCreate(name);
131
+ const folders = await client.list();
132
+ const created = folders.find(folder => folder.path === name);
133
+ test.ok(created, 'UTF-8 mailbox should be listed under its literal name');
134
+
135
+ const mailbox = await client.mailboxOpen(name);
136
+ test.equal(mailbox.path, name);
137
+ await client.mailboxClose();
138
+ await client.mailboxDelete(name);
139
+ } finally {
140
+ await client.logout();
141
+ }
142
+ test.done();
143
+ };
144
+
145
+ module.exports['Live rev2: statusQuery is answered inline via LIST-STATUS'] = async test => {
146
+ const logs = [];
147
+ const client = await connectClient(null, logs);
148
+ try {
149
+ await client.append('INBOX', Buffer.from('Subject: status probe\r\n\r\nstatus probe body\r\n'));
150
+
151
+ const folders = await client.list({ statusQuery: { messages: true, unseen: true } });
152
+
153
+ test.ok(clientSent(logs, 'STATUS'), 'LIST should carry RETURN (STATUS ...)');
154
+ const inbox = folders.find(folder => folder.path === 'INBOX');
155
+ test.equal(inbox.status.messages, 1, 'inline STATUS should report the appended message');
156
+ } finally {
157
+ await client.logout();
158
+ }
159
+ test.done();
160
+ };
161
+
162
+ module.exports['Live rev2: plain search results are collected'] = async test => {
163
+ const logs = [];
164
+ const client = await connectClient(null, logs);
165
+ try {
166
+ await client.append('INBOX', Buffer.from('Subject: first\r\n\r\nfirst\r\n'));
167
+ await client.append('INBOX', Buffer.from('Subject: second\r\n\r\nsecond\r\n'));
168
+
169
+ await client.mailboxOpen('INBOX');
170
+ const results = await client.search({ all: true });
171
+
172
+ // RFC 9051 deprecates the untagged SEARCH response in favor of ESEARCH,
173
+ // but Dovecot 2.4 still answers a plain SEARCH with the legacy form even
174
+ // on an ENABLEd rev2 session (verified on the wire). The client accepts
175
+ // both forms, so assert that the results arrived via one of them - the
176
+ // ESEARCH-answered variant of a plain SEARCH is covered by the mock
177
+ // suite, and a real ESEARCH response is exercised live in the
178
+ // returnOptions test below.
179
+ test.ok(serverSentUntagged(logs, 'SEARCH') || serverSentUntagged(logs, 'ESEARCH'), 'results should arrive via an untagged SEARCH or ESEARCH response');
180
+ test.deepEqual(results, [1, 2]);
181
+ } finally {
182
+ await client.logout();
183
+ }
184
+ test.done();
185
+ };
186
+
187
+ module.exports['Live rev2: returnOptions search is answered via a real ESEARCH response'] = async test => {
188
+ const logs = [];
189
+ const client = await connectClient(null, logs);
190
+ try {
191
+ await client.append('INBOX', Buffer.from('Subject: first\r\n\r\nfirst\r\n'));
192
+ await client.append('INBOX', Buffer.from('Subject: second\r\n\r\nsecond\r\n'));
193
+
194
+ await client.mailboxOpen('INBOX');
195
+ const result = await client.search({ all: true }, { returnOptions: ['ALL', 'COUNT'] });
196
+
197
+ // SEARCH RETURN (...) makes Dovecot answer with a genuine untagged
198
+ // ESEARCH response - this exercises the ESEARCH parsing path against a
199
+ // real server
200
+ test.ok(serverSentUntagged(logs, 'ESEARCH'), 'server should reply with an untagged ESEARCH response');
201
+ test.ok(!serverSentUntagged(logs, 'SEARCH'), 'no legacy untagged SEARCH response is expected');
202
+ test.equal(result.count, 2);
203
+ test.equal(result.all, '1:2');
204
+ } finally {
205
+ await client.logout();
206
+ }
207
+ test.done();
208
+ };
209
+
210
+ module.exports['Live rev2: message lifecycle smoke test'] = async test => {
211
+ const client = await connectClient();
212
+ try {
213
+ await client.append('INBOX', Buffer.from('Subject: smoke\r\n\r\nsmoke body\r\n'), ['\\Seen']);
214
+
215
+ await client.mailboxOpen('INBOX');
216
+ const message = await client.fetchOne('1', { envelope: true, flags: true, uid: true });
217
+ test.equal(message.envelope.subject, 'smoke');
218
+ test.ok(message.flags.has('\\Seen'));
219
+
220
+ await client.mailboxCreate('Smoke');
221
+ await client.messageCopy('1', 'Smoke');
222
+ await client.messageMove('1', 'Smoke');
223
+
224
+ const status = await client.status('Smoke', { messages: true });
225
+ test.equal(status.messages, 2, 'copy plus move should land two messages');
226
+
227
+ // uid expunge - UIDPLUS is folded into base rev2, so only the requested
228
+ // message may disappear
229
+ await client.mailboxOpen('Smoke');
230
+ const first = await client.fetchOne('1', { uid: true });
231
+ await client.messageDelete(`${first.uid}`, { uid: true });
232
+ const statusAfter = await client.status('Smoke', { messages: true });
233
+ test.equal(statusAfter.messages, 1, 'UID EXPUNGE must remove only the targeted message');
234
+
235
+ await client.mailboxClose();
236
+ await client.mailboxRename('Smoke', 'Smoke2');
237
+ await client.mailboxDelete('Smoke2');
238
+ } finally {
239
+ await client.logout();
240
+ }
241
+ test.done();
242
+ };
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Runs the ImapFlow live integration tests against a real IMAP4rev2 server
5
+ # (Dovecot 2.4+ in Docker). Opt-in via `npm run test:rev2` - not part of the
6
+ # regular `npm test` run, which stays Docker-free.
7
+ #
8
+ # Environment overrides:
9
+ # IMAPFLOW_DOVECOT_IMAGE image to run (default dovecot/dovecot:2.4.4)
10
+ # IMAPFLOW_DOVECOT_PLATFORM e.g. linux/amd64; defaults to the host platform.
11
+ # Note: forcing linux/amd64 on Apple Silicon does
12
+ # not work - Rosetta cannot start Dovecot's
13
+ # privilege-separated login processes.
14
+ # IMAPFLOW_TEST_PORT host port to publish (default 31143)
15
+
16
+ CONTAINER_NAME="${IMAPFLOW_DOVECOT_CONTAINER:-imapflow-rev2-test}"
17
+ IMAGE="${IMAPFLOW_DOVECOT_IMAGE:-dovecot/dovecot:2.4.4}"
18
+ PORT="${IMAPFLOW_TEST_PORT:-31143}"
19
+
20
+ # Empty unless a platform override was requested; --platform=value keeps it a
21
+ # single argument so plain ${PLATFORM_ARG:+...} expansion works under set -u
22
+ PLATFORM_ARG="${IMAPFLOW_DOVECOT_PLATFORM:+--platform=$IMAPFLOW_DOVECOT_PLATFORM}"
23
+
24
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
25
+ PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
26
+
27
+ cleanup() {
28
+ docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
29
+ }
30
+ trap cleanup EXIT
31
+ cleanup
32
+
33
+ docker run ${PLATFORM_ARG:+"$PLATFORM_ARG"} -d --name "$CONTAINER_NAME" \
34
+ -e USER_PASSWORD=pass \
35
+ -v "$SCRIPT_DIR/dovecot-test.conf:/etc/dovecot/conf.d/99-imapflow-test.conf:ro" \
36
+ -p "127.0.0.1:$PORT:31143" \
37
+ "$IMAGE" >/dev/null
38
+
39
+ echo "Waiting for Dovecot to accept IMAP connections on port $PORT..."
40
+ for i in $(seq 1 30); do
41
+ if node -e "
42
+ const net = require('net');
43
+ const socket = net.connect(Number(process.argv[1]), '127.0.0.1');
44
+ const bail = code => { socket.destroy(); process.exit(code); };
45
+ socket.on('data', chunk => bail(chunk.toString().startsWith('* OK') ? 0 : 1));
46
+ socket.on('error', () => bail(1));
47
+ setTimeout(() => bail(1), 2000);
48
+ " "$PORT" 2>/dev/null; then
49
+ echo "Dovecot is ready"
50
+ break
51
+ fi
52
+ if [ "$i" = 30 ]; then
53
+ echo "Dovecot container did not become ready" >&2
54
+ docker logs "$CONTAINER_NAME" >&2 || true
55
+ exit 1
56
+ fi
57
+ sleep 1
58
+ done
59
+
60
+ cd "$PROJECT_DIR"
61
+ IMAPFLOW_TEST_HOST=127.0.0.1 IMAPFLOW_TEST_PORT="$PORT" npx nodeunit test/integration/rev2-live-test.js
@@ -204,7 +204,10 @@ module.exports['Reliability: per-call maxLockHoldTime overrides constructor opti
204
204
  let lock = await client.getMailboxLock('INBOX', { maxLockHoldTime: 20 });
205
205
  await new Promise(r => setTimeout(r, 50));
206
206
 
207
- test.ok(warnings.some(w => w && w.msg === 'Mailbox lock held for a long time'), 'Per-call override must take effect');
207
+ test.ok(
208
+ warnings.some(w => w && w.msg === 'Mailbox lock held for a long time'),
209
+ 'Per-call override must take effect'
210
+ );
208
211
 
209
212
  lock.release();
210
213
  await drain();
@@ -191,6 +191,25 @@ module.exports['Search Compiler: RECENT flag'] = test => {
191
191
  test.done();
192
192
  };
193
193
 
194
+ module.exports['Search Compiler: NEW/OLD/RECENT throw on rev2 sessions'] = test => {
195
+ // IMAP4rev2 (RFC 9051) removed the \Recent flag and these search keys -
196
+ // a rev2 server would reject the whole search with a tagged BAD
197
+ let connection = createMockConnection({ enabled: ['IMAP4REV2'] });
198
+ for (let key of ['new', 'old', 'recent']) {
199
+ try {
200
+ searchCompiler(connection, { [key]: true });
201
+ test.ok(false, `Should have thrown for ${key}`);
202
+ } catch (err) {
203
+ test.equal(err.code, 'MissingServerExtension');
204
+ }
205
+ }
206
+ // Falsy values compile to nothing and must not throw
207
+ test.equal(searchCompiler(connection, { recent: false }).length, 0);
208
+ // ALL is still part of the rev2 grammar
209
+ test.ok(hasAttr(searchCompiler(connection, { all: true }), 'ALL'));
210
+ test.done();
211
+ };
212
+
194
213
  module.exports['Search Compiler: Simple flags ignored when falsy'] = test => {
195
214
  let connection = createMockConnection();
196
215
  let compiled = searchCompiler(connection, {