imapflow 1.6.5 → 1.7.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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +20 -0
- package/lib/commands/append.js +26 -4
- 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/idle.js +26 -5
- 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-stream.js +56 -2
- package/lib/handler/limits.js +16 -4
- package/lib/imap-flow.d.ts +33 -2
- package/lib/imap-flow.js +307 -97
- package/lib/jp-decoder.js +30 -5
- package/lib/limited-passthrough.js +19 -1
- package/lib/tools.js +190 -39
- package/package.json +4 -4
- package/test/auto-idle-test.js +470 -0
- package/test/commands-branches-test.js +4 -0
- package/test/commands-integration-test.js +683 -0
- package/test/connection-edge-cases-test.js +3 -1
- package/test/copyuid-parser-test.js +20 -0
- package/test/fixtures/test-client.js +57 -0
- package/test/idle-polling-test.js +88 -0
- package/test/imap-flow-coverage-test.js +8 -12
- package/test/imap-flow-fetch-download-test.js +29 -10
- package/test/imap-flow-internals-test.js +14 -32
- package/test/imap-flow-methods-test.js +92 -0
- package/test/imap-stream-edge-cases-test.js +136 -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/timer-policy-test.js +31 -18
- 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
|
|
@@ -10439,3 +10443,682 @@ module.exports['Commands: downloadMany with fetchOne returning null'] = async te
|
|
|
10439
10443
|
test.equal(result.response, false);
|
|
10440
10444
|
test.done();
|
|
10441
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
|
+
};
|