imapflow 1.4.9 → 1.6.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 +22 -0
- package/CLAUDE.md +3 -5
- package/lib/commands/fetch.js +18 -14
- package/lib/commands/idle.js +197 -104
- package/lib/commands/list.js +19 -8
- package/lib/commands/quota.js +3 -0
- package/lib/commands/select.js +5 -0
- package/lib/commands/status.js +10 -1
- package/lib/connection-deadline.js +98 -0
- package/lib/handler/imap-compiler.js +20 -14
- package/lib/handler/imap-stream.js +141 -50
- package/lib/handler/limits.js +43 -0
- package/lib/handler/token-parser.js +38 -1
- package/lib/imap-flow.d.ts +47 -5
- package/lib/imap-flow.js +594 -283
- package/lib/proxy-connection.js +393 -98
- package/lib/special-use.js +660 -51
- package/lib/tools.js +52 -3
- package/package.json +2 -2
- package/test/commands-branches-test.js +17 -1
- package/test/commands-integration-test.js +353 -2
- package/test/connection-edge-cases-test.js +4 -40
- package/test/fixtures/fake-timers.js +115 -0
- package/test/handler-branches-test.js +4 -28
- package/test/idle-polling-test.js +349 -0
- package/test/imap-compiler-test.js +85 -0
- package/test/imap-flow-compress-test.js +12 -0
- package/test/imap-flow-coverage-test.js +3 -3
- package/test/imap-flow-fetch-download-test.js +56 -0
- package/test/imap-flow-internals-test.js +23 -0
- package/test/imap-flow-proxy-paths-test.js +151 -0
- package/test/imap-flow-secure-test.js +182 -9
- package/test/imap-flow-server-test.js +229 -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/parser-limits-test.js +274 -0
- package/test/proxy-connection-test.js +553 -442
- package/test/reliability-improvements-test.js +87 -0
- package/test/search-compiler-test.js +17 -0
- package/test/special-use-test.js +337 -0
- package/test/tag-correlation-test.js +333 -0
- package/test/timer-policy-test.js +214 -0
- package/test/tools-test.js +42 -4
|
@@ -797,6 +797,8 @@ module.exports['Server: PREAUTH greeting skips login'] = async test => {
|
|
|
797
797
|
await client.connect();
|
|
798
798
|
test.equal(client.state, client.states.AUTHENTICATED);
|
|
799
799
|
test.ok(client.usable);
|
|
800
|
+
// documented contract: `true` if the connection was authenticated by PREAUTH
|
|
801
|
+
test.strictEqual(client.authenticated, true);
|
|
800
802
|
|
|
801
803
|
await client.logout();
|
|
802
804
|
client.close();
|
|
@@ -845,6 +847,84 @@ module.exports['Server: unsolicited EXISTS and VANISHED reach the untagged handl
|
|
|
845
847
|
test.done();
|
|
846
848
|
};
|
|
847
849
|
|
|
850
|
+
module.exports['Server: rev2-shaped SELECT response is tolerated through the full pipeline'] = async test => {
|
|
851
|
+
// RFC 9051 requires rev2 servers to include an untagged LIST in the SELECT
|
|
852
|
+
// response and a CLOSED response code when another mailbox was selected, and
|
|
853
|
+
// to omit RECENT/UNSEEN. The client must consume such a response cleanly.
|
|
854
|
+
let server = createServer({
|
|
855
|
+
capabilities: 'IMAP4rev2 ID ENABLE NAMESPACE',
|
|
856
|
+
handlers: {
|
|
857
|
+
SELECT(ctx) {
|
|
858
|
+
ctx.write('* OK [CLOSED] Previous mailbox closed\r\n');
|
|
859
|
+
ctx.write('* 3 EXISTS\r\n');
|
|
860
|
+
ctx.write('* LIST () "/" INBOX\r\n');
|
|
861
|
+
ctx.write('* FLAGS (\\Seen \\Answered \\Flagged \\Deleted \\Draft)\r\n');
|
|
862
|
+
ctx.write('* OK [PERMANENTFLAGS (\\Seen \\*)] Limited\r\n');
|
|
863
|
+
ctx.write('* OK [UIDVALIDITY 12345] UIDs valid\r\n');
|
|
864
|
+
ctx.write('* OK [UIDNEXT 100] Predicted next UID\r\n');
|
|
865
|
+
ctx.ok('[READ-WRITE] SELECT completed');
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
});
|
|
869
|
+
let port = await listen(server);
|
|
870
|
+
let client = makeClient(port);
|
|
871
|
+
let errors = [];
|
|
872
|
+
client.on('error', err => errors.push(err));
|
|
873
|
+
|
|
874
|
+
await client.connect();
|
|
875
|
+
test.ok(client.capabilities.has('IMAP4rev2'));
|
|
876
|
+
|
|
877
|
+
let mailbox = await client.mailboxOpen('INBOX');
|
|
878
|
+
test.equal(mailbox.path, 'INBOX');
|
|
879
|
+
test.equal(mailbox.exists, 3);
|
|
880
|
+
test.equal(mailbox.uidNext, 100);
|
|
881
|
+
test.equal(mailbox.uidValidity, 12345n);
|
|
882
|
+
|
|
883
|
+
// re-select drives the CLOSED response code through the live pipeline again
|
|
884
|
+
let again = await client.mailboxOpen('INBOX');
|
|
885
|
+
test.equal(again.exists, 3);
|
|
886
|
+
test.equal(errors.length, 0, 'no errors from the rev2-shaped response');
|
|
887
|
+
|
|
888
|
+
await client.logout();
|
|
889
|
+
client.close();
|
|
890
|
+
server.close();
|
|
891
|
+
test.done();
|
|
892
|
+
};
|
|
893
|
+
|
|
894
|
+
module.exports['Server: unsolicited STATUS for another mailbox is tolerated'] = async test => {
|
|
895
|
+
// RFC 9051 (Appendix E item 20): with rev2, servers may push updates that are
|
|
896
|
+
// unrelated to the selected mailbox (e.g. a STATUS for another mailbox during
|
|
897
|
+
// IDLE). The client must ignore them without corrupting the selected state.
|
|
898
|
+
let server = createServer({
|
|
899
|
+
handlers: {
|
|
900
|
+
NOOP(ctx) {
|
|
901
|
+
ctx.write('* STATUS "Other" (MESSAGES 5 UNSEEN 1)\r\n');
|
|
902
|
+
ctx.ok('NOOP completed');
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
let port = await listen(server);
|
|
907
|
+
let client = makeClient(port);
|
|
908
|
+
let errors = [];
|
|
909
|
+
client.on('error', err => errors.push(err));
|
|
910
|
+
|
|
911
|
+
await client.connect();
|
|
912
|
+
await client.mailboxOpen('INBOX');
|
|
913
|
+
test.equal(client.mailbox.exists, 3);
|
|
914
|
+
|
|
915
|
+
await client.noop();
|
|
916
|
+
await new Promise(r => setTimeout(r, 20));
|
|
917
|
+
|
|
918
|
+
test.equal(client.mailbox.path, 'INBOX', 'selected mailbox unchanged');
|
|
919
|
+
test.equal(client.mailbox.exists, 3, 'selected mailbox message count unchanged');
|
|
920
|
+
test.equal(errors.length, 0, 'unsolicited STATUS must not raise errors');
|
|
921
|
+
|
|
922
|
+
await client.logout();
|
|
923
|
+
client.close();
|
|
924
|
+
server.close();
|
|
925
|
+
test.done();
|
|
926
|
+
};
|
|
927
|
+
|
|
848
928
|
module.exports['Server: invalid tagged response rejects with InvalidResponse'] = async test => {
|
|
849
929
|
let server = createServer({
|
|
850
930
|
handlers: {
|
|
@@ -1023,6 +1103,155 @@ module.exports['Server: proxy connection failure rejects connect'] = async test
|
|
|
1023
1103
|
test.done();
|
|
1024
1104
|
};
|
|
1025
1105
|
|
|
1106
|
+
module.exports['Server: close clears public session state'] = async test => {
|
|
1107
|
+
// Callers inspect these properties in reconnect logic, so they must not keep describing a
|
|
1108
|
+
// session that is gone.
|
|
1109
|
+
let server = createServer();
|
|
1110
|
+
let port = await listen(server);
|
|
1111
|
+
let client = makeClient(port);
|
|
1112
|
+
client.on('error', () => {});
|
|
1113
|
+
|
|
1114
|
+
let events = [];
|
|
1115
|
+
client.on('mailboxClose', mailbox => events.push({ event: 'mailboxClose', path: mailbox.path }));
|
|
1116
|
+
client.on('close', () => events.push({ event: 'close' }));
|
|
1117
|
+
|
|
1118
|
+
await client.connect();
|
|
1119
|
+
await client.mailboxOpen('INBOX');
|
|
1120
|
+
|
|
1121
|
+
test.ok(client.mailbox, 'a mailbox is selected');
|
|
1122
|
+
test.ok(client.authenticated, 'the session is authenticated');
|
|
1123
|
+
test.ok(client.currentSelectCommand, 'the select command is remembered for polling');
|
|
1124
|
+
|
|
1125
|
+
client.close();
|
|
1126
|
+
|
|
1127
|
+
test.equal(client.mailbox, false, 'mailbox state cleared');
|
|
1128
|
+
test.equal(client.currentSelectCommand, false, 'saved select command cleared');
|
|
1129
|
+
test.equal(client.authenticated, false, 'authentication state cleared');
|
|
1130
|
+
test.equal(client.preCheck, false, 'preCheck cleared');
|
|
1131
|
+
test.equal(client.usable, false, 'connection no longer usable');
|
|
1132
|
+
test.equal(client.idling, false, 'idling cleared');
|
|
1133
|
+
test.equal(client.state, client.states.LOGOUT, 'state is LOGOUT');
|
|
1134
|
+
|
|
1135
|
+
// The selected mailbox transitions to closed exactly once, before 'close'
|
|
1136
|
+
test.deepEqual(events, [{ event: 'mailboxClose', path: 'INBOX' }, { event: 'close' }], 'mailboxClose is emitted once, ahead of close');
|
|
1137
|
+
|
|
1138
|
+
// Repeated close() is idempotent and emits nothing more
|
|
1139
|
+
client.close();
|
|
1140
|
+
client.close();
|
|
1141
|
+
test.equal(events.length, 2, 'no duplicate events from repeated close()');
|
|
1142
|
+
|
|
1143
|
+
server.close();
|
|
1144
|
+
test.done();
|
|
1145
|
+
};
|
|
1146
|
+
|
|
1147
|
+
module.exports['Server: close without a selected mailbox emits no mailboxClose'] = async test => {
|
|
1148
|
+
let server = createServer();
|
|
1149
|
+
let port = await listen(server);
|
|
1150
|
+
let client = makeClient(port);
|
|
1151
|
+
client.on('error', () => {});
|
|
1152
|
+
|
|
1153
|
+
let mailboxCloseCount = 0;
|
|
1154
|
+
client.on('mailboxClose', () => mailboxCloseCount++);
|
|
1155
|
+
|
|
1156
|
+
await client.connect();
|
|
1157
|
+
client.close();
|
|
1158
|
+
|
|
1159
|
+
test.equal(mailboxCloseCount, 0, 'nothing to close, nothing emitted');
|
|
1160
|
+
server.close();
|
|
1161
|
+
test.done();
|
|
1162
|
+
};
|
|
1163
|
+
|
|
1164
|
+
module.exports['Server: GETQUOTA fallback releases the parser'] = async test => {
|
|
1165
|
+
// Regression: the GETQUOTA fallback never handed its response back to the reader, so the
|
|
1166
|
+
// parser stayed blocked on its backpressure callback and every later command hung.
|
|
1167
|
+
let server = createServer({
|
|
1168
|
+
capabilities: 'IMAP4rev1 ID ENABLE NAMESPACE QUOTA',
|
|
1169
|
+
handlers: {
|
|
1170
|
+
GETQUOTAROOT(ctx) {
|
|
1171
|
+
// root only, no inline QUOTA response - forces the fallback command
|
|
1172
|
+
ctx.write('* QUOTAROOT "INBOX" "userquota"\r\n');
|
|
1173
|
+
ctx.ok('GETQUOTAROOT completed');
|
|
1174
|
+
},
|
|
1175
|
+
GETQUOTA(ctx) {
|
|
1176
|
+
ctx.write('* QUOTA "userquota" (STORAGE 512 1024)\r\n');
|
|
1177
|
+
ctx.ok('GETQUOTA completed');
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
});
|
|
1181
|
+
let port = await listen(server);
|
|
1182
|
+
let client = makeClient(port);
|
|
1183
|
+
client.on('error', () => {});
|
|
1184
|
+
|
|
1185
|
+
await client.connect();
|
|
1186
|
+
|
|
1187
|
+
let quota = await client.getQuota();
|
|
1188
|
+
test.ok(quota, 'quota resolved');
|
|
1189
|
+
test.equal(quota.quotaRoot, 'userquota', 'quota root from the first command');
|
|
1190
|
+
test.equal(quota.storage.usage, 512 * 1024, 'quota usage from the fallback command');
|
|
1191
|
+
|
|
1192
|
+
// The parser must still be live: a following command has to complete.
|
|
1193
|
+
let noopResponse = await client.exec('NOOP', false, {});
|
|
1194
|
+
noopResponse.next();
|
|
1195
|
+
test.ok(noopResponse.response, 'the connection still processes commands after the fallback');
|
|
1196
|
+
|
|
1197
|
+
await client.logout();
|
|
1198
|
+
client.close();
|
|
1199
|
+
server.close();
|
|
1200
|
+
test.done();
|
|
1201
|
+
};
|
|
1202
|
+
|
|
1203
|
+
module.exports['Server: oversized literal cannot inject protocol'] = async test => {
|
|
1204
|
+
// Response-injection regression. With a lowered maxLiteralSize the parser rejects the
|
|
1205
|
+
// literal, and everything after the marker line is ordinary message content chosen by a
|
|
1206
|
+
// third party. None of it may be parsed: no untagged handler may fire and the forged
|
|
1207
|
+
// tagged completion may not settle the in-flight request.
|
|
1208
|
+
let server = createServer({
|
|
1209
|
+
handlers: {
|
|
1210
|
+
NOOP(ctx) {
|
|
1211
|
+
ctx.write(
|
|
1212
|
+
`* 1 FETCH (BODY[] {5000}\r\n` + //
|
|
1213
|
+
`INNOCENT MESSAGE TEXT\r\n` +
|
|
1214
|
+
`* 9999 EXISTS\r\n` +
|
|
1215
|
+
`${ctx.tag} OK forged completion\r\n`
|
|
1216
|
+
);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
});
|
|
1220
|
+
let port = await listen(server);
|
|
1221
|
+
let client = makeClient(port, { maxLiteralSize: 1024 });
|
|
1222
|
+
|
|
1223
|
+
let errors = [];
|
|
1224
|
+
let existsEvents = [];
|
|
1225
|
+
client.on('error', err => errors.push(err));
|
|
1226
|
+
client.on('exists', ev => existsEvents.push(ev));
|
|
1227
|
+
|
|
1228
|
+
await client.connect();
|
|
1229
|
+
let mailbox = await client.mailboxOpen('INBOX');
|
|
1230
|
+
test.equal(mailbox.exists, 3, 'mailbox opened with the server reported count');
|
|
1231
|
+
|
|
1232
|
+
let noopErr = null;
|
|
1233
|
+
try {
|
|
1234
|
+
// exec() surfaces the raw command outcome, so a forged tagged completion would show
|
|
1235
|
+
// up here as a resolved request (client.noop() would swallow it into `false`).
|
|
1236
|
+
await client.exec('NOOP', false, {});
|
|
1237
|
+
test.ok(false, 'the forged tagged completion must not resolve the in-flight command');
|
|
1238
|
+
} catch (err) {
|
|
1239
|
+
noopErr = err;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
test.ok(noopErr, 'the in-flight command rejected instead of accepting injected content');
|
|
1243
|
+
test.deepEqual(existsEvents, [], 'the injected untagged EXISTS never reached an untagged handler');
|
|
1244
|
+
test.ok(
|
|
1245
|
+
errors.some(err => err.code === 'LiteralTooLarge'),
|
|
1246
|
+
'the limit violation is surfaced to the caller'
|
|
1247
|
+
);
|
|
1248
|
+
test.ok(client.isClosed || !client.usable, 'the connection failed closed');
|
|
1249
|
+
|
|
1250
|
+
client.close();
|
|
1251
|
+
server.close();
|
|
1252
|
+
test.done();
|
|
1253
|
+
};
|
|
1254
|
+
|
|
1026
1255
|
module.exports['Server: socket close triggers close handling'] = async test => {
|
|
1027
1256
|
let server = createServer();
|
|
1028
1257
|
let port = await listen(server);
|
package/test/imap-parser-test.js
CHANGED
|
@@ -435,6 +435,115 @@ module.exports['IMAP Parser: Literals: allow zero length literal in the end of a
|
|
|
435
435
|
])
|
|
436
436
|
);
|
|
437
437
|
|
|
438
|
+
module.exports['IMAP Parser: Literals: zero length literal keeps the literal queue aligned'] = test =>
|
|
439
|
+
asyncWrapper(test, async test =>
|
|
440
|
+
// ImapStream queues a Buffer for every literal marker it extracts, including
|
|
441
|
+
// {0}, so the parser must consume exactly one queue entry per marker.
|
|
442
|
+
// Otherwise every literal after a {0} in the same response is silently
|
|
443
|
+
// shifted to the wrong value (RFC 9051 4.3 allows {0} as an empty string).
|
|
444
|
+
test.deepEqual((await parser('TAG1 CMD ({0}\r\n {5}\r\n)', { literals: [Buffer.from(''), Buffer.from('hello')] })).attributes, [
|
|
445
|
+
[
|
|
446
|
+
{
|
|
447
|
+
type: 'LITERAL',
|
|
448
|
+
value: Buffer.from('')
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
type: 'LITERAL',
|
|
452
|
+
value: Buffer.from('hello')
|
|
453
|
+
}
|
|
454
|
+
]
|
|
455
|
+
])
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
module.exports['IMAP Parser: Literals: zero length literal between literals keeps values aligned'] = test =>
|
|
459
|
+
asyncWrapper(test, async test =>
|
|
460
|
+
test.deepEqual(
|
|
461
|
+
(await parser('TAG1 CMD ({3}\r\n {0}\r\n {5}\r\n)', { literals: [Buffer.from('abc'), Buffer.from(''), Buffer.from('world')] })).attributes,
|
|
462
|
+
[
|
|
463
|
+
[
|
|
464
|
+
{
|
|
465
|
+
type: 'LITERAL',
|
|
466
|
+
value: Buffer.from('abc')
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
type: 'LITERAL',
|
|
470
|
+
value: Buffer.from('')
|
|
471
|
+
},
|
|
472
|
+
{
|
|
473
|
+
type: 'LITERAL',
|
|
474
|
+
value: Buffer.from('world')
|
|
475
|
+
}
|
|
476
|
+
]
|
|
477
|
+
]
|
|
478
|
+
)
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
// RFC 9051 updated resp-text to allow empty text: resp-text = ["[" resp-text-code "]" SP] [text]
|
|
482
|
+
module.exports['IMAP Parser: resp-text: bare OK with no text'] = test =>
|
|
483
|
+
asyncWrapper(test, async test => test.deepEqual(await parser('* OK'), { tag: '*', command: 'OK' }));
|
|
484
|
+
|
|
485
|
+
module.exports['IMAP Parser: resp-text: tagged OK with no text'] = test =>
|
|
486
|
+
asyncWrapper(test, async test => test.deepEqual(await parser('TAG1 OK'), { tag: 'TAG1', command: 'OK' }));
|
|
487
|
+
|
|
488
|
+
module.exports['IMAP Parser: resp-text: response code with no trailing text'] = test =>
|
|
489
|
+
asyncWrapper(test, async test => {
|
|
490
|
+
let parsed = await parser('* OK [UIDNEXT 5]');
|
|
491
|
+
test.equal(parsed.command, 'OK');
|
|
492
|
+
test.deepEqual(parsed.attributes, [
|
|
493
|
+
{
|
|
494
|
+
type: 'ATOM',
|
|
495
|
+
value: '',
|
|
496
|
+
section: [
|
|
497
|
+
{ type: 'ATOM', value: 'UIDNEXT' },
|
|
498
|
+
{ type: 'ATOM', value: '5' }
|
|
499
|
+
]
|
|
500
|
+
}
|
|
501
|
+
]);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
module.exports['IMAP Parser: resp-text: response code with trailing space and no text'] = test =>
|
|
505
|
+
asyncWrapper(test, async test => {
|
|
506
|
+
let parsed = await parser('* OK [READ-WRITE] ');
|
|
507
|
+
test.equal(parsed.command, 'OK');
|
|
508
|
+
test.deepEqual(parsed.attributes, [
|
|
509
|
+
{
|
|
510
|
+
type: 'ATOM',
|
|
511
|
+
value: '',
|
|
512
|
+
section: [{ type: 'ATOM', value: 'READ-WRITE' }]
|
|
513
|
+
}
|
|
514
|
+
]);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// RFC 9051 uses number64 (up to 2^63-1) for message and body sizes - values beyond
|
|
518
|
+
// 2^32 must survive the tokenizer without truncation
|
|
519
|
+
module.exports['IMAP Parser: number64: RFC822.SIZE beyond 32 bits'] = test =>
|
|
520
|
+
asyncWrapper(test, async test => {
|
|
521
|
+
let parsed = await parser('* 1 FETCH (RFC822.SIZE 12345678901234)');
|
|
522
|
+
test.deepEqual(parsed.attributes, [
|
|
523
|
+
{ type: 'ATOM', value: 'FETCH' },
|
|
524
|
+
[
|
|
525
|
+
{ type: 'ATOM', value: 'RFC822.SIZE' },
|
|
526
|
+
{ type: 'ATOM', value: '12345678901234' }
|
|
527
|
+
]
|
|
528
|
+
]);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
module.exports['IMAP Parser: number64: response code argument beyond 32 bits'] = test =>
|
|
532
|
+
asyncWrapper(test, async test => {
|
|
533
|
+
let parsed = await parser('* OK [HIGHESTMODSEQ 90060115194045007] Ok');
|
|
534
|
+
test.deepEqual(parsed.attributes, [
|
|
535
|
+
{
|
|
536
|
+
type: 'ATOM',
|
|
537
|
+
value: '',
|
|
538
|
+
section: [
|
|
539
|
+
{ type: 'ATOM', value: 'HIGHESTMODSEQ' },
|
|
540
|
+
{ type: 'ATOM', value: '90060115194045007' }
|
|
541
|
+
]
|
|
542
|
+
},
|
|
543
|
+
{ type: 'TEXT', value: 'Ok' }
|
|
544
|
+
]);
|
|
545
|
+
});
|
|
546
|
+
|
|
438
547
|
module.exports['IMAP Parser: Section: empty'] = test =>
|
|
439
548
|
asyncWrapper(test, async test =>
|
|
440
549
|
test.deepEqual((await parser('TAG1 CMD BODY[]')).attributes, [
|
|
@@ -1284,7 +1393,9 @@ module.exports['IMAP Parser, FETCH with BODYSTRUCTURE'] = test =>
|
|
|
1284
1393
|
]);
|
|
1285
1394
|
});
|
|
1286
1395
|
|
|
1287
|
-
|
|
1396
|
+
// NB! must not share a name with the deep-BODYSTRUCTURE test above - a duplicate
|
|
1397
|
+
// module.exports key silently overwrites the earlier test and it never runs
|
|
1398
|
+
module.exports['IMAP Parser, FETCH exceeding max nesting depth'] = test =>
|
|
1288
1399
|
asyncWrapper(test, async test => {
|
|
1289
1400
|
try {
|
|
1290
1401
|
let parsed = await parser('* 1 FETCH (UID 1 (((((((((((((((((((((((((');
|
package/test/imap-stream-test.js
CHANGED
|
@@ -66,6 +66,52 @@ A LOGOUT
|
|
|
66
66
|
writer().catch(err => test.ifError(err));
|
|
67
67
|
};
|
|
68
68
|
|
|
69
|
+
module.exports['Literal8 marker activates literal extraction'] = test => {
|
|
70
|
+
// RFC 9051 folds the FETCH side of BINARY into base IMAP4rev2 - servers answer
|
|
71
|
+
// BINARY fetches with literal8 syntax (~{n}) whose content may contain NULs.
|
|
72
|
+
// The stream must treat the trailing {n} as a literal marker regardless of the
|
|
73
|
+
// '~' prefix and hand the raw bytes over unmodified.
|
|
74
|
+
let input = Buffer.from('* 1 FETCH (BINARY[1] ~{5}\r\nhel\x00o)\r\n', 'binary');
|
|
75
|
+
|
|
76
|
+
let stream = new ImapStream();
|
|
77
|
+
|
|
78
|
+
let reading = false;
|
|
79
|
+
let reader = async () => {
|
|
80
|
+
let cmd;
|
|
81
|
+
while ((cmd = stream.read()) !== null) {
|
|
82
|
+
test.equal(cmd.payload.toString('binary'), '* 1 FETCH (BINARY[1] ~{5}\r\n)');
|
|
83
|
+
test.equal(cmd.literals.length, 1);
|
|
84
|
+
test.deepEqual(cmd.literals[0], Buffer.from('hel\x00o', 'binary'));
|
|
85
|
+
|
|
86
|
+
// and the parser consumes the literal8 into a LITERAL node with the NUL intact
|
|
87
|
+
let parsed = await parser(cmd.payload, { literals: cmd.literals });
|
|
88
|
+
test.deepEqual(parsed.attributes[1][1], { type: 'LITERAL', value: Buffer.from('hel\x00o', 'binary') });
|
|
89
|
+
cmd.next();
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
stream.on('readable', () => {
|
|
94
|
+
if (!reading) {
|
|
95
|
+
reading = true;
|
|
96
|
+
reader()
|
|
97
|
+
.catch(err => test.ifError(err))
|
|
98
|
+
.finally(() => {
|
|
99
|
+
reading = false;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
stream.on('error', err => {
|
|
105
|
+
test.ifError(err);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
stream.on('end', () => {
|
|
109
|
+
test.done();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
stream.end(input);
|
|
113
|
+
};
|
|
114
|
+
|
|
69
115
|
module.exports['Single byte'] = test => {
|
|
70
116
|
let input = Buffer.from(
|
|
71
117
|
`A CAPABILITY
|
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
Runs the ImapFlow client against a real IMAP4rev2 server - Dovecot 2.4+ in
|
|
4
4
|
Docker - instead of protocol mocks. Covers the ENABLE IMAP4rev2 negotiation,
|
|
5
5
|
LIST RETURN (SUBSCRIBED) without LSUB, subscription round-trips, UTF-8 mailbox
|
|
6
|
-
names, inline LIST-STATUS
|
|
7
|
-
|
|
6
|
+
names, inline LIST-STATUS (including the rev2 SIZE and DELETED status items),
|
|
7
|
+
ESEARCH responses to plain SEARCH, STATUS SIZE/DELETED, the rev2-shaped SELECT
|
|
8
|
+
response (untagged LIST, CLOSED on re-select), the folded-in FETCH BINARY,
|
|
9
|
+
COPYUID from the untagged OK on MOVE, and a message lifecycle smoke test with
|
|
10
|
+
UID EXPUNGE.
|
|
8
11
|
|
|
9
12
|
## Running
|
|
10
13
|
|
|
@@ -18,8 +21,16 @@ on `127.0.0.1:31143`, runs `rev2-live-test.js` with nodeunit, and always
|
|
|
18
21
|
removes the container afterwards.
|
|
19
22
|
|
|
20
23
|
These tests are intentionally not part of `npm test` - the Gruntfile nodeunit
|
|
21
|
-
config excludes `test/integration/**`, so
|
|
22
|
-
|
|
24
|
+
config excludes `test/integration/**`, so plain test runs stay Docker-free.
|
|
25
|
+
CI runs this suite in a dedicated `test-rev2` job on `ubuntu-latest` (amd64
|
|
26
|
+
with Docker preinstalled), forcing `IMAPFLOW_DOVECOT_PLATFORM=linux/amd64` -
|
|
27
|
+
that job is the authoritative linux/amd64 run, since Apple Silicon machines
|
|
28
|
+
cannot execute the amd64 image (see below).
|
|
29
|
+
|
|
30
|
+
If a local image for the configured tag exists but was pulled for a different
|
|
31
|
+
architecture than the Docker host (e.g. an amd64 image left behind on an arm64
|
|
32
|
+
host), the runner script detects the mismatch and re-pulls the host-native
|
|
33
|
+
variant before starting the container.
|
|
23
34
|
|
|
24
35
|
## Environment overrides
|
|
25
36
|
|
|
@@ -28,7 +39,8 @@ Docker-free.
|
|
|
28
39
|
- `IMAPFLOW_DOVECOT_PLATFORM` - e.g. `linux/amd64`; defaults to the host
|
|
29
40
|
platform. Forcing `linux/amd64` on Apple Silicon does not work - Rosetta
|
|
30
41
|
cannot start Dovecot's privilege-separated login processes
|
|
31
|
-
(`rosetta error: mmap_anonymous_rw mmap failed
|
|
42
|
+
(`rosetta error: mmap_anonymous_rw mmap failed`; reconfirmed with
|
|
43
|
+
dovecot/dovecot:2.4.4 in July 2026)
|
|
32
44
|
- `IMAPFLOW_TEST_PORT` - host port to publish (default 31143)
|
|
33
45
|
|
|
34
46
|
## Test account model
|
|
@@ -207,6 +207,131 @@ module.exports['Live rev2: returnOptions search is answered via a real ESEARCH r
|
|
|
207
207
|
test.done();
|
|
208
208
|
};
|
|
209
209
|
|
|
210
|
+
module.exports['Live rev2: STATUS reports SIZE and DELETED'] = async test => {
|
|
211
|
+
const logs = [];
|
|
212
|
+
const client = await connectClient(null, logs);
|
|
213
|
+
try {
|
|
214
|
+
const raw = Buffer.from('Subject: sized\r\n\r\nsized body\r\n');
|
|
215
|
+
await client.append('INBOX', raw, ['\\Deleted']);
|
|
216
|
+
await client.append('INBOX', Buffer.from('Subject: kept\r\n\r\nkept body\r\n'));
|
|
217
|
+
|
|
218
|
+
const status = await client.status('INBOX', { messages: true, size: true, deleted: true });
|
|
219
|
+
|
|
220
|
+
test.ok(clientSent(logs, 'SIZE'), 'STATUS should request SIZE');
|
|
221
|
+
test.ok(clientSent(logs, 'DELETED'), 'STATUS should request DELETED');
|
|
222
|
+
test.equal(status.messages, 2);
|
|
223
|
+
test.equal(status.deleted, 1, 'one message carries the \\Deleted flag');
|
|
224
|
+
test.ok(Number.isSafeInteger(status.size) && status.size >= raw.length, 'mailbox size should cover at least the first appended message');
|
|
225
|
+
} finally {
|
|
226
|
+
await client.logout();
|
|
227
|
+
}
|
|
228
|
+
test.done();
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
module.exports['Live rev2: statusQuery returns SIZE and DELETED inline via LIST-STATUS'] = async test => {
|
|
232
|
+
const client = await connectClient();
|
|
233
|
+
try {
|
|
234
|
+
await client.append('INBOX', Buffer.from('Subject: probe\r\n\r\nprobe body\r\n'), ['\\Deleted']);
|
|
235
|
+
|
|
236
|
+
const folders = await client.list({ statusQuery: { messages: true, size: true, deleted: true } });
|
|
237
|
+
const inbox = folders.find(folder => folder.path === 'INBOX');
|
|
238
|
+
test.equal(inbox.status.messages, 1);
|
|
239
|
+
test.equal(inbox.status.deleted, 1);
|
|
240
|
+
test.ok(Number.isSafeInteger(inbox.status.size) && inbox.status.size > 0);
|
|
241
|
+
} finally {
|
|
242
|
+
await client.logout();
|
|
243
|
+
}
|
|
244
|
+
test.done();
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
module.exports['Live rev2: SELECT response carries an untagged LIST and re-select gets CLOSED'] = async test => {
|
|
248
|
+
const logs = [];
|
|
249
|
+
const client = await connectClient(null, logs);
|
|
250
|
+
try {
|
|
251
|
+
await client.mailboxCreate('Closer');
|
|
252
|
+
const mailbox = await client.mailboxOpen('INBOX');
|
|
253
|
+
test.equal(mailbox.path, 'INBOX');
|
|
254
|
+
|
|
255
|
+
// RFC 9051 6.3.1: the SELECT response includes an untagged LIST for the
|
|
256
|
+
// selected mailbox - the client must consume it without issue
|
|
257
|
+
test.ok(serverSentUntagged(logs, 'LIST'), 'rev2 SELECT should include an untagged LIST response');
|
|
258
|
+
|
|
259
|
+
// switching mailboxes must produce a CLOSED response code for the old one
|
|
260
|
+
await client.mailboxOpen('Closer');
|
|
261
|
+
const closed = wireLines(logs).some(entry => entry.src === 's' && entry.msg.includes('[CLOSED]'));
|
|
262
|
+
test.ok(closed, 're-select should carry a CLOSED response code');
|
|
263
|
+
test.equal(client.mailbox.path, 'Closer', 'client state should track the newly selected mailbox');
|
|
264
|
+
|
|
265
|
+
await client.mailboxClose();
|
|
266
|
+
await client.mailboxDelete('Closer');
|
|
267
|
+
} finally {
|
|
268
|
+
await client.logout();
|
|
269
|
+
}
|
|
270
|
+
test.done();
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
module.exports['Live rev2: binary fetch uses the folded-in FETCH BINARY'] = async test => {
|
|
274
|
+
const logs = [];
|
|
275
|
+
const client = await connectClient(null, logs);
|
|
276
|
+
try {
|
|
277
|
+
// BINARY sections only allow numeric part specifiers, so use a multipart
|
|
278
|
+
// message - part "1" of a single-part message would resolve to the TEXT
|
|
279
|
+
// section, which must stay a BODY fetch. The base64 encoding gives the
|
|
280
|
+
// server-side BINARY decoding something to undo.
|
|
281
|
+
const content = [
|
|
282
|
+
'Subject: bin',
|
|
283
|
+
'MIME-Version: 1.0',
|
|
284
|
+
'Content-Type: multipart/mixed; boundary=bb',
|
|
285
|
+
'',
|
|
286
|
+
'--bb',
|
|
287
|
+
'Content-Type: text/plain',
|
|
288
|
+
'Content-Transfer-Encoding: base64',
|
|
289
|
+
'',
|
|
290
|
+
Buffer.from('binary body').toString('base64'),
|
|
291
|
+
'--bb--',
|
|
292
|
+
''
|
|
293
|
+
].join('\r\n');
|
|
294
|
+
await client.append('INBOX', Buffer.from(content));
|
|
295
|
+
|
|
296
|
+
await client.mailboxOpen('INBOX');
|
|
297
|
+
const { content: downloadStream } = await client.download('1', '1', { binary: true });
|
|
298
|
+
const chunks = [];
|
|
299
|
+
for await (let chunk of downloadStream) {
|
|
300
|
+
chunks.push(chunk);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
test.ok(clientSent(logs, 'BINARY.PEEK[1]'), 'client should issue a BINARY fetch for the numeric part on a rev2 session');
|
|
304
|
+
test.equal(Buffer.concat(chunks).toString().trim(), 'binary body', 'BINARY fetch should return the decoded content');
|
|
305
|
+
} finally {
|
|
306
|
+
await client.logout();
|
|
307
|
+
}
|
|
308
|
+
test.done();
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
module.exports['Live rev2: MOVE reports COPYUID from the untagged OK'] = async test => {
|
|
312
|
+
const client = await connectClient();
|
|
313
|
+
try {
|
|
314
|
+
await client.append('INBOX', Buffer.from('Subject: mover\r\n\r\nmover body\r\n'));
|
|
315
|
+
await client.mailboxCreate('Moved');
|
|
316
|
+
|
|
317
|
+
await client.mailboxOpen('INBOX');
|
|
318
|
+
const result = await client.messageMove('1', 'Moved');
|
|
319
|
+
|
|
320
|
+
// RFC 9051 6.4.8: the server is REQUIRED to send COPYUID in an untagged OK
|
|
321
|
+
// before the EXPUNGEs - verify the client captured it
|
|
322
|
+
test.ok(result, 'move should succeed');
|
|
323
|
+
test.equal(result.path, 'INBOX');
|
|
324
|
+
test.equal(result.destination, 'Moved');
|
|
325
|
+
test.ok(result.uidMap && result.uidMap.size === 1, 'COPYUID must be captured from the untagged OK');
|
|
326
|
+
|
|
327
|
+
await client.mailboxClose();
|
|
328
|
+
await client.mailboxDelete('Moved');
|
|
329
|
+
} finally {
|
|
330
|
+
await client.logout();
|
|
331
|
+
}
|
|
332
|
+
test.done();
|
|
333
|
+
};
|
|
334
|
+
|
|
210
335
|
module.exports['Live rev2: message lifecycle smoke test'] = async test => {
|
|
211
336
|
const client = await connectClient();
|
|
212
337
|
try {
|
|
@@ -30,6 +30,20 @@ cleanup() {
|
|
|
30
30
|
trap cleanup EXIT
|
|
31
31
|
cleanup
|
|
32
32
|
|
|
33
|
+
# Guard against a stale image cached for the wrong architecture: `docker run`
|
|
34
|
+
# without --platform silently reuses a local image even when its architecture
|
|
35
|
+
# does not match the host (e.g. an amd64 image left behind on an arm64 host),
|
|
36
|
+
# and Dovecot then fails to start with confusing emulation errors. Only applies
|
|
37
|
+
# when no explicit platform override was requested.
|
|
38
|
+
if [ -z "${IMAPFLOW_DOVECOT_PLATFORM:-}" ] && docker image inspect "$IMAGE" >/dev/null 2>&1; then
|
|
39
|
+
image_arch="$(docker image inspect --format '{{.Architecture}}' "$IMAGE" 2>/dev/null || true)"
|
|
40
|
+
host_arch="$(docker version --format '{{.Server.Arch}}' 2>/dev/null || true)"
|
|
41
|
+
if [ -n "$image_arch" ] && [ -n "$host_arch" ] && [ "$image_arch" != "$host_arch" ]; then
|
|
42
|
+
echo "Local $IMAGE image is $image_arch but the Docker host is $host_arch - re-pulling for linux/$host_arch..."
|
|
43
|
+
docker pull --platform "linux/$host_arch" "$IMAGE"
|
|
44
|
+
fi
|
|
45
|
+
fi
|
|
46
|
+
|
|
33
47
|
docker run ${PLATFORM_ARG:+"$PLATFORM_ARG"} -d --name "$CONTAINER_NAME" \
|
|
34
48
|
-e USER_PASSWORD=pass \
|
|
35
49
|
-v "$SCRIPT_DIR/dovecot-test.conf:/etc/dovecot/conf.d/99-imapflow-test.conf:ro" \
|