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.
- package/.github/workflows/test.yml +20 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/CLAUDE.md +12 -5
- package/Gruntfile.js +3 -1
- package/lib/commands/authenticate.js +8 -3
- package/lib/commands/enable.js +13 -4
- package/lib/commands/expunge.js +2 -2
- package/lib/commands/fetch.js +18 -14
- package/lib/commands/idle.js +6 -3
- package/lib/commands/list.js +241 -61
- 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 +19 -26
- package/lib/handler/imap-compiler.js +12 -9
- package/lib/handler/token-parser.js +7 -0
- package/lib/imap-flow.d.ts +19 -3
- package/lib/imap-flow.js +58 -9
- package/lib/search-compiler.js +15 -1
- package/lib/tools.js +173 -9
- package/package.json +3 -2
- package/test/commands-branches-test.js +11 -4
- package/test/commands-integration-test.js +1528 -108
- package/test/connection-edge-cases-test.js +4 -40
- package/test/fixtures/test-tls.js +2 -2
- package/test/handler-branches-test.js +4 -3
- package/test/imap-compiler-test.js +85 -0
- package/test/imap-flow-coverage-test.js +8 -1
- package/test/imap-flow-fetch-download-test.js +57 -4
- package/test/imap-flow-internals-test.js +2 -2
- package/test/imap-flow-methods-test.js +65 -6
- package/test/imap-flow-secure-test.js +25 -11
- package/test/imap-flow-server-test.js +80 -0
- package/test/imap-parser-test.js +113 -3
- package/test/imap-stream-test.js +46 -0
- package/test/integration/README.md +52 -0
- package/test/integration/dovecot-test.conf +27 -0
- package/test/integration/rev2-live-test.js +367 -0
- package/test/integration/run-rev2-tests.sh +75 -0
- package/test/reliability-improvements-test.js +4 -1
- package/test/search-compiler-test.js +36 -0
- package/test/search-test.js +52 -54
- 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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
auth: { user: 'test', pass: 'test' }
|
|
616
|
-
});
|
|
617
|
-
|
|
618
|
-
// Mock capabilities
|
|
619
|
-
client.capabilities = new Map();
|
|
620
|
-
client.capabilities.set('STARTTLS', true);
|
|
621
|
-
|
|
622
|
-
// Mock run method
|
|
623
|
-
client.run = async command => {
|
|
624
|
-
if (command === 'STARTTLS') {
|
|
625
|
-
client.expectCapabilityUpdate = true;
|
|
626
|
-
return true;
|
|
627
|
-
}
|
|
628
|
-
if (command === 'CAPABILITY') {
|
|
629
|
-
return true;
|
|
630
|
-
}
|
|
631
|
-
};
|
|
632
|
-
|
|
633
|
-
// Mock socket upgrade
|
|
634
|
-
client.socket = new EventEmitter();
|
|
635
|
-
client.socket.unpipe = () => {};
|
|
636
|
-
client.streamer = new EventEmitter();
|
|
637
|
-
|
|
638
|
-
// Override the upgradeToSTARTTLS to test capability update
|
|
639
|
-
client
|
|
640
|
-
.upgradeToSTARTTLS()
|
|
641
|
-
.then(result => {
|
|
642
|
-
test.ok(result, 'Should successfully upgrade to TLS');
|
|
643
|
-
test.ok(client.expectCapabilityUpdate, 'Should expect capability update');
|
|
644
|
-
test.done();
|
|
645
|
-
})
|
|
646
|
-
.catch(() => {
|
|
647
|
-
// Expected for this mock setup
|
|
648
|
-
test.done();
|
|
649
|
-
});
|
|
650
|
-
};
|
|
611
|
+
// NB! the capability discard/re-fetch behavior around STARTTLS is asserted end to
|
|
612
|
+
// end in imap-flow-secure-test.js ('Secure: STARTTLS upgrade completes a session',
|
|
613
|
+
// PRETLS-ONLY/POSTTLS-ONLY markers) - a mocked upgradeToSTARTTLS cannot reach the
|
|
614
|
+
// re-fetch code without a real TLS handshake, so no vacuous variant is kept here.
|
|
651
615
|
|
|
652
616
|
module.exports['Connection Edge: Event handlers attached before piping'] = test => {
|
|
653
617
|
let client = new ImapFlow({
|
|
@@ -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
|
};
|
|
@@ -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
|
|
119
|
-
//
|
|
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 {
|
|
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: [
|
|
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 = {
|
|
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
|
+
};
|
|
@@ -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
|
-
|
|
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 ${
|
|
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(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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: {
|