imapflow 1.4.7 → 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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +16 -0
- package/CLAUDE.md +10 -1
- package/Gruntfile.js +3 -1
- package/lib/commands/authenticate.js +13 -1
- package/lib/commands/enable.js +13 -4
- package/lib/commands/expunge.js +2 -2
- package/lib/commands/idle.js +6 -3
- package/lib/commands/list.js +237 -60
- package/lib/commands/move.js +2 -2
- package/lib/commands/namespace.js +3 -1
- package/lib/commands/search.js +88 -13
- package/lib/commands/status.js +13 -25
- package/lib/imap-flow.d.ts +5 -3
- package/lib/imap-flow.js +43 -9
- package/lib/search-compiler.js +15 -1
- package/lib/tools.js +141 -9
- package/package.json +7 -6
- package/test/commands-branches-test.js +11 -4
- package/test/commands-integration-test.js +1270 -124
- package/test/connection-edge-cases-test.js +2 -2
- package/test/connection-test.js +1 -1
- package/test/fixtures/test-tls.js +2 -2
- package/test/imap-flow-coverage-test.js +8 -1
- package/test/imap-flow-fetch-download-test.js +1 -4
- package/test/imap-flow-internals-test.js +2 -2
- package/test/imap-flow-methods-test.js +65 -6
- package/test/imap-parser-test.js +1 -2
- package/test/integration/README.md +40 -0
- package/test/integration/dovecot-test.conf +27 -0
- package/test/integration/rev2-live-test.js +242 -0
- package/test/integration/run-rev2-tests.sh +61 -0
- package/test/reliability-improvements-test.js +4 -1
- package/test/search-compiler-test.js +19 -0
- package/test/search-test.js +52 -54
- package/test/tools-test.js +134 -15
- package/.github/codeql/codeql-config.yml +0 -12
- package/.github/workflows/codeql.yml +0 -102
|
@@ -143,7 +143,7 @@ module.exports['Connection Edge: Connection with default values'] = test => {
|
|
|
143
143
|
});
|
|
144
144
|
|
|
145
145
|
test.ok(client, 'Client should be created with defaults');
|
|
146
|
-
test.equal(client.port,
|
|
146
|
+
test.equal(client.port, 143, 'Should use default port');
|
|
147
147
|
test.equal(client.secureConnection, false, 'Should default to non-secure');
|
|
148
148
|
test.done();
|
|
149
149
|
};
|
|
@@ -155,7 +155,7 @@ module.exports['Connection Edge: Port number handling'] = test => {
|
|
|
155
155
|
secure: false,
|
|
156
156
|
auth: { user: 'test', pass: 'test' }
|
|
157
157
|
});
|
|
158
|
-
test.equal(client1.port,
|
|
158
|
+
test.equal(client1.port, 143, 'Default non-secure port');
|
|
159
159
|
|
|
160
160
|
let client2 = new ImapFlow({
|
|
161
161
|
host: 'imap.example.com',
|
package/test/connection-test.js
CHANGED
|
@@ -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:
|
|
7
|
-
key:
|
|
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: [
|
|
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 = {
|
|
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 = {
|
|
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
|
+
};
|
package/test/imap-parser-test.js
CHANGED
|
@@ -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(
|
|
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, {
|