imapflow 1.4.9 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -488,6 +488,33 @@ module.exports['Commands: search collects results from an ESEARCH reply to plain
488
488
  test.done();
489
489
  };
490
490
 
491
+ module.exports['Commands: search returns empty array for an ESEARCH reply without ALL'] = async test => {
492
+ const connection = createMockConnection({
493
+ state: 3,
494
+ capabilities: new Map([['IMAP4rev2', true]]),
495
+ exec: async (cmd, attrs, opts) => {
496
+ // RFC 9051: an ESEARCH response with no matches omits the ALL item
497
+ if (opts && opts.untagged && opts.untagged.ESEARCH) {
498
+ await opts.untagged.ESEARCH({
499
+ attributes: [
500
+ [
501
+ { type: 'ATOM', value: 'TAG' },
502
+ { type: 'STRING', value: 'A282' }
503
+ ],
504
+ { type: 'ATOM', value: 'COUNT' },
505
+ { type: 'ATOM', value: '0' }
506
+ ]
507
+ });
508
+ }
509
+ return { next: () => {} };
510
+ }
511
+ });
512
+
513
+ const result = await searchCommand(connection, true, {});
514
+ test.deepEqual(result, []);
515
+ test.done();
516
+ };
517
+
491
518
  module.exports['Commands: search caps a hostile ESEARCH ALL range at the mailbox size'] = async test => {
492
519
  const connection = createMockConnection({
493
520
  state: 3,
@@ -693,6 +720,26 @@ module.exports['Commands: store add flags'] = async test => {
693
720
  test.done();
694
721
  };
695
722
 
723
+ module.exports['Commands: store drops the Recent flag from the wire'] = async test => {
724
+ let execArgs = null;
725
+ const connection = createMockConnection({
726
+ state: 3,
727
+ exec: async (cmd, attrs) => {
728
+ execArgs = { cmd, attrs };
729
+ return { next: () => {} };
730
+ }
731
+ });
732
+
733
+ // \Recent is owned by the server (and removed entirely in IMAP4rev2) - a
734
+ // client-side STORE must never try to set it
735
+ const result = await storeCommand(connection, '1:10', ['\\Seen', '\\Recent'], { operation: 'add' });
736
+ test.equal(result, true);
737
+ const attrsStr = JSON.stringify(execArgs.attrs);
738
+ test.ok(attrsStr.includes('\\\\Seen'));
739
+ test.ok(!attrsStr.toLowerCase().includes('recent'));
740
+ test.done();
741
+ };
742
+
696
743
  module.exports['Commands: store remove flags'] = async test => {
697
744
  let execArgs = null;
698
745
  const connection = createMockConnection({
@@ -2975,6 +3022,97 @@ module.exports['Commands: fetch with BINARY capability'] = async test => {
2975
3022
  test.done();
2976
3023
  };
2977
3024
 
3025
+ module.exports['Commands: fetch with binary uses BINARY on rev2-only servers without the token'] = async test => {
3026
+ let queryAttrs = null;
3027
+ const connection = createMockConnection({
3028
+ state: 3,
3029
+ // rev2-only server: no BINARY token, but RFC 9051 folds the FETCH side of
3030
+ // the BINARY extension into base IMAP4rev2
3031
+ capabilities: new Map([['IMAP4rev2', true]]),
3032
+ exec: async (cmd, attrs) => {
3033
+ queryAttrs = attrs;
3034
+ return { next: () => {} };
3035
+ }
3036
+ });
3037
+
3038
+ await fetchCommand(connection, '1', { source: true }, { binary: true });
3039
+ test.ok(queryAttrs);
3040
+ test.ok(JSON.stringify(queryAttrs).includes('BINARY.PEEK'));
3041
+ test.done();
3042
+ };
3043
+
3044
+ module.exports['Commands: fetch with binary keeps BODY for non-numeric sections'] = async test => {
3045
+ let queryAttrs = null;
3046
+ const connection = createMockConnection({
3047
+ state: 3,
3048
+ capabilities: new Map([['BINARY', true]]),
3049
+ exec: async (cmd, attrs) => {
3050
+ queryAttrs = attrs;
3051
+ return { next: () => {} };
3052
+ }
3053
+ });
3054
+
3055
+ // RFC 3516/RFC 9051: section-binary only allows numeric part specifiers -
3056
+ // BINARY[HEADER], BINARY[TEXT] and BINARY[n.MIME] are invalid syntax that
3057
+ // servers reject, so those sections must stay BODY fetches even with
3058
+ // options.binary set
3059
+ await fetchCommand(connection, '1', { headers: true, bodyParts: ['TEXT', '1.MIME', '1.2'] }, { binary: true });
3060
+ test.ok(queryAttrs);
3061
+ const sections = [];
3062
+ const walk = list => {
3063
+ for (let entry of Array.isArray(list) ? list : [list]) {
3064
+ if (Array.isArray(entry)) {
3065
+ walk(entry);
3066
+ } else if (entry && entry.section) {
3067
+ sections.push({ value: entry.value, section: entry.section.length ? entry.section[0].value : '' });
3068
+ }
3069
+ }
3070
+ };
3071
+ walk(queryAttrs);
3072
+
3073
+ for (let entry of sections) {
3074
+ if (['HEADER', 'TEXT', '1.MIME'].includes(entry.section)) {
3075
+ test.equal(entry.value, 'BODY.PEEK', `${entry.section} must be fetched via BODY.PEEK`);
3076
+ }
3077
+ if (entry.section === '1.2') {
3078
+ test.equal(entry.value, 'BINARY.PEEK', 'numeric part specifiers may use BINARY.PEEK');
3079
+ }
3080
+ }
3081
+ test.ok(
3082
+ sections.some(entry => entry.section === '1.2'),
3083
+ 'numeric body part present'
3084
+ );
3085
+ test.ok(
3086
+ sections.some(entry => entry.section === 'TEXT'),
3087
+ 'TEXT body part present'
3088
+ );
3089
+ test.done();
3090
+ };
3091
+
3092
+ module.exports['Commands: fetch with binary keeps BODY on unenabled dual rev1+rev2 servers'] = async test => {
3093
+ let queryAttrs = null;
3094
+ const connection = createMockConnection({
3095
+ state: 3,
3096
+ // dual server without ENABLE IMAP4rev2 - rev2 semantics are not active, so
3097
+ // the BINARY fold must not apply
3098
+ capabilities: new Map([
3099
+ ['IMAP4rev1', true],
3100
+ ['IMAP4rev2', true]
3101
+ ]),
3102
+ exec: async (cmd, attrs) => {
3103
+ queryAttrs = attrs;
3104
+ return { next: () => {} };
3105
+ }
3106
+ });
3107
+
3108
+ await fetchCommand(connection, '1', { source: true }, { binary: true });
3109
+ test.ok(queryAttrs);
3110
+ const queryStr = JSON.stringify(queryAttrs);
3111
+ test.ok(queryStr.includes('BODY.PEEK'));
3112
+ test.ok(!queryStr.includes('BINARY.PEEK'));
3113
+ test.done();
3114
+ };
3115
+
2978
3116
  module.exports['Commands: fetch with OBJECTID capability'] = async test => {
2979
3117
  let queryAttrs = null;
2980
3118
  const connection = createMockConnection({
@@ -3453,6 +3591,71 @@ module.exports['Commands: list with statusQuery'] = async test => {
3453
3591
  test.done();
3454
3592
  };
3455
3593
 
3594
+ module.exports['Commands: list statusQuery parses inline SIZE and DELETED on rev2 sessions'] = async test => {
3595
+ let listAttrs = null;
3596
+ const connection = createMockConnection({
3597
+ state: 3,
3598
+ capabilities: new Map([['IMAP4rev2', true]]),
3599
+ exec: async (cmd, attrs, opts) => {
3600
+ if (cmd === 'LIST') {
3601
+ listAttrs = attrs;
3602
+ if (opts && opts.untagged && opts.untagged.LIST) {
3603
+ await opts.untagged.LIST({
3604
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
3605
+ });
3606
+ }
3607
+ if (opts && opts.untagged && opts.untagged.STATUS) {
3608
+ await opts.untagged.STATUS({
3609
+ attributes: [
3610
+ { value: 'INBOX' },
3611
+ [{ value: 'MESSAGES' }, { value: '10' }, { value: 'SIZE' }, { value: '12345678901234' }, { value: 'DELETED' }, { value: '3' }]
3612
+ ]
3613
+ });
3614
+ }
3615
+ }
3616
+ return { next: () => {} };
3617
+ }
3618
+ });
3619
+
3620
+ const result = await listCommand(connection, '', '*', {
3621
+ statusQuery: { messages: true, size: true, deleted: true }
3622
+ });
3623
+ const attrsStr = JSON.stringify(listAttrs);
3624
+ test.ok(attrsStr.includes('SIZE'));
3625
+ test.ok(attrsStr.includes('DELETED'));
3626
+ const inbox = result.find(entry => entry.path === 'INBOX');
3627
+ test.ok(inbox);
3628
+ test.equal(inbox.status.messages, 10);
3629
+ // STATUS SIZE is a number64 - values beyond 2^32 must survive
3630
+ test.strictEqual(inbox.status.size, 12345678901234);
3631
+ test.strictEqual(inbox.status.deleted, 3);
3632
+ test.done();
3633
+ };
3634
+
3635
+ module.exports['Commands: list tolerates an OLDNAME extended data item'] = async test => {
3636
+ const connection = createMockConnection({
3637
+ state: 3,
3638
+ capabilities: new Map([['IMAP4rev2', true]]),
3639
+ exec: async (cmd, attrs, opts) => {
3640
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
3641
+ // RFC 9051 6.3.9.7: a LIST response may carry an OLDNAME extended
3642
+ // data item after a RENAME or name normalization - the client must
3643
+ // parse the response without choking on the extra attribute
3644
+ await opts.untagged.LIST({
3645
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'NewBox' }, [{ value: 'OLDNAME' }, [{ value: 'OldBox' }]]]
3646
+ });
3647
+ }
3648
+ return { next: () => {} };
3649
+ }
3650
+ });
3651
+
3652
+ const result = await listCommand(connection, '', '*');
3653
+ const entry = result.find(folder => folder.path === 'NewBox');
3654
+ test.ok(entry, 'mailbox with OLDNAME extended data must be listed');
3655
+ test.ok(entry.flags.has('\\HasNoChildren'));
3656
+ test.done();
3657
+ };
3658
+
3456
3659
  module.exports['Commands: list with CONDSTORE status query'] = async test => {
3457
3660
  let listAttrs = null;
3458
3661
  const connection = createMockConnection({
@@ -5742,6 +5945,108 @@ module.exports['Commands: status merges synthetic recent into rev2 query results
5742
5945
  test.done();
5743
5946
  };
5744
5947
 
5948
+ module.exports['Commands: status requests and parses SIZE and DELETED on rev2 sessions'] = async test => {
5949
+ let queryAttrs = null;
5950
+ const connection = createMockConnection({
5951
+ state: 2,
5952
+ // rev2-only server: STATUS=SIZE is folded in and DELETED is a base rev2
5953
+ // status item (RFC 9051 Appendix E item 3)
5954
+ capabilities: new Map([['IMAP4rev2', true]]),
5955
+ exec: async (cmd, attrs, opts) => {
5956
+ queryAttrs = JSON.stringify(attrs);
5957
+ await opts.untagged.STATUS({
5958
+ attributes: [
5959
+ { value: 'INBOX' },
5960
+ [{ value: 'MESSAGES' }, { value: '100' }, { value: 'SIZE' }, { value: '12345678901234' }, { value: 'DELETED' }, { value: '3' }]
5961
+ ]
5962
+ });
5963
+ return { next: () => {} };
5964
+ }
5965
+ });
5966
+
5967
+ const result = await statusCommand(connection, 'INBOX', { messages: true, size: true, deleted: true });
5968
+ test.ok(queryAttrs.includes('SIZE'));
5969
+ test.ok(queryAttrs.includes('DELETED'));
5970
+ test.equal(result.messages, 100);
5971
+ // STATUS SIZE is a number64 - values beyond 2^32 must survive
5972
+ test.strictEqual(result.size, 12345678901234);
5973
+ test.strictEqual(result.deleted, 3);
5974
+ test.done();
5975
+ };
5976
+
5977
+ module.exports['Commands: status requests SIZE with the STATUS=SIZE token on rev1 sessions'] = async test => {
5978
+ let queryAttrs = null;
5979
+ const connection = createMockConnection({
5980
+ state: 2,
5981
+ // RFC 8438 server: SIZE is available via the capability token, DELETED is
5982
+ // rev2-only and must be dropped
5983
+ capabilities: new Map([
5984
+ ['IMAP4rev1', true],
5985
+ ['STATUS=SIZE', true]
5986
+ ]),
5987
+ exec: async (cmd, attrs, opts) => {
5988
+ queryAttrs = JSON.stringify(attrs);
5989
+ await opts.untagged.STATUS({
5990
+ attributes: [{ value: 'INBOX' }, [{ value: 'SIZE' }, { value: '2048' }]]
5991
+ });
5992
+ return { next: () => {} };
5993
+ }
5994
+ });
5995
+
5996
+ const result = await statusCommand(connection, 'INBOX', { size: true, deleted: true });
5997
+ test.ok(queryAttrs.includes('SIZE'));
5998
+ test.ok(!queryAttrs.includes('DELETED'));
5999
+ test.strictEqual(result.size, 2048);
6000
+ test.done();
6001
+ };
6002
+
6003
+ module.exports['Commands: status requests DELETED with QUOTA=RES-MESSAGE on rev1 sessions'] = async test => {
6004
+ let queryAttrs = null;
6005
+ const connection = createMockConnection({
6006
+ state: 2,
6007
+ // RFC 9208: the DELETED status item is mandatory when QUOTA=RES-MESSAGE
6008
+ // is advertised, even without IMAP4rev2
6009
+ capabilities: new Map([
6010
+ ['IMAP4rev1', true],
6011
+ ['QUOTA=RES-MESSAGE', true]
6012
+ ]),
6013
+ exec: async (cmd, attrs, opts) => {
6014
+ queryAttrs = JSON.stringify(attrs);
6015
+ await opts.untagged.STATUS({
6016
+ attributes: [{ value: 'INBOX' }, [{ value: 'DELETED' }, { value: '4' }]]
6017
+ });
6018
+ return { next: () => {} };
6019
+ }
6020
+ });
6021
+
6022
+ const result = await statusCommand(connection, 'INBOX', { deleted: true });
6023
+ test.ok(queryAttrs.includes('DELETED'));
6024
+ test.strictEqual(result.deleted, 4);
6025
+ test.done();
6026
+ };
6027
+
6028
+ module.exports['Commands: status drops SIZE and DELETED on rev1 sessions without support'] = async test => {
6029
+ let queryAttrs = null;
6030
+ const connection = createMockConnection({
6031
+ state: 2,
6032
+ exec: async (cmd, attrs, opts) => {
6033
+ queryAttrs = JSON.stringify(attrs);
6034
+ await opts.untagged.STATUS({
6035
+ attributes: [{ value: 'INBOX' }, [{ value: 'MESSAGES' }, { value: '100' }]]
6036
+ });
6037
+ return { next: () => {} };
6038
+ }
6039
+ });
6040
+
6041
+ // requesting them must not poison the whole STATUS command on a server that
6042
+ // does not know these items
6043
+ const result = await statusCommand(connection, 'INBOX', { messages: true, size: true, deleted: true });
6044
+ test.ok(!queryAttrs.includes('SIZE'));
6045
+ test.ok(!queryAttrs.includes('DELETED'));
6046
+ test.equal(result.messages, 100);
6047
+ test.done();
6048
+ };
6049
+
5745
6050
  module.exports['Commands: status skips when all query values are false'] = async test => {
5746
6051
  const connection = createMockConnection({ state: 2 });
5747
6052
 
@@ -7591,6 +7896,30 @@ module.exports['Commands: namespace with NAMESPACE capability'] = async test =>
7591
7896
  test.done();
7592
7897
  };
7593
7898
 
7899
+ module.exports['Commands: namespace uses the real command on rev2-only servers without the token'] = async test => {
7900
+ const connection = createMockConnection({
7901
+ state: 2,
7902
+ // NAMESPACE is folded into base IMAP4rev2 (RFC 9051 Appendix E) - a
7903
+ // rev2-only server gets a real NAMESPACE command, not the LIST fallback
7904
+ capabilities: new Map([['IMAP4rev2', true]]),
7905
+ exec: async (cmd, args, opts) => {
7906
+ test.equal(cmd, 'NAMESPACE');
7907
+ if (opts && opts.untagged && opts.untagged.NAMESPACE) {
7908
+ await opts.untagged.NAMESPACE({
7909
+ attributes: [[[{ value: '' }, { value: '/' }]], null, null]
7910
+ });
7911
+ }
7912
+ return { next: () => {} };
7913
+ }
7914
+ });
7915
+
7916
+ const result = await namespaceCommand(connection);
7917
+ test.ok(result);
7918
+ test.equal(result.prefix, '');
7919
+ test.equal(result.delimiter, '/');
7920
+ test.done();
7921
+ };
7922
+
7594
7923
  module.exports['Commands: namespace fallback without capability'] = async test => {
7595
7924
  const connection = createMockConnection({
7596
7925
  state: 2, // AUTHENTICATED
@@ -608,46 +608,10 @@ module.exports['Connection Edge: Race condition in mailbox lock'] = test => {
608
608
  });
609
609
  };
610
610
 
611
- module.exports['Connection Edge: Capability update after STARTTLS'] = test => {
612
- let client = new ImapFlow({
613
- host: 'imap.example.com',
614
- port: 143,
615
- auth: { user: 'test', pass: 'test' }
616
- });
617
-
618
- // Mock capabilities
619
- client.capabilities = new Map();
620
- client.capabilities.set('STARTTLS', true);
621
-
622
- // Mock run method
623
- client.run = async command => {
624
- if (command === 'STARTTLS') {
625
- client.expectCapabilityUpdate = true;
626
- return true;
627
- }
628
- if (command === 'CAPABILITY') {
629
- return true;
630
- }
631
- };
632
-
633
- // Mock socket upgrade
634
- client.socket = new EventEmitter();
635
- client.socket.unpipe = () => {};
636
- client.streamer = new EventEmitter();
637
-
638
- // Override the upgradeToSTARTTLS to test capability update
639
- client
640
- .upgradeToSTARTTLS()
641
- .then(result => {
642
- test.ok(result, 'Should successfully upgrade to TLS');
643
- test.ok(client.expectCapabilityUpdate, 'Should expect capability update');
644
- test.done();
645
- })
646
- .catch(() => {
647
- // Expected for this mock setup
648
- test.done();
649
- });
650
- };
611
+ // NB! the capability discard/re-fetch behavior around STARTTLS is asserted end to
612
+ // end in imap-flow-secure-test.js ('Secure: STARTTLS upgrade completes a session',
613
+ // PRETLS-ONLY/POSTTLS-ONLY markers) - a mocked upgradeToSTARTTLS cannot reach the
614
+ // re-fetch code without a real TLS handshake, so no vacuous variant is kept here.
651
615
 
652
616
  module.exports['Connection Edge: Event handlers attached before piping'] = test => {
653
617
  let client = new ImapFlow({
@@ -115,8 +115,9 @@ module.exports['imap-compiler: synchronizing literal with non-buffer value seeds
115
115
  {
116
116
  tag: '*',
117
117
  command: 'CMD',
118
- // value is an object that is neither string/number/Buffer but has a numeric
119
- // length, so literalLength resolves to 10 while formatRespEntry(value, true)
118
+ // value is an object that is neither string/number/Buffer, so the size
119
+ // marker derives from the UTF-8 byte length of its stringified form
120
+ // ('[object Object]', 15 bytes) while formatRespEntry(value, true)
120
121
  // returns null -> the seeded resp falls back to [] (line 156 right side).
121
122
  attributes: [{ type: 'LITERAL', value: { length: 10 } }]
122
123
  },
@@ -125,7 +126,7 @@ module.exports['imap-compiler: synchronizing literal with non-buffer value seeds
125
126
  ).map(entry => entry.toString());
126
127
  // Only the header segment is emitted; the empty seeded data segment is dropped
127
128
  // because `if (resp.length)` is false at the end.
128
- test.deepEqual(out, ['* CMD {10}\r\n']);
129
+ test.deepEqual(out, ['* CMD {15}\r\n']);
129
130
  });
130
131
 
131
132
  // STRING node with falsy value -> `(node.value || '')` right side (line 165).
@@ -362,6 +362,91 @@ module.exports['IMAP Compiler: LITERAL literal8'] = test =>
362
362
  )
363
363
  );
364
364
 
365
+ // RFC 7888 (folded into IMAP4rev2): with LITERAL- a non-synchronizing literal
366
+ // marker {n+} is only allowed for literals of up to 4096 bytes - anything larger
367
+ // must use the synchronizing {n} form, in every compilation mode
368
+ module.exports['IMAP Compiler: LITERAL- oversized literal stays synchronizing in single-buffer mode'] = test =>
369
+ asyncWrapper(test, async test => {
370
+ let payload = 'a'.repeat(4097);
371
+ let compiled = (
372
+ await compiler(
373
+ {
374
+ tag: '*',
375
+ command: 'CMD',
376
+ attributes: [
377
+ {
378
+ type: 'LITERAL',
379
+ value: payload
380
+ }
381
+ ]
382
+ },
383
+ { literalMinus: true }
384
+ )
385
+ ).toString();
386
+ test.ok(compiled.startsWith('* CMD {4097}\r\n'), `must not use a non-synchronizing marker: ${compiled.slice(0, 20)}`);
387
+ });
388
+
389
+ module.exports['IMAP Compiler: LITERAL- boundary: exactly 4096 bytes is non-synchronizing'] = test =>
390
+ asyncWrapper(test, async test => {
391
+ let payload = 'a'.repeat(4096);
392
+ let parts = (
393
+ await compiler(
394
+ {
395
+ tag: '*',
396
+ command: 'CMD',
397
+ attributes: [
398
+ {
399
+ type: 'LITERAL',
400
+ value: payload
401
+ }
402
+ ]
403
+ },
404
+ { asArray: true, literalMinus: true }
405
+ )
406
+ ).map(entry => entry.toString());
407
+ test.equal(parts.length, 1, 'literal must be appended inline without a continuation break');
408
+ test.ok(parts[0].startsWith('* CMD {4096+}\r\n'));
409
+ });
410
+
411
+ module.exports['IMAP Compiler: LITERAL- boundary: 4097 bytes falls back to synchronizing'] = test =>
412
+ asyncWrapper(test, async test => {
413
+ let payload = 'a'.repeat(4097);
414
+ let parts = (
415
+ await compiler(
416
+ {
417
+ tag: '*',
418
+ command: 'CMD',
419
+ attributes: [
420
+ {
421
+ type: 'LITERAL',
422
+ value: payload
423
+ }
424
+ ]
425
+ },
426
+ { asArray: true, literalMinus: true }
427
+ )
428
+ ).map(entry => entry.toString());
429
+ test.equal(parts.length, 2, 'literal must wait for a continuation response');
430
+ test.ok(parts[0].endsWith('{4097}\r\n'), `marker must be synchronizing: ${parts[0].slice(-12)}`);
431
+ });
432
+
433
+ // The literal size marker counts octets, not UTF-16 code units - a unicode string
434
+ // value must declare its UTF-8 byte length
435
+ module.exports['IMAP Compiler: LITERAL declares byte length for unicode string values'] = test =>
436
+ asyncWrapper(test, async test => {
437
+ let compiled = await compiler({
438
+ tag: '*',
439
+ command: 'CMD',
440
+ attributes: [
441
+ {
442
+ type: 'LITERAL',
443
+ value: 'Sõnumid'
444
+ }
445
+ ]
446
+ });
447
+ test.equal(compiled.toString(), '* CMD {8}\r\nSõnumid');
448
+ });
449
+
365
450
  module.exports['IMAP Compiler: LITERAL array 1'] = test =>
366
451
  asyncWrapper(test, async test =>
367
452
  test.deepEqual(
@@ -300,6 +300,62 @@ module.exports['Download: base64 body part is decoded'] = async test => {
300
300
  test.done();
301
301
  };
302
302
 
303
+ module.exports['Download: binary-fetched part skips the client-side transfer decoding'] = async test => {
304
+ let client = makeClient();
305
+ // BINARY fetch (RFC 3516, FETCH side folded into IMAP4rev2): the server
306
+ // decodes the content-transfer-encoding itself, so the client gets raw bytes
307
+ // even though the MIME headers still declare base64 - decoding again would
308
+ // corrupt the data. formatMessageResponse records the parts that arrived via
309
+ // BINARY[...] in the binaryParts set, which the mock mirrors here.
310
+ let mime = Buffer.from('Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\n');
311
+ let decoded = Buffer.from('Hello World');
312
+ client.fetchOne = async () => {
313
+ let bodyParts = new Map();
314
+ bodyParts.set('2.mime', mime);
315
+ bodyParts.set('2', decoded);
316
+ return { uid: 1, size: decoded.length, bodyParts, binaryParts: new Set(['2']) };
317
+ };
318
+ let { meta, content } = await client.download('1', '2', { chunkSize: 1024, binary: true });
319
+ test.equal(meta.encoding, 'base64');
320
+ let data = await collect(content);
321
+ test.equal(data.toString(), 'Hello World');
322
+ test.done();
323
+ };
324
+
325
+ module.exports['DownloadMany: binary-fetched parts skip the client-side transfer decoding'] = async test => {
326
+ let client = makeClient();
327
+ let mime = Buffer.from('Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\n');
328
+ client.fetchOne = async () => {
329
+ let bodyParts = new Map();
330
+ bodyParts.set('2.mime', mime);
331
+ bodyParts.set('2', Buffer.from('part two'));
332
+ return { uid: 1, bodyParts, binaryParts: new Set(['2']) };
333
+ };
334
+ let res = await client.downloadMany('1', ['2'], { binary: true });
335
+ test.equal(res['2'].content.toString(), 'part two');
336
+ test.equal(res['2'].meta.encoding, 'base64');
337
+ test.done();
338
+ };
339
+
340
+ module.exports['Download: BODY-answered part is still decoded even when binary was requested'] = async test => {
341
+ let client = makeClient();
342
+ // RFC 3516 lets a server answer a BINARY request with a NO or a BODY response
343
+ // (e.g. [UNKNOWN-CTE]) - decoding must follow what actually arrived, so a part
344
+ // absent from binaryParts gets the client-side decoder despite options.binary
345
+ let mime = Buffer.from('Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\n');
346
+ let encoded = Buffer.from(libbase64.encode(Buffer.from('Hello World')));
347
+ client.fetchOne = async () => {
348
+ let bodyParts = new Map();
349
+ bodyParts.set('2.mime', mime);
350
+ bodyParts.set('2', encoded);
351
+ return { uid: 1, size: encoded.length, bodyParts };
352
+ };
353
+ let { content } = await client.download('1', '2', { chunkSize: 1024, binary: true });
354
+ let data = await collect(content);
355
+ test.equal(data.toString(), 'Hello World');
356
+ test.done();
357
+ };
358
+
303
359
  module.exports['Download: quoted-printable inline text with charset + filename'] = async test => {
304
360
  let client = makeClient();
305
361
  let mime = Buffer.from(
@@ -13,13 +13,17 @@ const CAPS = 'IMAP4rev1 ID ENABLE NAMESPACE';
13
13
 
14
14
  // Shared per-connection IMAP line handler used by both the plaintext and the
15
15
  // upgraded TLS phases. Returns responses for the minimal session commands.
16
- const handleLine = (sock, line, onStartTls) => {
16
+ // `caps` is the full capability list to advertise for this phase - the STARTTLS
17
+ // test uses different pre- and post-TLS sets to prove the client discards the
18
+ // plaintext capabilities and re-fetches them over TLS (RFC 9051 6.2.1).
19
+ const handleLine = (sock, line, onStartTls, caps) => {
20
+ caps = caps || `${CAPS} STARTTLS`;
17
21
  let parts = line.split(' ');
18
22
  let tag = parts[0];
19
23
  let cmd = (parts[1] || '').toUpperCase();
20
24
  switch (cmd) {
21
25
  case 'CAPABILITY':
22
- sock.write(`* CAPABILITY ${CAPS} STARTTLS\r\n${tag} OK CAPABILITY done\r\n`);
26
+ sock.write(`* CAPABILITY ${caps}\r\n${tag} OK CAPABILITY done\r\n`);
23
27
  break;
24
28
  case 'STARTTLS':
25
29
  sock.write(`${tag} OK Begin TLS\r\n`);
@@ -81,17 +85,23 @@ module.exports['Secure: STARTTLS upgrade completes a session'] = async test => {
81
85
 
82
86
  let detachPlain;
83
87
  detachPlain = lineReader(rawSocket, line => {
84
- handleLine(rawSocket, line, () => {
85
- // Upgrade: stop reading plaintext, wrap the socket in TLS
86
- detachPlain();
87
- let tlsSocket = new tls.TLSSocket(rawSocket, { isServer: true, key, cert });
88
- tlsSocket.on('error', () => {});
89
- tlsSocket.on('secure', () => {});
90
- lineReader(tlsSocket, l => handleLine(tlsSocket, l));
91
- });
88
+ handleLine(
89
+ rawSocket,
90
+ line,
91
+ () => {
92
+ // Upgrade: stop reading plaintext, wrap the socket in TLS
93
+ detachPlain();
94
+ let tlsSocket = new tls.TLSSocket(rawSocket, { isServer: true, key, cert });
95
+ tlsSocket.on('error', () => {});
96
+ tlsSocket.on('secure', () => {});
97
+ // post-TLS phase advertises a different capability set
98
+ lineReader(tlsSocket, l => handleLine(tlsSocket, l, null, `${CAPS} POSTTLS-ONLY`));
99
+ },
100
+ `${CAPS} STARTTLS PRETLS-ONLY`
101
+ );
92
102
  });
93
103
 
94
- rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS] ready\r\n`);
104
+ rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS PRETLS-ONLY] ready\r\n`);
95
105
  });
96
106
 
97
107
  let port = await listen(server);
@@ -113,6 +123,10 @@ module.exports['Secure: STARTTLS upgrade completes a session'] = async test => {
113
123
  test.ok(client.secureConnection, 'connection upgraded to TLS');
114
124
  test.ok(client.authenticated, 'authenticated over TLS');
115
125
  test.ok(client.usable);
126
+ // RFC 9051 6.2.1: capabilities cached before STARTTLS MUST be discarded and
127
+ // re-fetched over the TLS channel
128
+ test.ok(client.capabilities.has('POSTTLS-ONLY'), 'post-TLS capabilities were re-fetched');
129
+ test.ok(!client.capabilities.has('PRETLS-ONLY'), 'pre-TLS capabilities were discarded');
116
130
 
117
131
  await client.noop();
118
132
  await client.logout();