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
|
@@ -151,3 +151,23 @@ module.exports['CopyUID Parser: uidValidity zero is stored as BigInt(0)'] = test
|
|
|
151
151
|
test.equal(map.uidMap.get(1), 100);
|
|
152
152
|
test.done();
|
|
153
153
|
};
|
|
154
|
+
|
|
155
|
+
// ============================================
|
|
156
|
+
// Hostile server input
|
|
157
|
+
// ============================================
|
|
158
|
+
|
|
159
|
+
module.exports['CopyUID Parser: non-decimal uidValidity is ignored, uidMap still parsed'] = test => {
|
|
160
|
+
// isNaN() passed values like "1e5" and "Infinity" through, and BigInt() then threw,
|
|
161
|
+
// losing the whole COPYUID result
|
|
162
|
+
let map = {};
|
|
163
|
+
parseCopyUid(makeResponse([{ value: 'COPYUID' }, { value: '1e5' }, { value: '1' }, { value: '100' }]), map);
|
|
164
|
+
test.equal(map.uidValidity, undefined);
|
|
165
|
+
test.ok(map.uidMap instanceof Map);
|
|
166
|
+
test.equal(map.uidMap.get(1), 100);
|
|
167
|
+
|
|
168
|
+
let map2 = {};
|
|
169
|
+
parseCopyUid(makeResponse([{ value: 'COPYUID' }, { value: 'Infinity' }, { value: '1' }, { value: '100' }]), map2);
|
|
170
|
+
test.equal(map2.uidValidity, undefined);
|
|
171
|
+
test.equal(map2.uidMap.get(1), 100);
|
|
172
|
+
test.done();
|
|
173
|
+
};
|
|
@@ -347,3 +347,84 @@ module.exports['Polling: no polling session without a saved select command'] = a
|
|
|
347
347
|
test.equal(connection.idling, false, 'idling untouched');
|
|
348
348
|
test.done();
|
|
349
349
|
};
|
|
350
|
+
|
|
351
|
+
module.exports['Polling: a falsy STATUS result stops the loop as PollFailed'] = async test => {
|
|
352
|
+
await withFakeTimers(async timers => {
|
|
353
|
+
let warnings = [];
|
|
354
|
+
let connection = createConnection({
|
|
355
|
+
missingIdleCommand: 'STATUS',
|
|
356
|
+
// Only STATUS is ever polled; a failure without a NO status makes the
|
|
357
|
+
// real STATUS implementation swallow the error and return false
|
|
358
|
+
respond: async () => {
|
|
359
|
+
throw new Error('Command failed');
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
connection.log.warn = entry => warnings.push(entry);
|
|
363
|
+
|
|
364
|
+
let idlePromise = idleCommand(connection, 60000);
|
|
365
|
+
await timers.drain();
|
|
366
|
+
await idlePromise;
|
|
367
|
+
|
|
368
|
+
test.ok(
|
|
369
|
+
warnings.some(entry => entry && entry.err && entry.err.code === 'PollFailed'),
|
|
370
|
+
'the falsy STATUS result surfaced as a PollFailed error'
|
|
371
|
+
);
|
|
372
|
+
test.equal(connection.idling, false, 'idling reset after the failed poll');
|
|
373
|
+
test.equal(connection.preCheck, false, 'preCheck released');
|
|
374
|
+
test.equal(timers.count(), 0, 'polling stopped');
|
|
375
|
+
test.done();
|
|
376
|
+
});
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
module.exports['Polling: a repeated break call is a no-op'] = async test => {
|
|
380
|
+
await withFakeTimers(async timers => {
|
|
381
|
+
let connection = createConnection();
|
|
382
|
+
|
|
383
|
+
let idlePromise = idleCommand(connection, 60000);
|
|
384
|
+
await timers.drain();
|
|
385
|
+
test.deepEqual(connection.commands, ['NOOP'], 'the immediate first poll ran');
|
|
386
|
+
|
|
387
|
+
// A caller may hold on to the break function and invoke it more than once
|
|
388
|
+
let preCheck = connection.preCheck;
|
|
389
|
+
await preCheck();
|
|
390
|
+
await preCheck();
|
|
391
|
+
await idlePromise;
|
|
392
|
+
|
|
393
|
+
test.equal(connection.idling, false, 'idling reset');
|
|
394
|
+
test.equal(connection.preCheck, false, 'preCheck released');
|
|
395
|
+
test.equal(timers.count(), 0, 'no timer left armed');
|
|
396
|
+
|
|
397
|
+
await timers.fire();
|
|
398
|
+
test.deepEqual(connection.commands, ['NOOP'], 'no further poll after the duplicate break');
|
|
399
|
+
test.done();
|
|
400
|
+
});
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
module.exports['Polling: a break in the initiation tick prevents the first poll'] = async test => {
|
|
404
|
+
await withFakeTimers(async timers => {
|
|
405
|
+
let connection = createConnection();
|
|
406
|
+
// Simulate a break request (e.g. a command being queued) landing in the same
|
|
407
|
+
// tick the loop is initiated: trap the loop installing its own preCheck and
|
|
408
|
+
// break through it immediately, before the first poll has started
|
|
409
|
+
let installedPreCheck = connection.preCheck;
|
|
410
|
+
Object.defineProperty(connection, 'preCheck', {
|
|
411
|
+
get: () => installedPreCheck,
|
|
412
|
+
set: value => {
|
|
413
|
+
installedPreCheck = value;
|
|
414
|
+
if (typeof value === 'function') {
|
|
415
|
+
value();
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
let idlePromise = idleCommand(connection, 60000);
|
|
421
|
+
await timers.drain();
|
|
422
|
+
await idlePromise;
|
|
423
|
+
|
|
424
|
+
test.deepEqual(connection.commands, [], 'the already-cancelled session never polled');
|
|
425
|
+
test.equal(connection.idling, false, 'idling reset');
|
|
426
|
+
test.equal(connection.preCheck, false, 'preCheck released');
|
|
427
|
+
test.equal(timers.count(), 0, 'no timer left armed');
|
|
428
|
+
test.done();
|
|
429
|
+
});
|
|
430
|
+
};
|
|
@@ -642,10 +642,11 @@ module.exports['IMAP Compiler: partial range in SECTION'] = test =>
|
|
|
642
642
|
|
|
643
643
|
// Returns the error a compile attempt raised, or undefined when it succeeded.
|
|
644
644
|
// Several cases below assert that a token can never reach the wire, and the
|
|
645
|
-
// try/catch is the only interesting part of each.
|
|
646
|
-
|
|
645
|
+
// try/catch is the only interesting part of each. Takes either an attributes
|
|
646
|
+
// array (compiled under a default tag/command) or a full response object.
|
|
647
|
+
const compileError = async input => {
|
|
647
648
|
try {
|
|
648
|
-
await compiler({ tag: 'A', command: 'CMD', attributes });
|
|
649
|
+
await compiler(Array.isArray(input) ? { tag: 'A', command: 'CMD', attributes: input } : input);
|
|
649
650
|
} catch (err) {
|
|
650
651
|
return err;
|
|
651
652
|
}
|
|
@@ -663,10 +664,50 @@ module.exports['IMAP Compiler: SEQUENCE rejects values that are not sequence set
|
|
|
663
664
|
|
|
664
665
|
module.exports['IMAP Compiler: SEQUENCE accepts every valid sequence-set form'] = test =>
|
|
665
666
|
asyncWrapper(test, async test => {
|
|
666
|
-
|
|
667
|
+
// '$' is the RFC 5182 SEARCHRES saved-result marker, valid as the entire set
|
|
668
|
+
for (let value of ['1', '*', '1:*', '*:1', '1,3,5', '1:3,7,9:*', '4294967295', '$']) {
|
|
667
669
|
const compiled = (await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'SEQUENCE', value }] })).toString();
|
|
668
670
|
test.equal(compiled, `A FETCH ${value}`);
|
|
669
671
|
}
|
|
672
|
+
|
|
673
|
+
// '$' only stands for the whole set, never an element of one
|
|
674
|
+
for (let value of ['$,1', '1,$', '$:2']) {
|
|
675
|
+
let err = await compileError([{ type: 'SEQUENCE', value }]);
|
|
676
|
+
test.equal(err && err.code, 'InvalidSequenceSet', `${JSON.stringify(value)} must be rejected`);
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
module.exports['IMAP Compiler: SEQUENCE validation stays linear on huge valid sets'] = test =>
|
|
681
|
+
asyncWrapper(test, async test => {
|
|
682
|
+
// The regex this replaced overflowed the engine's backtrack stack with an
|
|
683
|
+
// uncoded RangeError at roughly a million comma-separated elements - a set
|
|
684
|
+
// a fetch over a large mailbox can legitimately produce
|
|
685
|
+
const huge = Array.from({ length: 1500000 }, (_, i) => i + 1).join(',');
|
|
686
|
+
const compiled = await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'SEQUENCE', value: huge }] });
|
|
687
|
+
test.ok(compiled.toString().endsWith(huge), 'the huge but valid set must compile');
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
module.exports['IMAP Compiler: SEQUENCE validation is skipped when logging'] = test =>
|
|
691
|
+
asyncWrapper(test, async test => {
|
|
692
|
+
// The incoming token parser accepts sequence-shaped tokens the strict grammar
|
|
693
|
+
// rejects (an ESEARCH set like "1:2:3", a folder name like "12:30:00"). Every
|
|
694
|
+
// parsed server response is re-compiled for the log, so a throw here would let
|
|
695
|
+
// one quirky server line kill the whole connection.
|
|
696
|
+
for (let value of ['1:2:3', '12:30:00', '1,2:3:4']) {
|
|
697
|
+
const compiled = (await compiler({ tag: '*', command: 'OK', attributes: [{ type: 'SEQUENCE', value }] }, { isLogging: true })).toString();
|
|
698
|
+
test.equal(compiled, `* OK ${value}`, 'logging re-compile must pass the token through');
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
module.exports['IMAP Compiler: tag and command cannot carry a line terminator'] = test =>
|
|
703
|
+
asyncWrapper(test, async test => {
|
|
704
|
+
// The tag/command preamble is written verbatim ahead of any token, so it goes
|
|
705
|
+
// through the same choke point as everything else
|
|
706
|
+
let err = await compileError({ tag: 'A\r\nZZ LOGOUT', command: 'NOOP' });
|
|
707
|
+
test.equal(err && err.code, 'InvalidTokenValue', 'CRLF in the tag must be rejected');
|
|
708
|
+
|
|
709
|
+
err = await compileError({ tag: 'A', command: 'NOOP\r\nZZ LOGOUT' });
|
|
710
|
+
test.equal(err && err.code, 'InvalidTokenValue', 'CRLF in the command must be rejected');
|
|
670
711
|
});
|
|
671
712
|
|
|
672
713
|
module.exports['IMAP Compiler: quoted strings use IMAP escaping, not JSON escaping'] = test =>
|
|
@@ -707,6 +748,23 @@ module.exports['IMAP Compiler: NUMBER coerces its value instead of writing it th
|
|
|
707
748
|
test.equal((await compiler({ tag: 'A', command: 'CMD', attributes: [{ type: 'NUMBER', value: '42' }] })).toString(), 'A CMD 42');
|
|
708
749
|
});
|
|
709
750
|
|
|
751
|
+
module.exports['IMAP Compiler: numeric tokens clamp to bounded non-negative integers'] = test =>
|
|
752
|
+
asyncWrapper(test, async test => {
|
|
753
|
+
// Infinity, negatives and unsafe magnitudes would otherwise emit bytes outside
|
|
754
|
+
// the number alphabet ("Infinity", "-5", "1e+300") and get a server BAD blamed
|
|
755
|
+
// on the server instead of the caller
|
|
756
|
+
for (let [value, expected] of [
|
|
757
|
+
[Infinity, '0'],
|
|
758
|
+
['Infinity', '0'],
|
|
759
|
+
[-5, '0'],
|
|
760
|
+
['2e21', '0'],
|
|
761
|
+
[4.6, '5']
|
|
762
|
+
]) {
|
|
763
|
+
const compiled = (await compiler({ tag: 'A', command: 'CMD', attributes: [{ type: 'NUMBER', value }] })).toString();
|
|
764
|
+
test.equal(compiled, `A CMD ${expected}`, `${JSON.stringify(value)} must clamp to ${expected}`);
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
|
|
710
768
|
module.exports['IMAP Compiler: partial range coerces its elements'] = test =>
|
|
711
769
|
asyncWrapper(test, async test => {
|
|
712
770
|
// The partial range is the last token component written verbatim, so it is
|
|
@@ -719,6 +777,18 @@ module.exports['IMAP Compiler: partial range coerces its elements'] = test =>
|
|
|
719
777
|
await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'ATOM', value: 'BODY.PEEK', section: [], partial: [0, 1024] }] })
|
|
720
778
|
).toString();
|
|
721
779
|
test.equal(normal, 'A FETCH BODY.PEEK[]<0.1024>');
|
|
780
|
+
|
|
781
|
+
// Floats and unbounded numerics clamp the same way NUMBER tokens do, so no
|
|
782
|
+
// non-digit byte ("512.5", "Infinity") can appear inside the partial range
|
|
783
|
+
const float = (
|
|
784
|
+
await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'ATOM', value: 'BODY.PEEK', section: [], partial: [0, 512.5] }] })
|
|
785
|
+
).toString();
|
|
786
|
+
test.equal(float, 'A FETCH BODY.PEEK[]<0.513>');
|
|
787
|
+
|
|
788
|
+
const unbounded = (
|
|
789
|
+
await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'ATOM', value: 'BODY.PEEK', section: [], partial: ['Infinity', '2e21'] }] })
|
|
790
|
+
).toString();
|
|
791
|
+
test.equal(unbounded, 'A FETCH BODY.PEEK[]<0.0>');
|
|
722
792
|
});
|
|
723
793
|
|
|
724
794
|
module.exports['IMAP Compiler: logging output never throws on unsendable values'] = test =>
|
|
@@ -591,9 +591,11 @@ module.exports['Coverage: reader caps very large throttle backoff'] = async test
|
|
|
591
591
|
let readerPromise = runReaderWith(client, ['A1 BAD Request is throttled. Suggested Backoff Time: 999999999 milliseconds']);
|
|
592
592
|
// give the reader a tick to register the throttle timer, then abort it
|
|
593
593
|
await drain();
|
|
594
|
-
|
|
595
|
-
|
|
594
|
+
for (let entry of client._throttleWaits) {
|
|
595
|
+
clearTimeout(entry.timer);
|
|
596
|
+
entry.resolve(true);
|
|
596
597
|
}
|
|
598
|
+
client._throttleWaits.clear();
|
|
597
599
|
await readerPromise;
|
|
598
600
|
test.ok(rejected);
|
|
599
601
|
test.done();
|
|
@@ -852,3 +852,29 @@ module.exports['DownloadMany: decodes base64 and quoted-printable parts'] = asyn
|
|
|
852
852
|
test.equal(res['3'].meta.disposition, 'attachment');
|
|
853
853
|
test.done();
|
|
854
854
|
};
|
|
855
|
+
|
|
856
|
+
module.exports['Download: a buffering transform cannot defeat maxBytes'] = async test => {
|
|
857
|
+
// format=flowed unwrapping and the Japanese charset decoder both hold their whole input
|
|
858
|
+
// before emitting anything, so the limiter at the tail of the pipeline reports
|
|
859
|
+
// `limited === false` however much the server sends. Without a bound of their own the fetch
|
|
860
|
+
// loop would keep pulling the entire part.
|
|
861
|
+
for (let contentType of ['text/plain; format=flowed', 'text/plain; charset=iso-2022-jp']) {
|
|
862
|
+
let client = makeClient();
|
|
863
|
+
let mime = Buffer.from(`Content-Type: ${contentType}\r\nContent-Disposition: inline\r\n\r\n`);
|
|
864
|
+
let calls = 0;
|
|
865
|
+
client.fetchOne = async () => {
|
|
866
|
+
calls++;
|
|
867
|
+
let bodyParts = new Map();
|
|
868
|
+
bodyParts.set('2.mime', mime);
|
|
869
|
+
// full-size chunks keep hasMore true; the loop must stop on its own, not run dry
|
|
870
|
+
bodyParts.set('2', calls <= 40 ? Buffer.alloc(1024, 0x61) : Buffer.alloc(1));
|
|
871
|
+
return { uid: 1, size: 1024 * 1024, bodyParts };
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
let { content } = await client.download('1', '2', { chunkSize: 1024, maxBytes: 512 });
|
|
875
|
+
let data = await collect(content);
|
|
876
|
+
test.ok(data.length <= 512, `${contentType}: delivered ${data.length} bytes, expected no more than 512`);
|
|
877
|
+
test.ok(calls <= 3, `${contentType}: the fetch loop must stop at the bound, made ${calls} calls`);
|
|
878
|
+
}
|
|
879
|
+
test.done();
|
|
880
|
+
};
|
|
@@ -471,3 +471,137 @@ module.exports['Internals: untaggedFetch includes modseq and flagColor'] = async
|
|
|
471
471
|
test.equal(evt.flagColor, 'red');
|
|
472
472
|
test.done();
|
|
473
473
|
};
|
|
474
|
+
|
|
475
|
+
// ============================================================================
|
|
476
|
+
// runInternal dispatch guards
|
|
477
|
+
// ============================================================================
|
|
478
|
+
|
|
479
|
+
module.exports['Internals: runInternal returns false for unknown command'] = async test => {
|
|
480
|
+
let client = makeClient();
|
|
481
|
+
client.socket = { destroyed: false };
|
|
482
|
+
let res = await client.runInternal('NOT_A_COMMAND');
|
|
483
|
+
test.equal(res, false);
|
|
484
|
+
test.done();
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
module.exports['Internals: runInternal throws NoConnection when the socket is destroyed'] = async test => {
|
|
488
|
+
let client = makeClient();
|
|
489
|
+
client.socket = { destroyed: true };
|
|
490
|
+
let err = null;
|
|
491
|
+
try {
|
|
492
|
+
await client.runInternal('NOOP');
|
|
493
|
+
} catch (e) {
|
|
494
|
+
err = e;
|
|
495
|
+
}
|
|
496
|
+
test.ok(err);
|
|
497
|
+
test.equal(err.code, 'NoConnection');
|
|
498
|
+
test.done();
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
// ============================================================================
|
|
502
|
+
// send() onSend error containment
|
|
503
|
+
// ============================================================================
|
|
504
|
+
|
|
505
|
+
module.exports['Internals: send contains a throwing onSend callback'] = async test => {
|
|
506
|
+
let client = makeClient();
|
|
507
|
+
let written = [];
|
|
508
|
+
let warnings = [];
|
|
509
|
+
client.socket = { destroyed: false };
|
|
510
|
+
client.writeSocket = { destroyed: false, write: chunk => written.push(chunk) };
|
|
511
|
+
client.state = client.states.AUTHENTICATED;
|
|
512
|
+
client.log.warn = entry => warnings.push(entry);
|
|
513
|
+
client.currentRequest = { tag: 'A1', sent: false };
|
|
514
|
+
|
|
515
|
+
// The command is already on the wire when onSend runs, so a throwing callback
|
|
516
|
+
// must be logged and swallowed instead of rejecting the request
|
|
517
|
+
await client.send({
|
|
518
|
+
tag: 'A1',
|
|
519
|
+
command: 'NOOP',
|
|
520
|
+
attributes: [],
|
|
521
|
+
options: {
|
|
522
|
+
onSend: () => {
|
|
523
|
+
throw new Error('onSend boom');
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test.equal(written.length, 1, 'the command was written to the socket');
|
|
529
|
+
test.equal(written[0].toString(), 'A1 NOOP\r\n', 'the actual wire bytes went out before onSend ran');
|
|
530
|
+
test.equal(client.currentRequest.sent, true, 'the request is marked as sent');
|
|
531
|
+
test.ok(
|
|
532
|
+
warnings.some(entry => entry && entry.err && entry.err.message === 'onSend boom'),
|
|
533
|
+
'the callback error was logged'
|
|
534
|
+
);
|
|
535
|
+
test.done();
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
// ============================================================================
|
|
539
|
+
// countUnknownTag teardown guard
|
|
540
|
+
// ============================================================================
|
|
541
|
+
|
|
542
|
+
module.exports['Internals: countUnknownTag ignores tags on a closed connection'] = test => {
|
|
543
|
+
let client = makeClient();
|
|
544
|
+
client.isClosed = true;
|
|
545
|
+
client.countUnknownTag('A1');
|
|
546
|
+
test.equal(client._unknownTagCount, 0, 'teardown crossover is not counted');
|
|
547
|
+
test.done();
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
// ============================================================================
|
|
551
|
+
// rejectUnparsedCompletion
|
|
552
|
+
// ============================================================================
|
|
553
|
+
|
|
554
|
+
// Installs a command that is current and on the wire, returning a holder for
|
|
555
|
+
// the rejection the reader loop would deliver to the caller
|
|
556
|
+
const armInFlight = (client, tag) => {
|
|
557
|
+
let captured = { err: null };
|
|
558
|
+
client.currentRequest = { tag, sent: true };
|
|
559
|
+
client.requestTagMap.set(tag, {
|
|
560
|
+
tag,
|
|
561
|
+
reject: err => {
|
|
562
|
+
captured.err = err;
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
return captured;
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
module.exports['Internals: rejectUnparsedCompletion ignores lines without an in-flight command'] = test => {
|
|
569
|
+
let client = makeClient();
|
|
570
|
+
|
|
571
|
+
client.currentRequest = false;
|
|
572
|
+
client.rejectUnparsedCompletion(Buffer.from('A1 OK done'), new Error('parse fail'));
|
|
573
|
+
test.equal(client.currentRequest, false, 'no request to settle');
|
|
574
|
+
|
|
575
|
+
// A command that is current but not yet on the wire must not be settled either
|
|
576
|
+
client.currentRequest = { tag: 'A1', sent: false };
|
|
577
|
+
client.rejectUnparsedCompletion(Buffer.from('A1 OK done'), new Error('parse fail'));
|
|
578
|
+
test.ok(client.currentRequest, 'the unsent request is untouched');
|
|
579
|
+
test.done();
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
module.exports['Internals: rejectUnparsedCompletion recovers the tag from NUL-padded raw bytes'] = async test => {
|
|
583
|
+
let client = makeClient();
|
|
584
|
+
let captured = armInFlight(client, 'A1');
|
|
585
|
+
|
|
586
|
+
// The parser died before extracting a tag - the raw line carries the buggy-server
|
|
587
|
+
// NUL padding, so the fallback must skip it and stop at the first non-tag byte
|
|
588
|
+
let parserError = new Error('parse fail');
|
|
589
|
+
client.rejectUnparsedCompletion(Buffer.from('\x00\x00A1 \x07garbage'), parserError);
|
|
590
|
+
|
|
591
|
+
test.ok(captured.err, 'the in-flight command was failed');
|
|
592
|
+
test.equal(captured.err.code, 'ParserError');
|
|
593
|
+
test.equal(captured.err.parserError, parserError);
|
|
594
|
+
test.equal(client.currentRequest, false, 'the request slot was cleared');
|
|
595
|
+
test.done();
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
module.exports['Internals: rejectUnparsedCompletion ignores a mismatched raw tag'] = test => {
|
|
599
|
+
let client = makeClient();
|
|
600
|
+
let captured = armInFlight(client, 'A1');
|
|
601
|
+
|
|
602
|
+
client.rejectUnparsedCompletion(Buffer.from('A2 NO other'), new Error('parse fail'));
|
|
603
|
+
|
|
604
|
+
test.equal(captured.err, null, 'a line for another tag settles nothing');
|
|
605
|
+
test.ok(client.currentRequest, 'the in-flight request stays current');
|
|
606
|
+
test.done();
|
|
607
|
+
};
|
|
@@ -795,3 +795,95 @@ module.exports['Methods: autoEnable does not retry when disableIMAP4rev2 already
|
|
|
795
795
|
test.equal(calls.length, 1);
|
|
796
796
|
test.done();
|
|
797
797
|
};
|
|
798
|
+
|
|
799
|
+
module.exports['Methods: untaggedExpunge refuses an unusable sequence number'] = async test => {
|
|
800
|
+
// Same bound untaggedExists() applies: an overflowing or non-decimal value must not
|
|
801
|
+
// decrement the live message count
|
|
802
|
+
let client = makeClient();
|
|
803
|
+
client.mailbox.exists = 10;
|
|
804
|
+
let events = [];
|
|
805
|
+
client.on('expunge', e => events.push(e));
|
|
806
|
+
|
|
807
|
+
await client.untaggedExpunge(false);
|
|
808
|
+
for (let bad of ['9'.repeat(400), '1e5', '0', 'abc', '']) {
|
|
809
|
+
await client.untaggedExpunge({ command: bad });
|
|
810
|
+
}
|
|
811
|
+
test.equal(client.mailbox.exists, 10, 'the live message count must be left alone');
|
|
812
|
+
test.equal(events.length, 0);
|
|
813
|
+
|
|
814
|
+
await client.untaggedExpunge({ command: '3' });
|
|
815
|
+
test.equal(client.mailbox.exists, 9, 'a usable sequence number is still applied');
|
|
816
|
+
test.equal(events.length, 1);
|
|
817
|
+
test.equal(events[0].seq, 3);
|
|
818
|
+
test.done();
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
module.exports['Methods: untaggedVanished survives a malformed response'] = async test => {
|
|
822
|
+
// "* VANISHED (EARLIER)" leaves no sequence set at all. Throwing here would abort the
|
|
823
|
+
// handler for the whole response, and handleResponse only warns - so every expunge the
|
|
824
|
+
// response did carry would be dropped and the client's mailbox view would go stale.
|
|
825
|
+
let client = makeClient();
|
|
826
|
+
let events = [];
|
|
827
|
+
client.on('expunge', e => events.push(e));
|
|
828
|
+
|
|
829
|
+
await client.untaggedVanished({ attributes: [[{ value: 'EARLIER' }]] });
|
|
830
|
+
await client.untaggedVanished({ attributes: [] });
|
|
831
|
+
await client.untaggedVanished({});
|
|
832
|
+
await client.untaggedVanished({ attributes: [[{ value: 'EARLIER' }], null] });
|
|
833
|
+
|
|
834
|
+
test.equal(events.length, 0, 'nothing to report, and nothing thrown');
|
|
835
|
+
|
|
836
|
+
// a well-formed response still works after the malformed ones
|
|
837
|
+
await client.untaggedVanished({ attributes: [[{ value: 'EARLIER' }], { value: '5:6' }] });
|
|
838
|
+
test.equal(events.length, 2);
|
|
839
|
+
test.equal(events[0].uid, 5);
|
|
840
|
+
test.equal(events[0].earlier, true);
|
|
841
|
+
test.done();
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
module.exports['Methods: untaggedExists refuses an overflowing count'] = async test => {
|
|
845
|
+
// Number('9'.repeat(400)) is Infinity: resolveRange('*') would then compile the literal
|
|
846
|
+
// string "Infinity" and every range-based command would fail until the next SELECT
|
|
847
|
+
let client = makeClient();
|
|
848
|
+
client.mailbox = { path: 'INBOX', exists: 5 };
|
|
849
|
+
let events = [];
|
|
850
|
+
client.on('exists', e => events.push(e));
|
|
851
|
+
|
|
852
|
+
await client.untaggedExists(false);
|
|
853
|
+
await client.untaggedExists({});
|
|
854
|
+
for (let bad of ['9'.repeat(400), '1e5', '-1', 'abc', '1.5', '']) {
|
|
855
|
+
await client.untaggedExists({ command: bad });
|
|
856
|
+
}
|
|
857
|
+
test.equal(client.mailbox.exists, 5, 'the live message count must be left alone');
|
|
858
|
+
test.equal(events.length, 0);
|
|
859
|
+
|
|
860
|
+
await client.untaggedExists({ command: '7' });
|
|
861
|
+
test.equal(client.mailbox.exists, 7, 'a usable count is still applied');
|
|
862
|
+
test.equal(events.length, 1);
|
|
863
|
+
test.done();
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
module.exports['Methods: throttleWait caps the delay and close() aborts it'] = async test => {
|
|
867
|
+
let client = makeClient();
|
|
868
|
+
|
|
869
|
+
// A server hint is unbounded; the wait may never outlive the client either
|
|
870
|
+
let started = Date.now();
|
|
871
|
+
let pending = client.throttleWait(7 * 24 * 3600 * 1000);
|
|
872
|
+
test.equal(client._throttleWaits.size, 1, 'the wait is tracked so close() can reach it');
|
|
873
|
+
|
|
874
|
+
client.close();
|
|
875
|
+
test.equal(await pending, true, 'an aborted wait reports true');
|
|
876
|
+
test.equal(client._throttleWaits.size, 0, 'the tracked wait is released');
|
|
877
|
+
test.ok(Date.now() - started < 5000, 'close() must not wait out the delay');
|
|
878
|
+
|
|
879
|
+
// normal expiry reports false and releases the entry. The back-off timer is deliberately
|
|
880
|
+
// unref'd, so the test has to hold the event loop open itself - in a real session the
|
|
881
|
+
// socket does that.
|
|
882
|
+
let client2 = makeClient();
|
|
883
|
+
let keepAlive = setTimeout(() => {}, 1000);
|
|
884
|
+
test.equal(await client2.throttleWait(1), false);
|
|
885
|
+
clearTimeout(keepAlive);
|
|
886
|
+
test.equal(client2._throttleWaits.size, 0);
|
|
887
|
+
client2.close();
|
|
888
|
+
test.done();
|
|
889
|
+
};
|