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.
- package/.github/workflows/test.yml +20 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/CLAUDE.md +2 -4
- package/lib/commands/fetch.js +18 -14
- package/lib/commands/list.js +4 -1
- package/lib/commands/status.js +6 -1
- package/lib/handler/imap-compiler.js +12 -9
- package/lib/handler/token-parser.js +7 -0
- package/lib/imap-flow.d.ts +14 -0
- package/lib/imap-flow.js +19 -2
- package/lib/tools.js +35 -3
- package/package.json +1 -1
- package/test/commands-integration-test.js +329 -0
- package/test/connection-edge-cases-test.js +4 -40
- package/test/handler-branches-test.js +4 -3
- package/test/imap-compiler-test.js +85 -0
- package/test/imap-flow-fetch-download-test.js +56 -0
- package/test/imap-flow-secure-test.js +25 -11
- package/test/imap-flow-server-test.js +80 -0
- package/test/imap-parser-test.js +112 -1
- package/test/imap-stream-test.js +46 -0
- package/test/integration/README.md +17 -5
- package/test/integration/rev2-live-test.js +125 -0
- package/test/integration/run-rev2-tests.sh +14 -0
- package/test/search-compiler-test.js +17 -0
- package/test/tools-test.js +42 -4
|
@@ -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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
auth: { user: 'test', pass: 'test' }
|
|
616
|
-
});
|
|
617
|
-
|
|
618
|
-
// Mock capabilities
|
|
619
|
-
client.capabilities = new Map();
|
|
620
|
-
client.capabilities.set('STARTTLS', true);
|
|
621
|
-
|
|
622
|
-
// Mock run method
|
|
623
|
-
client.run = async command => {
|
|
624
|
-
if (command === 'STARTTLS') {
|
|
625
|
-
client.expectCapabilityUpdate = true;
|
|
626
|
-
return true;
|
|
627
|
-
}
|
|
628
|
-
if (command === 'CAPABILITY') {
|
|
629
|
-
return true;
|
|
630
|
-
}
|
|
631
|
-
};
|
|
632
|
-
|
|
633
|
-
// Mock socket upgrade
|
|
634
|
-
client.socket = new EventEmitter();
|
|
635
|
-
client.socket.unpipe = () => {};
|
|
636
|
-
client.streamer = new EventEmitter();
|
|
637
|
-
|
|
638
|
-
// Override the upgradeToSTARTTLS to test capability update
|
|
639
|
-
client
|
|
640
|
-
.upgradeToSTARTTLS()
|
|
641
|
-
.then(result => {
|
|
642
|
-
test.ok(result, 'Should successfully upgrade to TLS');
|
|
643
|
-
test.ok(client.expectCapabilityUpdate, 'Should expect capability update');
|
|
644
|
-
test.done();
|
|
645
|
-
})
|
|
646
|
-
.catch(() => {
|
|
647
|
-
// Expected for this mock setup
|
|
648
|
-
test.done();
|
|
649
|
-
});
|
|
650
|
-
};
|
|
611
|
+
// NB! the capability discard/re-fetch behavior around STARTTLS is asserted end to
|
|
612
|
+
// end in imap-flow-secure-test.js ('Secure: STARTTLS upgrade completes a session',
|
|
613
|
+
// PRETLS-ONLY/POSTTLS-ONLY markers) - a mocked upgradeToSTARTTLS cannot reach the
|
|
614
|
+
// re-fetch code without a real TLS handshake, so no vacuous variant is kept here.
|
|
651
615
|
|
|
652
616
|
module.exports['Connection Edge: Event handlers attached before piping'] = test => {
|
|
653
617
|
let client = new ImapFlow({
|
|
@@ -115,8 +115,9 @@ module.exports['imap-compiler: synchronizing literal with non-buffer value seeds
|
|
|
115
115
|
{
|
|
116
116
|
tag: '*',
|
|
117
117
|
command: 'CMD',
|
|
118
|
-
// value is an object that is neither string/number/Buffer
|
|
119
|
-
//
|
|
118
|
+
// value is an object that is neither string/number/Buffer, so the size
|
|
119
|
+
// marker derives from the UTF-8 byte length of its stringified form
|
|
120
|
+
// ('[object Object]', 15 bytes) while formatRespEntry(value, true)
|
|
120
121
|
// returns null -> the seeded resp falls back to [] (line 156 right side).
|
|
121
122
|
attributes: [{ type: 'LITERAL', value: { length: 10 } }]
|
|
122
123
|
},
|
|
@@ -125,7 +126,7 @@ module.exports['imap-compiler: synchronizing literal with non-buffer value seeds
|
|
|
125
126
|
).map(entry => entry.toString());
|
|
126
127
|
// Only the header segment is emitted; the empty seeded data segment is dropped
|
|
127
128
|
// because `if (resp.length)` is false at the end.
|
|
128
|
-
test.deepEqual(out, ['* CMD {
|
|
129
|
+
test.deepEqual(out, ['* CMD {15}\r\n']);
|
|
129
130
|
});
|
|
130
131
|
|
|
131
132
|
// STRING node with falsy value -> `(node.value || '')` right side (line 165).
|
|
@@ -362,6 +362,91 @@ module.exports['IMAP Compiler: LITERAL literal8'] = test =>
|
|
|
362
362
|
)
|
|
363
363
|
);
|
|
364
364
|
|
|
365
|
+
// RFC 7888 (folded into IMAP4rev2): with LITERAL- a non-synchronizing literal
|
|
366
|
+
// marker {n+} is only allowed for literals of up to 4096 bytes - anything larger
|
|
367
|
+
// must use the synchronizing {n} form, in every compilation mode
|
|
368
|
+
module.exports['IMAP Compiler: LITERAL- oversized literal stays synchronizing in single-buffer mode'] = test =>
|
|
369
|
+
asyncWrapper(test, async test => {
|
|
370
|
+
let payload = 'a'.repeat(4097);
|
|
371
|
+
let compiled = (
|
|
372
|
+
await compiler(
|
|
373
|
+
{
|
|
374
|
+
tag: '*',
|
|
375
|
+
command: 'CMD',
|
|
376
|
+
attributes: [
|
|
377
|
+
{
|
|
378
|
+
type: 'LITERAL',
|
|
379
|
+
value: payload
|
|
380
|
+
}
|
|
381
|
+
]
|
|
382
|
+
},
|
|
383
|
+
{ literalMinus: true }
|
|
384
|
+
)
|
|
385
|
+
).toString();
|
|
386
|
+
test.ok(compiled.startsWith('* CMD {4097}\r\n'), `must not use a non-synchronizing marker: ${compiled.slice(0, 20)}`);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
module.exports['IMAP Compiler: LITERAL- boundary: exactly 4096 bytes is non-synchronizing'] = test =>
|
|
390
|
+
asyncWrapper(test, async test => {
|
|
391
|
+
let payload = 'a'.repeat(4096);
|
|
392
|
+
let parts = (
|
|
393
|
+
await compiler(
|
|
394
|
+
{
|
|
395
|
+
tag: '*',
|
|
396
|
+
command: 'CMD',
|
|
397
|
+
attributes: [
|
|
398
|
+
{
|
|
399
|
+
type: 'LITERAL',
|
|
400
|
+
value: payload
|
|
401
|
+
}
|
|
402
|
+
]
|
|
403
|
+
},
|
|
404
|
+
{ asArray: true, literalMinus: true }
|
|
405
|
+
)
|
|
406
|
+
).map(entry => entry.toString());
|
|
407
|
+
test.equal(parts.length, 1, 'literal must be appended inline without a continuation break');
|
|
408
|
+
test.ok(parts[0].startsWith('* CMD {4096+}\r\n'));
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
module.exports['IMAP Compiler: LITERAL- boundary: 4097 bytes falls back to synchronizing'] = test =>
|
|
412
|
+
asyncWrapper(test, async test => {
|
|
413
|
+
let payload = 'a'.repeat(4097);
|
|
414
|
+
let parts = (
|
|
415
|
+
await compiler(
|
|
416
|
+
{
|
|
417
|
+
tag: '*',
|
|
418
|
+
command: 'CMD',
|
|
419
|
+
attributes: [
|
|
420
|
+
{
|
|
421
|
+
type: 'LITERAL',
|
|
422
|
+
value: payload
|
|
423
|
+
}
|
|
424
|
+
]
|
|
425
|
+
},
|
|
426
|
+
{ asArray: true, literalMinus: true }
|
|
427
|
+
)
|
|
428
|
+
).map(entry => entry.toString());
|
|
429
|
+
test.equal(parts.length, 2, 'literal must wait for a continuation response');
|
|
430
|
+
test.ok(parts[0].endsWith('{4097}\r\n'), `marker must be synchronizing: ${parts[0].slice(-12)}`);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// The literal size marker counts octets, not UTF-16 code units - a unicode string
|
|
434
|
+
// value must declare its UTF-8 byte length
|
|
435
|
+
module.exports['IMAP Compiler: LITERAL declares byte length for unicode string values'] = test =>
|
|
436
|
+
asyncWrapper(test, async test => {
|
|
437
|
+
let compiled = await compiler({
|
|
438
|
+
tag: '*',
|
|
439
|
+
command: 'CMD',
|
|
440
|
+
attributes: [
|
|
441
|
+
{
|
|
442
|
+
type: 'LITERAL',
|
|
443
|
+
value: 'Sõnumid'
|
|
444
|
+
}
|
|
445
|
+
]
|
|
446
|
+
});
|
|
447
|
+
test.equal(compiled.toString(), '* CMD {8}\r\nSõnumid');
|
|
448
|
+
});
|
|
449
|
+
|
|
365
450
|
module.exports['IMAP Compiler: LITERAL array 1'] = test =>
|
|
366
451
|
asyncWrapper(test, async test =>
|
|
367
452
|
test.deepEqual(
|
|
@@ -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
|
-
|
|
16
|
+
// `caps` is the full capability list to advertise for this phase - the STARTTLS
|
|
17
|
+
// test uses different pre- and post-TLS sets to prove the client discards the
|
|
18
|
+
// plaintext capabilities and re-fetches them over TLS (RFC 9051 6.2.1).
|
|
19
|
+
const handleLine = (sock, line, onStartTls, caps) => {
|
|
20
|
+
caps = caps || `${CAPS} STARTTLS`;
|
|
17
21
|
let parts = line.split(' ');
|
|
18
22
|
let tag = parts[0];
|
|
19
23
|
let cmd = (parts[1] || '').toUpperCase();
|
|
20
24
|
switch (cmd) {
|
|
21
25
|
case 'CAPABILITY':
|
|
22
|
-
sock.write(`* CAPABILITY ${
|
|
26
|
+
sock.write(`* CAPABILITY ${caps}\r\n${tag} OK CAPABILITY done\r\n`);
|
|
23
27
|
break;
|
|
24
28
|
case 'STARTTLS':
|
|
25
29
|
sock.write(`${tag} OK Begin TLS\r\n`);
|
|
@@ -81,17 +85,23 @@ module.exports['Secure: STARTTLS upgrade completes a session'] = async test => {
|
|
|
81
85
|
|
|
82
86
|
let detachPlain;
|
|
83
87
|
detachPlain = lineReader(rawSocket, line => {
|
|
84
|
-
handleLine(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
88
|
+
handleLine(
|
|
89
|
+
rawSocket,
|
|
90
|
+
line,
|
|
91
|
+
() => {
|
|
92
|
+
// Upgrade: stop reading plaintext, wrap the socket in TLS
|
|
93
|
+
detachPlain();
|
|
94
|
+
let tlsSocket = new tls.TLSSocket(rawSocket, { isServer: true, key, cert });
|
|
95
|
+
tlsSocket.on('error', () => {});
|
|
96
|
+
tlsSocket.on('secure', () => {});
|
|
97
|
+
// post-TLS phase advertises a different capability set
|
|
98
|
+
lineReader(tlsSocket, l => handleLine(tlsSocket, l, null, `${CAPS} POSTTLS-ONLY`));
|
|
99
|
+
},
|
|
100
|
+
`${CAPS} STARTTLS PRETLS-ONLY`
|
|
101
|
+
);
|
|
92
102
|
});
|
|
93
103
|
|
|
94
|
-
rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS] ready\r\n`);
|
|
104
|
+
rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS PRETLS-ONLY] ready\r\n`);
|
|
95
105
|
});
|
|
96
106
|
|
|
97
107
|
let port = await listen(server);
|
|
@@ -113,6 +123,10 @@ module.exports['Secure: STARTTLS upgrade completes a session'] = async test => {
|
|
|
113
123
|
test.ok(client.secureConnection, 'connection upgraded to TLS');
|
|
114
124
|
test.ok(client.authenticated, 'authenticated over TLS');
|
|
115
125
|
test.ok(client.usable);
|
|
126
|
+
// RFC 9051 6.2.1: capabilities cached before STARTTLS MUST be discarded and
|
|
127
|
+
// re-fetched over the TLS channel
|
|
128
|
+
test.ok(client.capabilities.has('POSTTLS-ONLY'), 'post-TLS capabilities were re-fetched');
|
|
129
|
+
test.ok(!client.capabilities.has('PRETLS-ONLY'), 'pre-TLS capabilities were discarded');
|
|
116
130
|
|
|
117
131
|
await client.noop();
|
|
118
132
|
await client.logout();
|