imapflow 1.6.4 → 1.6.6
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 +14 -0
- package/lib/commands/append.js +26 -4
- package/lib/commands/compress.js +29 -18
- package/lib/commands/copyuid-parser.js +4 -2
- package/lib/commands/expunge.js +5 -2
- package/lib/commands/fetch.js +9 -3
- package/lib/commands/list.js +18 -38
- package/lib/commands/namespace.js +2 -2
- package/lib/commands/quota.js +10 -2
- package/lib/commands/search.js +54 -14
- package/lib/commands/select.js +81 -70
- package/lib/commands/status-fields.js +68 -0
- package/lib/commands/status.js +23 -61
- package/lib/handler/imap-compiler.js +91 -60
- package/lib/handler/imap-parser.js +7 -0
- package/lib/handler/imap-stream.js +78 -12
- package/lib/handler/limits.js +16 -4
- package/lib/imap-flow.d.ts +22 -2
- package/lib/imap-flow.js +209 -94
- package/lib/jp-decoder.js +30 -5
- package/lib/limited-passthrough.js +19 -1
- package/lib/search-compiler.js +24 -16
- package/lib/tools.js +190 -39
- package/package.json +4 -4
- package/test/commands-branches-test.js +4 -0
- package/test/commands-integration-test.js +780 -5
- package/test/copyuid-parser-test.js +20 -0
- package/test/idle-polling-test.js +81 -0
- package/test/imap-compiler-test.js +74 -4
- package/test/imap-flow-coverage-test.js +4 -2
- package/test/imap-flow-fetch-download-test.js +26 -0
- package/test/imap-flow-internals-test.js +134 -0
- package/test/imap-flow-methods-test.js +92 -0
- package/test/imap-flow-secure-test.js +133 -116
- package/test/imap-flow-server-test.js +126 -0
- package/test/imap-parser-test.js +25 -0
- package/test/imap-stream-edge-cases-test.js +163 -3
- package/test/integration/rev2-live-test.js +30 -0
- package/test/jp-decoder-test.js +57 -0
- package/test/limited-passthrough-test.js +24 -0
- package/test/parser-limits-test.js +18 -0
- package/test/reliability-improvements-test.js +3 -3
- package/test/search-compiler-test.js +90 -3
- package/test/timer-policy-test.js +27 -1
- package/test/tools-test.js +151 -2
|
@@ -59,6 +59,10 @@ const createMockConnection = (overrides = {}) => {
|
|
|
59
59
|
skipListAuxArgs: false,
|
|
60
60
|
skipLsub: false,
|
|
61
61
|
messageFlagsAdd: overrides.messageFlagsAdd || (async () => {}),
|
|
62
|
+
// Mirrors ImapFlow.throttleWait(): resolves false on normal expiry, true when close()
|
|
63
|
+
// aborted the wait. The mock resolves immediately so throttle retries stay fast.
|
|
64
|
+
throttleWait: overrides.throttleWait || (async () => false),
|
|
65
|
+
createNoConnectionError: overrides.createNoConnectionError || (() => Object.assign(new Error('Connection not available'), { code: 'NoConnection' })),
|
|
62
66
|
run: overrides.run || (async () => {}),
|
|
63
67
|
// Mirrors ImapFlow.runInternal(): dispatch through the command registry without the
|
|
64
68
|
// preCheck/auto-IDLE handshake that run() performs, so a fallback poll runs the real
|
|
@@ -2910,6 +2914,40 @@ module.exports['Commands: compress handles error'] = async test => {
|
|
|
2910
2914
|
test.done();
|
|
2911
2915
|
};
|
|
2912
2916
|
|
|
2917
|
+
module.exports['Commands: compress fails the connection on trailing data'] = async test => {
|
|
2918
|
+
// Per RFC 4978 the server switches to DEFLATE at its tagged OK, so data already
|
|
2919
|
+
// buffered behind the OK was consumed as cleartext and the deflate stream is
|
|
2920
|
+
// truncated. Declining the upgrade is not a protocol option at that point - the
|
|
2921
|
+
// session is unrecoverable in both directions and must fail closed.
|
|
2922
|
+
let closeAfterCalled = false;
|
|
2923
|
+
let nextCalled = false;
|
|
2924
|
+
const connection = createMockConnection({
|
|
2925
|
+
capabilities: new Map([['COMPRESS=DEFLATE', true]]),
|
|
2926
|
+
closeAfter: () => {
|
|
2927
|
+
closeAfterCalled = true;
|
|
2928
|
+
},
|
|
2929
|
+
exec: async () => ({
|
|
2930
|
+
hasTrailingData: true,
|
|
2931
|
+
next: () => {
|
|
2932
|
+
nextCalled = true;
|
|
2933
|
+
test.ok(closeAfterCalled, 'teardown must be scheduled before parser backpressure is released');
|
|
2934
|
+
}
|
|
2935
|
+
})
|
|
2936
|
+
});
|
|
2937
|
+
|
|
2938
|
+
let err = null;
|
|
2939
|
+
try {
|
|
2940
|
+
await compressCommand(connection);
|
|
2941
|
+
} catch (e) {
|
|
2942
|
+
err = e;
|
|
2943
|
+
}
|
|
2944
|
+
test.ok(err, 'compress must throw');
|
|
2945
|
+
test.equal(err && err.code, 'COMPRESS_TRAILING_DATA');
|
|
2946
|
+
test.ok(closeAfterCalled, 'the connection must be closed');
|
|
2947
|
+
test.ok(nextCalled, 'parser backpressure must still be released');
|
|
2948
|
+
test.done();
|
|
2949
|
+
};
|
|
2950
|
+
|
|
2913
2951
|
// ============================================
|
|
2914
2952
|
// STARTTLS Command Tests
|
|
2915
2953
|
// ============================================
|
|
@@ -3895,6 +3933,52 @@ module.exports['Commands: list runs separate INBOX query when using namespace']
|
|
|
3895
3933
|
test.done();
|
|
3896
3934
|
};
|
|
3897
3935
|
|
|
3936
|
+
module.exports['Commands: list INBOX fixup propagates non-reducible failures'] = async test => {
|
|
3937
|
+
// Both sides of the fixup's retry guard: a NO on the extended call is an operational
|
|
3938
|
+
// failure (not a RETURN options rejection), and a BAD on a call that already ran plain
|
|
3939
|
+
// has no options left to reduce - neither may trigger the plain retry
|
|
3940
|
+
for (let { capabilities, status } of [
|
|
3941
|
+
{
|
|
3942
|
+
capabilities: [
|
|
3943
|
+
['IMAP4rev1', true],
|
|
3944
|
+
['LIST-EXTENDED', true]
|
|
3945
|
+
],
|
|
3946
|
+
status: 'NO'
|
|
3947
|
+
},
|
|
3948
|
+
{ capabilities: [['IMAP4rev1', true]], status: 'BAD' }
|
|
3949
|
+
]) {
|
|
3950
|
+
let listCalls = 0;
|
|
3951
|
+
let lsubCalls = 0;
|
|
3952
|
+
const connection = createMockConnection({
|
|
3953
|
+
state: 3,
|
|
3954
|
+
capabilities: new Map(capabilities),
|
|
3955
|
+
exec: async (cmd, attrs) => {
|
|
3956
|
+
if (cmd === 'LSUB') {
|
|
3957
|
+
lsubCalls++;
|
|
3958
|
+
}
|
|
3959
|
+
if (cmd === 'LIST') {
|
|
3960
|
+
listCalls++;
|
|
3961
|
+
if (attrs[1] === 'INBOX') {
|
|
3962
|
+
throw commandError('Command failed', status);
|
|
3963
|
+
}
|
|
3964
|
+
}
|
|
3965
|
+
return { next: () => {} };
|
|
3966
|
+
}
|
|
3967
|
+
});
|
|
3968
|
+
|
|
3969
|
+
try {
|
|
3970
|
+
await listCommand(connection, 'Mail/', '*');
|
|
3971
|
+
test.ok(false, 'Should have thrown');
|
|
3972
|
+
} catch (err) {
|
|
3973
|
+
test.equal(err.responseStatus, status);
|
|
3974
|
+
}
|
|
3975
|
+
// Main listing and the failed INBOX call - no plain retry, no LSUB merge
|
|
3976
|
+
test.equal(listCalls, 2);
|
|
3977
|
+
test.equal(lsubCalls, 0);
|
|
3978
|
+
}
|
|
3979
|
+
test.done();
|
|
3980
|
+
};
|
|
3981
|
+
|
|
3898
3982
|
module.exports['Commands: list handles LSUB merging'] = async test => {
|
|
3899
3983
|
const connection = createMockConnection({
|
|
3900
3984
|
state: 3,
|
|
@@ -4851,18 +4935,28 @@ module.exports['Commands: list discards partial results from a rejected INBOX fi
|
|
|
4851
4935
|
state: 3,
|
|
4852
4936
|
capabilities: new Map([
|
|
4853
4937
|
['IMAP4rev1', true],
|
|
4854
|
-
['LIST-EXTENDED', true]
|
|
4938
|
+
['LIST-EXTENDED', true],
|
|
4939
|
+
['SPECIAL-USE', true]
|
|
4855
4940
|
]),
|
|
4856
4941
|
exec: async (cmd, attrs, opts) => {
|
|
4857
4942
|
if (cmd === 'LIST') {
|
|
4858
4943
|
listAttempts.push(JSON.stringify(attrs));
|
|
4859
4944
|
if (listAttempts.length === 1) {
|
|
4860
4945
|
await opts.untagged.LIST({
|
|
4861
|
-
attributes: [
|
|
4946
|
+
attributes: [
|
|
4947
|
+
[{ value: '\\Subscribed' }, { value: '\\HasNoChildren' }, { value: '\\Sent' }],
|
|
4948
|
+
{ value: '.' },
|
|
4949
|
+
{ value: 'Prefix.Folder1' }
|
|
4950
|
+
]
|
|
4862
4951
|
});
|
|
4863
4952
|
} else if (listAttempts.length === 2) {
|
|
4864
|
-
// The fixup attempt streams
|
|
4865
|
-
//
|
|
4953
|
+
// The fixup attempt streams untagged lines and THEN gets the tagged
|
|
4954
|
+
// BAD - the partial lines must not survive the retry. "Aliased" also
|
|
4955
|
+
// claims \Sent and sorts ahead of the main run's Prefix.Folder1, so
|
|
4956
|
+
// it would steal the special-use slot if it were not rolled back
|
|
4957
|
+
await opts.untagged.LIST({
|
|
4958
|
+
attributes: [[{ value: '\\Sent' }], { value: '.' }, { value: 'Aliased' }]
|
|
4959
|
+
});
|
|
4866
4960
|
await opts.untagged.LIST({
|
|
4867
4961
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '.' }, { value: 'INBOX' }]
|
|
4868
4962
|
});
|
|
@@ -4881,7 +4975,9 @@ module.exports['Commands: list discards partial results from a rejected INBOX fi
|
|
|
4881
4975
|
test.equal(listAttempts.length, 3);
|
|
4882
4976
|
// Exactly one INBOX entry - the rejected attempt's partial line was discarded
|
|
4883
4977
|
test.equal(result.filter(e => e.path === 'INBOX').length, 1);
|
|
4884
|
-
test.ok(result.
|
|
4978
|
+
test.ok(!result.some(e => e.path === 'Aliased'), 'the rejected attempt entry is gone');
|
|
4979
|
+
// The main run's special-use match survived the rollback of the rejected attempt
|
|
4980
|
+
test.equal(result.find(e => e.path === 'Prefix.Folder1').specialUse, '\\Sent');
|
|
4885
4981
|
test.done();
|
|
4886
4982
|
};
|
|
4887
4983
|
|
|
@@ -10347,3 +10443,682 @@ module.exports['Commands: downloadMany with fetchOne returning null'] = async te
|
|
|
10347
10443
|
test.equal(result.response, false);
|
|
10348
10444
|
test.done();
|
|
10349
10445
|
};
|
|
10446
|
+
|
|
10447
|
+
// ============================================
|
|
10448
|
+
// Security regression tests: hostile server input
|
|
10449
|
+
// ============================================
|
|
10450
|
+
|
|
10451
|
+
module.exports['Commands: quota ignores prototype-chain and fixed-field resource names'] = async test => {
|
|
10452
|
+
const connection = createMockConnection({
|
|
10453
|
+
state: 2,
|
|
10454
|
+
capabilities: new Map([['QUOTA', true]]),
|
|
10455
|
+
exec: async (cmd, args, opts) => {
|
|
10456
|
+
if (opts && opts.untagged && opts.untagged.QUOTA) {
|
|
10457
|
+
await opts.untagged.QUOTA({
|
|
10458
|
+
attributes: [
|
|
10459
|
+
{ value: '' },
|
|
10460
|
+
[
|
|
10461
|
+
{ value: '__PROTO__' },
|
|
10462
|
+
{ value: '10' },
|
|
10463
|
+
{ value: '100' },
|
|
10464
|
+
{ value: 'CONSTRUCTOR' },
|
|
10465
|
+
{ value: '1' },
|
|
10466
|
+
{ value: '2' },
|
|
10467
|
+
{ value: 'PATH' },
|
|
10468
|
+
{ value: '3' },
|
|
10469
|
+
{ value: '4' },
|
|
10470
|
+
{ value: 'STORAGE' },
|
|
10471
|
+
{ value: '250' },
|
|
10472
|
+
{ value: '500' }
|
|
10473
|
+
]
|
|
10474
|
+
]
|
|
10475
|
+
});
|
|
10476
|
+
}
|
|
10477
|
+
return { next: () => {} };
|
|
10478
|
+
}
|
|
10479
|
+
});
|
|
10480
|
+
|
|
10481
|
+
const result = await quotaCommand(connection, 'INBOX');
|
|
10482
|
+
// Capture before cleaning up: deleting first would erase exactly the evidence the
|
|
10483
|
+
// assertion looks for, and the test would then pass with the guard removed. The cleanup
|
|
10484
|
+
// still has to happen so a regression cannot leak into the rest of the suite.
|
|
10485
|
+
let leaked = ['usage', 'limit', 'status'].filter(key => Object.hasOwn(Object.prototype, key));
|
|
10486
|
+
delete Object.prototype.usage;
|
|
10487
|
+
delete Object.prototype.limit;
|
|
10488
|
+
delete Object.prototype.status;
|
|
10489
|
+
test.deepEqual(leaked, [], 'Object.prototype must stay clean');
|
|
10490
|
+
test.equal(result.path, 'INBOX', 'the fixed path field must not be overwritten');
|
|
10491
|
+
test.ok(!Object.prototype.hasOwnProperty.call(result, 'constructor'));
|
|
10492
|
+
test.equal(result.storage.usage, 250 * 1024);
|
|
10493
|
+
test.equal(result.storage.limit, 500 * 1024);
|
|
10494
|
+
test.done();
|
|
10495
|
+
};
|
|
10496
|
+
|
|
10497
|
+
module.exports['Commands: select ignores unknown response codes on the mailbox object'] = async test => {
|
|
10498
|
+
const connection = createMockConnection({
|
|
10499
|
+
state: 2, // AUTHENTICATED
|
|
10500
|
+
folders: new Map([['INBOX', { path: 'INBOX', delimiter: '/' }]]),
|
|
10501
|
+
run: async () => [],
|
|
10502
|
+
exec: async (cmd, attrs, opts) => {
|
|
10503
|
+
if (opts && opts.untagged) {
|
|
10504
|
+
if (opts.untagged.OK) {
|
|
10505
|
+
// A response code the client does not know must not become a mailbox
|
|
10506
|
+
// property: "PATH" would otherwise overwrite mailbox.path (defeating the
|
|
10507
|
+
// DELETE/RENAME guards), and "__PROTO__" with a list value would replace
|
|
10508
|
+
// the object's prototype
|
|
10509
|
+
await opts.untagged.OK({
|
|
10510
|
+
attributes: [{ section: [{ type: 'ATOM', value: 'PATH' }, { value: 'INBOX.evil' }] }]
|
|
10511
|
+
});
|
|
10512
|
+
await opts.untagged.OK({
|
|
10513
|
+
attributes: [{ section: [{ type: 'ATOM', value: '__PROTO__' }, [{ value: 'polluted' }]] }]
|
|
10514
|
+
});
|
|
10515
|
+
await opts.untagged.OK({
|
|
10516
|
+
attributes: [{ section: [{ type: 'ATOM', value: 'UIDNEXT' }, { value: '1000' }] }]
|
|
10517
|
+
});
|
|
10518
|
+
// malformed codes must not crash the handler or corrupt state either:
|
|
10519
|
+
// a NIL key, a NIL value, and a UIDNEXT digit run that overflows to Infinity
|
|
10520
|
+
await opts.untagged.OK({
|
|
10521
|
+
attributes: [{ section: [null] }]
|
|
10522
|
+
});
|
|
10523
|
+
await opts.untagged.OK({
|
|
10524
|
+
attributes: [{ section: [{ type: 'ATOM', value: 'UIDNEXT' }, null] }]
|
|
10525
|
+
});
|
|
10526
|
+
await opts.untagged.OK({
|
|
10527
|
+
attributes: [{ section: [{ type: 'ATOM', value: 'UIDNEXT' }, { value: '9'.repeat(400) }] }]
|
|
10528
|
+
});
|
|
10529
|
+
}
|
|
10530
|
+
if (opts.untagged.EXISTS) {
|
|
10531
|
+
// an overflowing count must be ignored, not stored as Infinity
|
|
10532
|
+
await opts.untagged.EXISTS({ command: '9'.repeat(400) });
|
|
10533
|
+
}
|
|
10534
|
+
}
|
|
10535
|
+
return {
|
|
10536
|
+
next: () => {},
|
|
10537
|
+
response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] }
|
|
10538
|
+
};
|
|
10539
|
+
},
|
|
10540
|
+
emit: () => {}
|
|
10541
|
+
});
|
|
10542
|
+
|
|
10543
|
+
const result = await selectCommand(connection, 'INBOX');
|
|
10544
|
+
test.equal(result.path, 'INBOX', 'a PATH response code must not overwrite mailbox.path');
|
|
10545
|
+
test.equal(result.uidNext, 1000, 'malformed UIDNEXT values must not replace a good one');
|
|
10546
|
+
test.equal(result.exists, undefined, 'an overflowing EXISTS count must be ignored');
|
|
10547
|
+
test.equal(result.polluted, undefined);
|
|
10548
|
+
// A "__proto__" key with a list value replaces the object's prototype rather than creating
|
|
10549
|
+
// an own property, so the own-property check alone would hold with the guard removed too -
|
|
10550
|
+
// the prototype identity is what actually detects it
|
|
10551
|
+
test.equal(Object.getPrototypeOf(result), Object.prototype, 'the mailbox object must keep its prototype');
|
|
10552
|
+
test.ok(!Object.prototype.hasOwnProperty.call(result, '__proto__'));
|
|
10553
|
+
test.done();
|
|
10554
|
+
};
|
|
10555
|
+
|
|
10556
|
+
module.exports['Commands: downloadMany ignores prototype-chain part keys'] = async test => {
|
|
10557
|
+
// The server chooses the BODY[...] keys in its FETCH answers: without a guard a
|
|
10558
|
+
// "__proto__" key wrote attacker-controlled content onto Object.prototype
|
|
10559
|
+
let client = new ImapFlow({
|
|
10560
|
+
host: 'imap.example.com',
|
|
10561
|
+
port: 993,
|
|
10562
|
+
auth: { user: 'test', pass: 'test' }
|
|
10563
|
+
});
|
|
10564
|
+
client.mailbox = { path: 'INBOX' };
|
|
10565
|
+
client.fetchOne = async () => ({
|
|
10566
|
+
bodyParts: new Map([
|
|
10567
|
+
['__proto__', Buffer.from('polluted')],
|
|
10568
|
+
['2.mime', Buffer.from('Content-Type: text/plain\r\n\r\n')],
|
|
10569
|
+
['2', Buffer.from('real content')]
|
|
10570
|
+
])
|
|
10571
|
+
});
|
|
10572
|
+
|
|
10573
|
+
let result = await client.downloadMany('1', ['2']);
|
|
10574
|
+
// Capture before cleaning up: deleting first would erase exactly the evidence the
|
|
10575
|
+
// assertion looks for, and the test would then pass with the guard removed
|
|
10576
|
+
let leaked = Object.hasOwn(Object.prototype, 'content') || Object.hasOwn(Object.prototype, 'meta');
|
|
10577
|
+
delete Object.prototype.content;
|
|
10578
|
+
delete Object.prototype.meta;
|
|
10579
|
+
test.equal(leaked, false, 'Object.prototype must stay clean');
|
|
10580
|
+
test.ok(result['2']);
|
|
10581
|
+
test.equal(result['2'].content.toString(), 'real content');
|
|
10582
|
+
test.equal(result['2'].meta.contentType, 'text/plain');
|
|
10583
|
+
test.done();
|
|
10584
|
+
};
|
|
10585
|
+
|
|
10586
|
+
// ============================================
|
|
10587
|
+
// Untrusted response value handling
|
|
10588
|
+
// ============================================
|
|
10589
|
+
// Servers control every value in these responses. Each test below pins a value the client
|
|
10590
|
+
// must refuse to store, or a malformed shape it must survive without losing the response.
|
|
10591
|
+
|
|
10592
|
+
const { canUseFlag } = require('../lib/tools.js');
|
|
10593
|
+
|
|
10594
|
+
const selectWithOkCodes = sections =>
|
|
10595
|
+
createMockConnection({
|
|
10596
|
+
state: 2, // AUTHENTICATED
|
|
10597
|
+
folders: new Map([['INBOX', { path: 'INBOX', delimiter: '/' }]]),
|
|
10598
|
+
run: async () => [],
|
|
10599
|
+
exec: async (cmd, attrs, opts) => {
|
|
10600
|
+
if (opts && opts.untagged && opts.untagged.OK) {
|
|
10601
|
+
for (let section of sections) {
|
|
10602
|
+
await opts.untagged.OK({ attributes: [{ section }] });
|
|
10603
|
+
}
|
|
10604
|
+
}
|
|
10605
|
+
return {
|
|
10606
|
+
next: () => {},
|
|
10607
|
+
response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] }
|
|
10608
|
+
};
|
|
10609
|
+
},
|
|
10610
|
+
emit: () => {}
|
|
10611
|
+
});
|
|
10612
|
+
|
|
10613
|
+
module.exports['Commands: select leaves permanentFlags unset for a NIL PERMANENTFLAGS'] = async test => {
|
|
10614
|
+
// An empty Set is not the same as "unset": canUseFlag() treats unset as permissive and an
|
|
10615
|
+
// empty set as deny-all, which would silently turn every later flag update into a no-op
|
|
10616
|
+
const result = await selectCommand(selectWithOkCodes([[{ type: 'ATOM', value: 'PERMANENTFLAGS' }, null]]), 'INBOX');
|
|
10617
|
+
test.equal(result.permanentFlags, undefined, 'a NIL value must not produce an empty flag set');
|
|
10618
|
+
test.ok(canUseFlag(result, '\\Seen'), 'flag updates must stay permitted');
|
|
10619
|
+
test.done();
|
|
10620
|
+
};
|
|
10621
|
+
|
|
10622
|
+
module.exports['Commands: select ignores an unparenthesized PERMANENTFLAGS value'] = async test => {
|
|
10623
|
+
// new Set('\\Seen') would be a set of the individual characters
|
|
10624
|
+
const result = await selectCommand(selectWithOkCodes([[{ type: 'ATOM', value: 'PERMANENTFLAGS' }, { value: '\\Seen' }]]), 'INBOX');
|
|
10625
|
+
test.equal(result.permanentFlags, undefined);
|
|
10626
|
+
test.ok(canUseFlag(result, '\\Seen'));
|
|
10627
|
+
test.done();
|
|
10628
|
+
};
|
|
10629
|
+
|
|
10630
|
+
module.exports['Commands: select survives NIL entries inside a PERMANENTFLAGS list'] = async test => {
|
|
10631
|
+
const result = await selectCommand(
|
|
10632
|
+
selectWithOkCodes([[{ type: 'ATOM', value: 'PERMANENTFLAGS' }, [{ value: '\\Seen' }, null, { value: '\\Draft' }]]]),
|
|
10633
|
+
'INBOX'
|
|
10634
|
+
);
|
|
10635
|
+
test.deepEqual(Array.from(result.permanentFlags), ['\\Seen', '\\Draft']);
|
|
10636
|
+
test.done();
|
|
10637
|
+
};
|
|
10638
|
+
|
|
10639
|
+
module.exports['Commands: select survives NIL entries inside a FLAGS response'] = async test => {
|
|
10640
|
+
const connection = createMockConnection({
|
|
10641
|
+
state: 2,
|
|
10642
|
+
folders: new Map([['INBOX', { path: 'INBOX', delimiter: '/' }]]),
|
|
10643
|
+
run: async () => [],
|
|
10644
|
+
exec: async (cmd, attrs, opts) => {
|
|
10645
|
+
if (opts && opts.untagged && opts.untagged.FLAGS) {
|
|
10646
|
+
await opts.untagged.FLAGS({ attributes: [[{ value: '\\Seen' }, null, { value: '\\Flagged' }]] });
|
|
10647
|
+
}
|
|
10648
|
+
return { next: () => {}, response: { attributes: [] } };
|
|
10649
|
+
},
|
|
10650
|
+
emit: () => {}
|
|
10651
|
+
});
|
|
10652
|
+
|
|
10653
|
+
const result = await selectCommand(connection, 'INBOX');
|
|
10654
|
+
test.deepEqual(Array.from(result.flags), ['\\Seen', '\\Flagged'], 'a NIL entry must not cost the whole flag list');
|
|
10655
|
+
test.done();
|
|
10656
|
+
};
|
|
10657
|
+
|
|
10658
|
+
module.exports['Commands: select handles a tagged OK carrying no response text'] = async test => {
|
|
10659
|
+
// "A1 OK" parses to an object with no `attributes` property at all. Reading through it in
|
|
10660
|
+
// the command body would land in the outer catch, which tears down the mailbox state the
|
|
10661
|
+
// server has actually selected and rejects the caller with a TypeError.
|
|
10662
|
+
const connection = createMockConnection({
|
|
10663
|
+
state: 2,
|
|
10664
|
+
folders: new Map([['INBOX', { path: 'INBOX', delimiter: '/' }]]),
|
|
10665
|
+
run: async () => [],
|
|
10666
|
+
exec: async (cmd, attrs, opts) => {
|
|
10667
|
+
if (opts && opts.untagged && opts.untagged.EXISTS) {
|
|
10668
|
+
await opts.untagged.EXISTS({ command: '42' });
|
|
10669
|
+
}
|
|
10670
|
+
return { next: () => {}, response: { tag: 'A1', command: 'OK' } };
|
|
10671
|
+
},
|
|
10672
|
+
emit: () => {}
|
|
10673
|
+
});
|
|
10674
|
+
|
|
10675
|
+
const result = await selectCommand(connection, 'INBOX');
|
|
10676
|
+
test.equal(result.path, 'INBOX');
|
|
10677
|
+
test.equal(result.exists, 42, 'collected mailbox data must survive');
|
|
10678
|
+
test.equal(result.readOnly, undefined);
|
|
10679
|
+
test.equal(connection.state, connection.states.SELECTED, 'the mailbox must stay selected');
|
|
10680
|
+
test.done();
|
|
10681
|
+
};
|
|
10682
|
+
|
|
10683
|
+
module.exports['Commands: select exposes UNSEEN and APPENDLIMIT'] = async test => {
|
|
10684
|
+
const result = await selectCommand(
|
|
10685
|
+
selectWithOkCodes([
|
|
10686
|
+
[{ type: 'ATOM', value: 'UNSEEN' }, { value: '12' }],
|
|
10687
|
+
[{ type: 'ATOM', value: 'APPENDLIMIT' }, { value: '35651584' }]
|
|
10688
|
+
]),
|
|
10689
|
+
'INBOX'
|
|
10690
|
+
);
|
|
10691
|
+
test.equal(result.unseen, 12);
|
|
10692
|
+
test.equal(result.appendlimit, 35651584);
|
|
10693
|
+
test.done();
|
|
10694
|
+
};
|
|
10695
|
+
|
|
10696
|
+
module.exports['Commands: select drops an unusable HIGHESTMODSEQ instead of storing it raw'] = async test => {
|
|
10697
|
+
// A non-numeric string stored in a BigInt field compares false in both directions, so the
|
|
10698
|
+
// value could never advance again and CONDSTORE/QRESYNC delta sync would stop for good
|
|
10699
|
+
for (let bad of ['none', '1e5', '-1', '', '9'.repeat(20)]) {
|
|
10700
|
+
const result = await selectCommand(selectWithOkCodes([[{ type: 'ATOM', value: 'HIGHESTMODSEQ' }, { value: bad }]]), 'INBOX');
|
|
10701
|
+
test.equal(result.highestModseq, undefined, `HIGHESTMODSEQ ${JSON.stringify(bad)} must be dropped`);
|
|
10702
|
+
}
|
|
10703
|
+
|
|
10704
|
+
const good = await selectCommand(selectWithOkCodes([[{ type: 'ATOM', value: 'HIGHESTMODSEQ' }, { value: '9122' }]]), 'INBOX');
|
|
10705
|
+
test.equal(good.highestModseq, 9122n);
|
|
10706
|
+
test.done();
|
|
10707
|
+
};
|
|
10708
|
+
|
|
10709
|
+
module.exports['Commands: select accepts only decimal UIDNEXT values'] = async test => {
|
|
10710
|
+
for (let bad of ['0x10', '1e3', ' 12 ', '-5', '1.5']) {
|
|
10711
|
+
const result = await selectCommand(selectWithOkCodes([[{ type: 'ATOM', value: 'UIDNEXT' }, { value: bad }]]), 'INBOX');
|
|
10712
|
+
test.equal(result.uidNext, undefined, `UIDNEXT ${JSON.stringify(bad)} must be dropped`);
|
|
10713
|
+
}
|
|
10714
|
+
|
|
10715
|
+
const good = await selectCommand(selectWithOkCodes([[{ type: 'ATOM', value: 'UIDNEXT' }, { value: '1000' }]]), 'INBOX');
|
|
10716
|
+
test.equal(good.uidNext, 1000);
|
|
10717
|
+
test.done();
|
|
10718
|
+
};
|
|
10719
|
+
|
|
10720
|
+
module.exports['Commands: append survives a malformed APPENDUID'] = async test => {
|
|
10721
|
+
// BigInt('1e5') throws where isNaN('1e5') passes, and append rethrows - the message is
|
|
10722
|
+
// already stored at that point, so a retrying caller would duplicate it
|
|
10723
|
+
const connection = createMockConnection({
|
|
10724
|
+
state: 2,
|
|
10725
|
+
folders: new Map([['INBOX', { path: 'INBOX', delimiter: '/' }]]),
|
|
10726
|
+
exec: async () => ({
|
|
10727
|
+
next: () => {},
|
|
10728
|
+
response: {
|
|
10729
|
+
attributes: [
|
|
10730
|
+
{
|
|
10731
|
+
section: [{ type: 'ATOM', value: 'APPENDUID' }, { value: '1e5' }, { value: '7' }]
|
|
10732
|
+
}
|
|
10733
|
+
]
|
|
10734
|
+
}
|
|
10735
|
+
})
|
|
10736
|
+
});
|
|
10737
|
+
|
|
10738
|
+
const result = await appendCommand(connection, 'INBOX', Buffer.from('test'));
|
|
10739
|
+
test.ok(result, 'append must not reject on an unusable APPENDUID');
|
|
10740
|
+
test.equal(result.uidValidity, undefined, 'the unusable uidValidity is dropped');
|
|
10741
|
+
test.equal(result.uid, 7, 'the usable uid is still reported');
|
|
10742
|
+
test.done();
|
|
10743
|
+
};
|
|
10744
|
+
|
|
10745
|
+
module.exports['Commands: append ignores an overflowing EXISTS count'] = async test => {
|
|
10746
|
+
// Number('9'.repeat(400)) is Infinity, and resolveRange('*') would then compile the literal
|
|
10747
|
+
// string "Infinity" into every later range-based command
|
|
10748
|
+
let emitted = [];
|
|
10749
|
+
const connection = createMockConnection({
|
|
10750
|
+
state: 3,
|
|
10751
|
+
mailbox: { path: 'INBOX', exists: 5, flags: new Set(), permanentFlags: new Set(['\\*']) },
|
|
10752
|
+
folders: new Map([['INBOX', { path: 'INBOX', delimiter: '/' }]]),
|
|
10753
|
+
emit: (name, payload) => emitted.push([name, payload]),
|
|
10754
|
+
exec: async (cmd, attrs, opts) => {
|
|
10755
|
+
if (opts && opts.untagged && opts.untagged.EXISTS) {
|
|
10756
|
+
await opts.untagged.EXISTS({ command: '9'.repeat(400) });
|
|
10757
|
+
}
|
|
10758
|
+
return { next: () => {}, response: { attributes: [] } };
|
|
10759
|
+
}
|
|
10760
|
+
});
|
|
10761
|
+
|
|
10762
|
+
await appendCommand(connection, 'INBOX', Buffer.from('test'));
|
|
10763
|
+
test.equal(connection.mailbox.exists, 5, 'the live message count must be left alone');
|
|
10764
|
+
test.equal(emitted.filter(entry => entry[0] === 'exists').length, 0, 'no exists event may be emitted for an unusable count');
|
|
10765
|
+
test.done();
|
|
10766
|
+
};
|
|
10767
|
+
|
|
10768
|
+
module.exports['Commands: expunge survives a malformed HIGHESTMODSEQ'] = async test => {
|
|
10769
|
+
const connection = createMockConnection({
|
|
10770
|
+
state: 3,
|
|
10771
|
+
mailbox: { path: 'INBOX', highestModseq: 100n },
|
|
10772
|
+
exec: async () => ({
|
|
10773
|
+
next: () => {},
|
|
10774
|
+
response: {
|
|
10775
|
+
attributes: [{ section: [{ type: 'ATOM', value: 'HIGHESTMODSEQ' }, { value: '1e5' }] }]
|
|
10776
|
+
}
|
|
10777
|
+
})
|
|
10778
|
+
});
|
|
10779
|
+
|
|
10780
|
+
const result = await expungeCommand(connection, '1:*', {});
|
|
10781
|
+
test.equal(result, true, 'the expunge did happen, so it must not be reported as failed');
|
|
10782
|
+
test.equal(connection.mailbox.highestModseq, 100n, 'the unusable value is not stored');
|
|
10783
|
+
test.done();
|
|
10784
|
+
};
|
|
10785
|
+
|
|
10786
|
+
module.exports['Commands: status skips one malformed field and keeps the rest'] = async test => {
|
|
10787
|
+
const connection = createMockConnection({
|
|
10788
|
+
state: 2,
|
|
10789
|
+
exec: async (cmd, attrs, opts) => {
|
|
10790
|
+
if (opts && opts.untagged && opts.untagged.STATUS) {
|
|
10791
|
+
await opts.untagged.STATUS({
|
|
10792
|
+
attributes: [
|
|
10793
|
+
{ value: 'INBOX' },
|
|
10794
|
+
[{ value: 'MESSAGES' }, { value: '1e5' }, { value: 'UIDNEXT' }, { value: '10' }, { value: 'UIDVALIDITY' }, { value: '99' }]
|
|
10795
|
+
]
|
|
10796
|
+
});
|
|
10797
|
+
}
|
|
10798
|
+
return { next: () => {} };
|
|
10799
|
+
}
|
|
10800
|
+
});
|
|
10801
|
+
|
|
10802
|
+
const result = await statusCommand(connection, 'INBOX', { messages: true, uidNext: true, uidValidity: true });
|
|
10803
|
+
test.equal(result.messages, undefined, 'the unusable field is dropped');
|
|
10804
|
+
test.equal(result.uidNext, 10, 'later fields must still be parsed');
|
|
10805
|
+
test.equal(result.uidValidity, 99n);
|
|
10806
|
+
test.done();
|
|
10807
|
+
};
|
|
10808
|
+
|
|
10809
|
+
module.exports['Commands: status ignores an overflowing MESSAGES count for the selected mailbox'] = async test => {
|
|
10810
|
+
let emitted = [];
|
|
10811
|
+
const connection = createMockConnection({
|
|
10812
|
+
state: 3,
|
|
10813
|
+
mailbox: { path: 'INBOX', exists: 5 },
|
|
10814
|
+
emit: (name, payload) => emitted.push([name, payload]),
|
|
10815
|
+
exec: async (cmd, attrs, opts) => {
|
|
10816
|
+
if (opts && opts.untagged && opts.untagged.STATUS) {
|
|
10817
|
+
await opts.untagged.STATUS({
|
|
10818
|
+
attributes: [{ value: 'INBOX' }, [{ value: 'MESSAGES' }, { value: '9'.repeat(400) }]]
|
|
10819
|
+
});
|
|
10820
|
+
}
|
|
10821
|
+
return { next: () => {} };
|
|
10822
|
+
}
|
|
10823
|
+
});
|
|
10824
|
+
|
|
10825
|
+
const result = await statusCommand(connection, 'INBOX', { messages: true });
|
|
10826
|
+
test.equal(result.messages, undefined);
|
|
10827
|
+
test.equal(connection.mailbox.exists, 5, 'the live message count must be left alone');
|
|
10828
|
+
test.equal(emitted.filter(entry => entry[0] === 'exists').length, 0);
|
|
10829
|
+
test.done();
|
|
10830
|
+
};
|
|
10831
|
+
|
|
10832
|
+
module.exports['Commands: search drops out-of-range values from an untagged SEARCH'] = async test => {
|
|
10833
|
+
// isNaN() passes '1e400' (Infinity), '-3' and '2.5'; a single one of those makes the
|
|
10834
|
+
// sequence set compiled from this result invalid and fails the caller's follow-up command
|
|
10835
|
+
const connection = createMockConnection({
|
|
10836
|
+
state: 3,
|
|
10837
|
+
exec: async (cmd, attrs, opts) => {
|
|
10838
|
+
if (opts && opts.untagged && opts.untagged.SEARCH) {
|
|
10839
|
+
await opts.untagged.SEARCH({
|
|
10840
|
+
attributes: [
|
|
10841
|
+
{ value: '1e400' },
|
|
10842
|
+
{ value: '-3' },
|
|
10843
|
+
{ value: '2.5' },
|
|
10844
|
+
{ value: '0' },
|
|
10845
|
+
null, // a parsed NIL
|
|
10846
|
+
{ value: ['1'] }, // a parenthesized value where a number belongs
|
|
10847
|
+
{ value: '2' },
|
|
10848
|
+
{ value: '7' }
|
|
10849
|
+
]
|
|
10850
|
+
});
|
|
10851
|
+
}
|
|
10852
|
+
return { next: () => {} };
|
|
10853
|
+
}
|
|
10854
|
+
});
|
|
10855
|
+
|
|
10856
|
+
const result = await searchCommand(connection, { all: true }, {});
|
|
10857
|
+
test.deepEqual(result, [2, 7], 'only valid nz-numbers may enter the result set');
|
|
10858
|
+
test.done();
|
|
10859
|
+
};
|
|
10860
|
+
|
|
10861
|
+
module.exports['Commands: downloadMany yields a part that arrived without its MIME headers'] = async test => {
|
|
10862
|
+
// A server may legally answer with fewer items than were requested. One part missing its
|
|
10863
|
+
// companion BODY[<part>.MIME] must not cost the caller the whole download.
|
|
10864
|
+
let client = new ImapFlow({
|
|
10865
|
+
host: 'imap.example.com',
|
|
10866
|
+
port: 993,
|
|
10867
|
+
auth: { user: 'test', pass: 'test' },
|
|
10868
|
+
logger: false
|
|
10869
|
+
});
|
|
10870
|
+
client.mailbox = { path: 'INBOX' };
|
|
10871
|
+
client.fetchOne = async () => ({
|
|
10872
|
+
bodyParts: new Map([['2', Buffer.from('real content')]])
|
|
10873
|
+
});
|
|
10874
|
+
|
|
10875
|
+
let result = await client.downloadMany('1', ['2']);
|
|
10876
|
+
test.equal(result['2'].content.toString(), 'real content');
|
|
10877
|
+
test.deepEqual(result['2'].meta, {}, 'a part with no MIME headers still gets a meta object');
|
|
10878
|
+
test.done();
|
|
10879
|
+
};
|
|
10880
|
+
|
|
10881
|
+
module.exports['Commands: list keeps a LIST-STATUS block when one field is malformed'] = async test => {
|
|
10882
|
+
// BigInt('1e5') throws where isNaN('1e5') passes, and the throw happened before the block
|
|
10883
|
+
// was stored - so one bad field made the whole mailbox's status vanish from the listing
|
|
10884
|
+
const connection = createMockConnection({
|
|
10885
|
+
state: 3,
|
|
10886
|
+
capabilities: new Map([['LIST-STATUS', true]]),
|
|
10887
|
+
exec: async (cmd, attrs, opts) => {
|
|
10888
|
+
if (cmd === 'LIST') {
|
|
10889
|
+
if (opts && opts.untagged && opts.untagged.LIST) {
|
|
10890
|
+
await opts.untagged.LIST({
|
|
10891
|
+
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
|
|
10892
|
+
});
|
|
10893
|
+
}
|
|
10894
|
+
if (opts && opts.untagged && opts.untagged.STATUS) {
|
|
10895
|
+
await opts.untagged.STATUS({
|
|
10896
|
+
attributes: [
|
|
10897
|
+
{ value: 'INBOX' },
|
|
10898
|
+
[{ value: 'HIGHESTMODSEQ' }, { value: '1e5' }, { value: 'MESSAGES' }, { value: '10' }, { value: 'UNSEEN' }, { value: '5' }]
|
|
10899
|
+
]
|
|
10900
|
+
});
|
|
10901
|
+
}
|
|
10902
|
+
}
|
|
10903
|
+
return { next: () => {} };
|
|
10904
|
+
}
|
|
10905
|
+
});
|
|
10906
|
+
|
|
10907
|
+
const result = await listCommand(connection, '', '*', { statusQuery: { messages: true, unseen: true, highestModseq: true } });
|
|
10908
|
+
const inbox = result.find(entry => entry.path === 'INBOX');
|
|
10909
|
+
test.ok(inbox && inbox.status, 'the status block must survive one unusable field');
|
|
10910
|
+
test.equal(inbox.status.highestModseq, undefined, 'the unusable field is dropped');
|
|
10911
|
+
test.equal(inbox.status.messages, 10);
|
|
10912
|
+
test.equal(inbox.status.unseen, 5);
|
|
10913
|
+
test.done();
|
|
10914
|
+
};
|
|
10915
|
+
|
|
10916
|
+
module.exports['Commands: fetch stops retrying a throttled request once the client closes'] = async test => {
|
|
10917
|
+
// The retry used to wait on a bare setTimeout that close() could not abort: a short-lived
|
|
10918
|
+
// process stayed alive for up to five minutes after close(), still holding the retry
|
|
10919
|
+
let calls = 0;
|
|
10920
|
+
const connection = createMockConnection({
|
|
10921
|
+
state: 3,
|
|
10922
|
+
mailbox: { path: 'INBOX', exists: 1, flags: new Set(), permanentFlags: new Set(), noModseq: true },
|
|
10923
|
+
// the wait reports aborted, which is what close() does to every tracked back-off
|
|
10924
|
+
throttleWait: async () => true,
|
|
10925
|
+
exec: async () => {
|
|
10926
|
+
calls++;
|
|
10927
|
+
const err = new Error('throttled');
|
|
10928
|
+
err.code = 'ETHROTTLE';
|
|
10929
|
+
err.throttleReset = 60000;
|
|
10930
|
+
err.responseText = 'throttled';
|
|
10931
|
+
throw err;
|
|
10932
|
+
}
|
|
10933
|
+
});
|
|
10934
|
+
|
|
10935
|
+
let failure = null;
|
|
10936
|
+
try {
|
|
10937
|
+
await fetchCommand(connection, '1:*', { uid: true }, { uid: true });
|
|
10938
|
+
} catch (err) {
|
|
10939
|
+
failure = err;
|
|
10940
|
+
}
|
|
10941
|
+
|
|
10942
|
+
test.ok(failure, 'the caller is told the fetch did not happen');
|
|
10943
|
+
test.equal(failure.code, 'NoConnection', 'an aborted back-off means the connection is gone');
|
|
10944
|
+
test.equal(calls, 1, 'no retry may be issued on a closed connection');
|
|
10945
|
+
test.done();
|
|
10946
|
+
};
|
|
10947
|
+
|
|
10948
|
+
module.exports['Commands: select accepts an unparenthesized MAILBOXID'] = async test => {
|
|
10949
|
+
// RFC 8474 sends the id as a parenthesized list, but servers in the wild send it bare too
|
|
10950
|
+
const result = await selectCommand(selectWithOkCodes([[{ type: 'ATOM', value: 'MAILBOXID' }, { value: 'abc123' }]]), 'INBOX');
|
|
10951
|
+
test.equal(result.mailboxId, 'abc123');
|
|
10952
|
+
test.done();
|
|
10953
|
+
};
|
|
10954
|
+
|
|
10955
|
+
module.exports['Commands: quota ignores a NIL resource value'] = async test => {
|
|
10956
|
+
// A parsed NIL is null, which must not be recorded as a usage of 0
|
|
10957
|
+
const connection = createMockConnection({
|
|
10958
|
+
state: 2,
|
|
10959
|
+
capabilities: new Map([['QUOTA', true]]),
|
|
10960
|
+
exec: async (cmd, attrs, opts) => {
|
|
10961
|
+
if (cmd === 'GETQUOTAROOT' && opts && opts.untagged && opts.untagged.QUOTA) {
|
|
10962
|
+
await opts.untagged.QUOTA({
|
|
10963
|
+
attributes: [{ value: 'root' }, [{ value: 'STORAGE' }, null, { value: '500' }]]
|
|
10964
|
+
});
|
|
10965
|
+
}
|
|
10966
|
+
return { next: () => {} };
|
|
10967
|
+
}
|
|
10968
|
+
});
|
|
10969
|
+
|
|
10970
|
+
const result = await quotaCommand(connection, 'INBOX');
|
|
10971
|
+
test.ok(result, 'the command must actually run');
|
|
10972
|
+
test.equal(result.storage.usage, undefined, 'a NIL usage must not record a zero');
|
|
10973
|
+
test.equal(result.storage.limit, 500 * 1024, 'the limit that did parse is still reported');
|
|
10974
|
+
test.done();
|
|
10975
|
+
};
|
|
10976
|
+
|
|
10977
|
+
module.exports['Commands: status matches item names case-insensitively without reaching the prototype'] = async test => {
|
|
10978
|
+
// The item name is server-controlled and is used as a lookup key. Uppercasing it before the
|
|
10979
|
+
// lookup is what keeps a name like "constructor" from resolving to an inherited member, so
|
|
10980
|
+
// the matching has to stay case-insensitive AND prototype-safe at the same time.
|
|
10981
|
+
const connection = createMockConnection({
|
|
10982
|
+
state: 2,
|
|
10983
|
+
exec: async (cmd, attrs, opts) => {
|
|
10984
|
+
if (opts && opts.untagged && opts.untagged.STATUS) {
|
|
10985
|
+
await opts.untagged.STATUS({
|
|
10986
|
+
attributes: [
|
|
10987
|
+
{ value: 'INBOX' },
|
|
10988
|
+
[
|
|
10989
|
+
{ value: 'constructor' },
|
|
10990
|
+
{ value: '1' },
|
|
10991
|
+
{ value: '__proto__' },
|
|
10992
|
+
{ value: '2' },
|
|
10993
|
+
{ value: 'toString' },
|
|
10994
|
+
{ value: '3' },
|
|
10995
|
+
{ value: 'messages' }, // lowercase: servers send uppercase, but be liberal
|
|
10996
|
+
{ value: '10' }
|
|
10997
|
+
]
|
|
10998
|
+
]
|
|
10999
|
+
});
|
|
11000
|
+
}
|
|
11001
|
+
return { next: () => {} };
|
|
11002
|
+
}
|
|
11003
|
+
});
|
|
11004
|
+
|
|
11005
|
+
const result = await statusCommand(connection, 'INBOX', { messages: true });
|
|
11006
|
+
test.equal(result.messages, 10, 'a lowercase item name must still be recognized');
|
|
11007
|
+
test.equal(Object.getPrototypeOf(result), Object.prototype, 'the status object must keep its prototype');
|
|
11008
|
+
test.deepEqual(Object.keys(result).sort(), ['messages', 'path'], 'no prototype-chain name may become a field');
|
|
11009
|
+
test.done();
|
|
11010
|
+
};
|
|
11011
|
+
|
|
11012
|
+
module.exports['Commands: status does not touch live mailbox state for another mailbox'] = async test => {
|
|
11013
|
+
// The updaters exist to keep the selected mailbox current. Running them for a STATUS of a
|
|
11014
|
+
// different mailbox would overwrite exists/uidNext/highestModseq with another folder's counts.
|
|
11015
|
+
let emitted = [];
|
|
11016
|
+
const connection = createMockConnection({
|
|
11017
|
+
state: 3,
|
|
11018
|
+
mailbox: { path: 'INBOX', exists: 5, uidNext: 100, highestModseq: 7n },
|
|
11019
|
+
emit: (name, payload) => emitted.push([name, payload]),
|
|
11020
|
+
exec: async (cmd, attrs, opts) => {
|
|
11021
|
+
if (opts && opts.untagged && opts.untagged.STATUS) {
|
|
11022
|
+
await opts.untagged.STATUS({
|
|
11023
|
+
attributes: [
|
|
11024
|
+
{ value: 'Archive' },
|
|
11025
|
+
[{ value: 'MESSAGES' }, { value: '999' }, { value: 'UIDNEXT' }, { value: '888' }, { value: 'HIGHESTMODSEQ' }, { value: '777' }]
|
|
11026
|
+
]
|
|
11027
|
+
});
|
|
11028
|
+
}
|
|
11029
|
+
return { next: () => {} };
|
|
11030
|
+
}
|
|
11031
|
+
});
|
|
11032
|
+
|
|
11033
|
+
const result = await statusCommand(connection, 'Archive', { messages: true, uidNext: true, highestModseq: true });
|
|
11034
|
+
test.equal(result.messages, 999, 'the queried mailbox is still reported');
|
|
11035
|
+
test.equal(connection.mailbox.exists, 5, 'the selected mailbox count must be untouched');
|
|
11036
|
+
test.equal(connection.mailbox.uidNext, 100);
|
|
11037
|
+
test.equal(connection.mailbox.highestModseq, 7n);
|
|
11038
|
+
test.equal(emitted.filter(entry => entry[0] === 'exists').length, 0, 'no exists event for another mailbox');
|
|
11039
|
+
test.done();
|
|
11040
|
+
};
|
|
11041
|
+
|
|
11042
|
+
module.exports['Commands: search drops unusable ESEARCH COUNT, MIN and MAX values'] = async test => {
|
|
11043
|
+
// isNaN() passes '1e400' (Infinity) and '-1'; a COUNT of Infinity or a negative MIN is not a
|
|
11044
|
+
// usable answer and must not reach the caller
|
|
11045
|
+
const connection = createMockConnection({
|
|
11046
|
+
state: 3,
|
|
11047
|
+
capabilities: new Map([['ESEARCH', true]]),
|
|
11048
|
+
exec: async (cmd, attrs, opts) => {
|
|
11049
|
+
if (opts && opts.untagged && opts.untagged.ESEARCH) {
|
|
11050
|
+
await opts.untagged.ESEARCH({
|
|
11051
|
+
attributes: [
|
|
11052
|
+
[{ type: 'ATOM', value: 'TAG' }, { value: 'A1' }],
|
|
11053
|
+
{ type: 'ATOM', value: 'COUNT' },
|
|
11054
|
+
{ value: '1e400' },
|
|
11055
|
+
{ type: 'ATOM', value: 'MIN' },
|
|
11056
|
+
{ value: '-1' },
|
|
11057
|
+
{ type: 'ATOM', value: 'MAX' },
|
|
11058
|
+
{ value: '42' }
|
|
11059
|
+
]
|
|
11060
|
+
});
|
|
11061
|
+
}
|
|
11062
|
+
return { next: () => {} };
|
|
11063
|
+
}
|
|
11064
|
+
});
|
|
11065
|
+
|
|
11066
|
+
const result = await searchCommand(connection, { all: true }, { returnOptions: ['COUNT', 'MIN', 'MAX'] });
|
|
11067
|
+
test.equal(result.count, undefined, 'an overflowing COUNT must be dropped');
|
|
11068
|
+
test.equal(result.min, undefined, 'a negative MIN must be dropped');
|
|
11069
|
+
test.equal(result.max, 42, 'a usable MAX is still reported');
|
|
11070
|
+
test.done();
|
|
11071
|
+
};
|
|
11072
|
+
|
|
11073
|
+
module.exports['Commands: quota drops unusable resource values'] = async test => {
|
|
11074
|
+
// isNaN() passes '1e5' and ' 12 '; neither is a usable octet count
|
|
11075
|
+
const connection = createMockConnection({
|
|
11076
|
+
state: 2,
|
|
11077
|
+
capabilities: new Map([['QUOTA', true]]),
|
|
11078
|
+
exec: async (cmd, attrs, opts) => {
|
|
11079
|
+
if (cmd === 'GETQUOTAROOT' && opts && opts.untagged && opts.untagged.QUOTA) {
|
|
11080
|
+
await opts.untagged.QUOTA({
|
|
11081
|
+
attributes: [
|
|
11082
|
+
{ value: 'root' },
|
|
11083
|
+
[{ value: 'STORAGE' }, { value: '1e5' }, { value: '500' }, { value: 'MESSAGE' }, { value: '10' }, { value: '20' }]
|
|
11084
|
+
]
|
|
11085
|
+
});
|
|
11086
|
+
}
|
|
11087
|
+
return { next: () => {} };
|
|
11088
|
+
}
|
|
11089
|
+
});
|
|
11090
|
+
|
|
11091
|
+
const result = await quotaCommand(connection, 'INBOX');
|
|
11092
|
+
test.ok(result, 'the command must actually run');
|
|
11093
|
+
test.equal(result.storage.usage, undefined, 'an unusable usage must not be recorded');
|
|
11094
|
+
test.equal(result.message.usage, 10, 'a usable resource is still reported');
|
|
11095
|
+
test.equal(result.message.limit, 20);
|
|
11096
|
+
test.done();
|
|
11097
|
+
};
|
|
11098
|
+
|
|
11099
|
+
module.exports['Commands: list does not invent a special-use from a prototype-named mailbox'] = async test => {
|
|
11100
|
+
// The special-use hint map is keyed by server-supplied mailbox paths. On a plain object a
|
|
11101
|
+
// mailbox literally named "constructor" resolves to Object.prototype.constructor - truthy -
|
|
11102
|
+
// and the client would attach a special-use flag the server never sent.
|
|
11103
|
+
const connection = createMockConnection({
|
|
11104
|
+
state: 3,
|
|
11105
|
+
exec: async (cmd, attrs, opts) => {
|
|
11106
|
+
if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
|
|
11107
|
+
for (let path of ['constructor', 'toString', '__proto__']) {
|
|
11108
|
+
await opts.untagged.LIST({
|
|
11109
|
+
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: path }]
|
|
11110
|
+
});
|
|
11111
|
+
}
|
|
11112
|
+
}
|
|
11113
|
+
return { next: () => {} };
|
|
11114
|
+
}
|
|
11115
|
+
});
|
|
11116
|
+
|
|
11117
|
+
const result = await listCommand(connection, '', '*', {});
|
|
11118
|
+
for (let path of ['constructor', 'toString', '__proto__']) {
|
|
11119
|
+
const entry = result.find(item => item.path === path);
|
|
11120
|
+
test.ok(entry, `${path} must still be listed`);
|
|
11121
|
+
test.equal(entry.specialUse, undefined, `${path} must not gain a special-use flag`);
|
|
11122
|
+
}
|
|
11123
|
+
test.done();
|
|
11124
|
+
};
|