imapflow 1.6.3 → 1.6.5
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/compress.js +29 -2
- package/lib/commands/search.js +10 -0
- package/lib/handler/imap-compiler.js +146 -46
- package/lib/handler/imap-parser.js +7 -0
- package/lib/handler/imap-stream.js +25 -5
- package/lib/handler/parser-instance.js +22 -3
- package/lib/imap-flow.d.ts +2 -0
- package/lib/imap-flow.js +119 -36
- package/lib/search-compiler.js +28 -17
- package/lib/tools.js +18 -4
- package/package.json +1 -1
- package/test/commands-integration-test.js +188 -5
- package/test/idle-polling-test.js +81 -0
- package/test/imap-compiler-test.js +168 -0
- package/test/imap-flow-internals-test.js +134 -0
- package/test/imap-flow-secure-test.js +154 -81
- package/test/imap-flow-server-test.js +162 -0
- package/test/imap-parser-test.js +51 -0
- package/test/imap-stream-edge-cases-test.js +72 -0
- package/test/integration/rev2-live-test.js +67 -3
- package/test/search-compiler-test.js +90 -3
- package/test/search-test.js +32 -3
- package/test/tools-test.js +28 -2
|
@@ -1310,3 +1310,165 @@ module.exports['Server: socket close triggers close handling'] = async test => {
|
|
|
1310
1310
|
client.close();
|
|
1311
1311
|
test.done();
|
|
1312
1312
|
};
|
|
1313
|
+
|
|
1314
|
+
module.exports['Server: an unparseable tagged completion fails the command instead of stalling'] = async test => {
|
|
1315
|
+
// A tagged line the parser cannot make sense of used to be logged and dropped.
|
|
1316
|
+
// currentRequest then stayed set forever, so trySend() stopped dispatching and
|
|
1317
|
+
// every later command queued behind a promise that never settled.
|
|
1318
|
+
let server = createServer({
|
|
1319
|
+
handlers: {
|
|
1320
|
+
SELECT(ctx) {
|
|
1321
|
+
// A control character inside the response code is not parseable
|
|
1322
|
+
ctx.write(`${ctx.tag} OK [\x01BAD-CODE] SELECT completed\r\n`);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
});
|
|
1326
|
+
let port = await listen(server);
|
|
1327
|
+
let client = makeClient(port);
|
|
1328
|
+
client.on('error', () => {});
|
|
1329
|
+
|
|
1330
|
+
await client.connect();
|
|
1331
|
+
|
|
1332
|
+
let selectErr = null;
|
|
1333
|
+
try {
|
|
1334
|
+
await client.mailboxOpen('INBOX');
|
|
1335
|
+
} catch (err) {
|
|
1336
|
+
selectErr = err;
|
|
1337
|
+
}
|
|
1338
|
+
test.ok(selectErr, 'the command whose completion could not be parsed must reject');
|
|
1339
|
+
|
|
1340
|
+
// The connection has to keep working: the next command still gets dispatched
|
|
1341
|
+
let folders = await client.list();
|
|
1342
|
+
test.ok(Array.isArray(folders) && folders.length, 'later commands still run');
|
|
1343
|
+
|
|
1344
|
+
await client.logout();
|
|
1345
|
+
client.close();
|
|
1346
|
+
server.close();
|
|
1347
|
+
test.done();
|
|
1348
|
+
};
|
|
1349
|
+
|
|
1350
|
+
module.exports['Server: a NUL-padded unparseable completion still fails the command'] = async test => {
|
|
1351
|
+
// Buggy servers pad lines with leading NUL bytes; the parser strips them before
|
|
1352
|
+
// reading the tag. The unparsed-completion recovery has to see the same tag the
|
|
1353
|
+
// parser saw, or the command hangs for exactly the server class the NUL
|
|
1354
|
+
// workaround exists for.
|
|
1355
|
+
let server = createServer({
|
|
1356
|
+
handlers: {
|
|
1357
|
+
SELECT(ctx) {
|
|
1358
|
+
ctx.write(`\x00\x00${ctx.tag} OK [\x01BAD-CODE] SELECT completed\r\n`);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
});
|
|
1362
|
+
let port = await listen(server);
|
|
1363
|
+
let client = makeClient(port);
|
|
1364
|
+
client.on('error', () => {});
|
|
1365
|
+
|
|
1366
|
+
await client.connect();
|
|
1367
|
+
|
|
1368
|
+
let selectErr = null;
|
|
1369
|
+
try {
|
|
1370
|
+
await client.mailboxOpen('INBOX');
|
|
1371
|
+
} catch (err) {
|
|
1372
|
+
selectErr = err;
|
|
1373
|
+
}
|
|
1374
|
+
test.ok(selectErr, 'the command whose completion could not be parsed must reject');
|
|
1375
|
+
|
|
1376
|
+
let folders = await client.list();
|
|
1377
|
+
test.ok(Array.isArray(folders) && folders.length, 'later commands still run');
|
|
1378
|
+
|
|
1379
|
+
await client.logout();
|
|
1380
|
+
client.close();
|
|
1381
|
+
server.close();
|
|
1382
|
+
test.done();
|
|
1383
|
+
};
|
|
1384
|
+
|
|
1385
|
+
module.exports['Server: a sequence-shaped token in a server response does not fail the connection'] = async test => {
|
|
1386
|
+
// The incoming token parser accepts sequence-shaped tokens the strict outgoing
|
|
1387
|
+
// grammar rejects ("1:2:3"). Every parsed response is re-compiled for the log, so
|
|
1388
|
+
// that pass must skip the validation - one quirky but parseable server line would
|
|
1389
|
+
// otherwise tear down the whole connection.
|
|
1390
|
+
let server = createServer({
|
|
1391
|
+
handlers: {
|
|
1392
|
+
NOOP(ctx) {
|
|
1393
|
+
ctx.write(`${ctx.tag} OK [XDATA 1:2:3] NOOP completed\r\n`);
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
});
|
|
1397
|
+
let port = await listen(server);
|
|
1398
|
+
let client = makeClient(port);
|
|
1399
|
+
client.on('error', () => {});
|
|
1400
|
+
|
|
1401
|
+
await client.connect();
|
|
1402
|
+
await client.noop();
|
|
1403
|
+
test.ok(client.usable, 'connection must stay usable');
|
|
1404
|
+
|
|
1405
|
+
let folders = await client.list();
|
|
1406
|
+
test.ok(Array.isArray(folders) && folders.length, 'later commands still run');
|
|
1407
|
+
|
|
1408
|
+
await client.logout();
|
|
1409
|
+
client.close();
|
|
1410
|
+
server.close();
|
|
1411
|
+
test.done();
|
|
1412
|
+
};
|
|
1413
|
+
|
|
1414
|
+
module.exports['Server: an invalid range rejects the command without wedging the queue'] = async test => {
|
|
1415
|
+
// The compiler refuses invalid sequence sets before anything reaches the wire.
|
|
1416
|
+
// The dispatch layer has to treat that as the command's own failure: the request
|
|
1417
|
+
// must reject (even when queued behind an in-flight command) and the queue must
|
|
1418
|
+
// keep moving instead of waiting forever on a response that can never arrive.
|
|
1419
|
+
let server = createServer();
|
|
1420
|
+
let port = await listen(server);
|
|
1421
|
+
let client = makeClient(port);
|
|
1422
|
+
client.on('error', () => {});
|
|
1423
|
+
|
|
1424
|
+
await client.connect();
|
|
1425
|
+
await client.mailboxOpen('INBOX');
|
|
1426
|
+
|
|
1427
|
+
let err = null;
|
|
1428
|
+
try {
|
|
1429
|
+
await client.fetchOne('1;2', { uid: true });
|
|
1430
|
+
} catch (e) {
|
|
1431
|
+
err = e;
|
|
1432
|
+
}
|
|
1433
|
+
test.ok(err, 'the invalid range must reject');
|
|
1434
|
+
test.equal(err && err.code, 'InvalidSequenceSet');
|
|
1435
|
+
|
|
1436
|
+
// Queued variant: the invalid command sits behind an in-flight one; every promise
|
|
1437
|
+
// must settle and the command behind the invalid one must still run
|
|
1438
|
+
let results = await Promise.allSettled([client.noop(), client.fetchOne('3;4', { uid: true }), client.noop()]);
|
|
1439
|
+
test.equal(results[0].status, 'fulfilled', 'command before the invalid one succeeds');
|
|
1440
|
+
test.equal(results[1].status, 'rejected', 'queued invalid command must reject, not hang');
|
|
1441
|
+
test.equal(results[2].status, 'fulfilled', 'command after the invalid one still runs');
|
|
1442
|
+
|
|
1443
|
+
await client.logout();
|
|
1444
|
+
client.close();
|
|
1445
|
+
server.close();
|
|
1446
|
+
test.done();
|
|
1447
|
+
};
|
|
1448
|
+
|
|
1449
|
+
module.exports['Server: a throwing response listener does not fail the command'] = async test => {
|
|
1450
|
+
// 'response' is emitted after a tagged completion parses successfully. A listener
|
|
1451
|
+
// throwing synchronously used to be caught by the parse-failure path, which
|
|
1452
|
+
// rejected the in-flight command with a bogus ParserError even though the server
|
|
1453
|
+
// had executed it.
|
|
1454
|
+
let server = createServer();
|
|
1455
|
+
let port = await listen(server);
|
|
1456
|
+
let client = makeClient(port);
|
|
1457
|
+
client.on('error', () => {});
|
|
1458
|
+
|
|
1459
|
+
await client.connect();
|
|
1460
|
+
client.on('response', payload => {
|
|
1461
|
+
if (payload.response === 'OK') {
|
|
1462
|
+
throw new Error('listener bug');
|
|
1463
|
+
}
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
await client.noop();
|
|
1467
|
+
let mailbox = await client.mailboxOpen('INBOX');
|
|
1468
|
+
test.ok(mailbox, 'commands succeed despite the throwing listener');
|
|
1469
|
+
|
|
1470
|
+
await client.logout();
|
|
1471
|
+
client.close();
|
|
1472
|
+
server.close();
|
|
1473
|
+
test.done();
|
|
1474
|
+
};
|
package/test/imap-parser-test.js
CHANGED
|
@@ -1421,3 +1421,54 @@ module.exports['IMAP Parser, ATOM with <, [, ]'] = test =>
|
|
|
1421
1421
|
]
|
|
1422
1422
|
});
|
|
1423
1423
|
});
|
|
1424
|
+
|
|
1425
|
+
module.exports['IMAP Parser: unbalanced bracket in a response code keeps the human-readable text'] = test =>
|
|
1426
|
+
asyncWrapper(test, async test => {
|
|
1427
|
+
// RFC 9051 lets a response code carry free text containing '[' but not ']'.
|
|
1428
|
+
// Counting that '[' as a nested bracket loses the whole human-readable text,
|
|
1429
|
+
// which is what NO/BAD error messages are built from.
|
|
1430
|
+
const parsed = await parser('A1 NO [XFOO see bar[baz] mailbox is busy');
|
|
1431
|
+
const text = (parsed.attributes || [])
|
|
1432
|
+
.filter(entry => entry && entry.type === 'TEXT')
|
|
1433
|
+
.map(entry => entry.value)
|
|
1434
|
+
.join('');
|
|
1435
|
+
test.equal(text, 'mailbox is busy');
|
|
1436
|
+
});
|
|
1437
|
+
|
|
1438
|
+
module.exports['IMAP Parser: balanced brackets inside a response code are still tolerated'] = test =>
|
|
1439
|
+
asyncWrapper(test, async test => {
|
|
1440
|
+
// Servers do put bracketed values inside a code, so a plain first-']' scan
|
|
1441
|
+
// would cut the code in half
|
|
1442
|
+
const parsed = await parser('* OK [PERMANENTFLAGS ([css3-page] \\*)] Flags permitted.');
|
|
1443
|
+
const text = (parsed.attributes || [])
|
|
1444
|
+
.filter(entry => entry && entry.type === 'TEXT')
|
|
1445
|
+
.map(entry => entry.value)
|
|
1446
|
+
.join('');
|
|
1447
|
+
test.equal(text, 'Flags permitted.');
|
|
1448
|
+
test.equal(parsed.attributes[0].section[0].value, 'PERMANENTFLAGS');
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
module.exports['IMAP Parser: a parse failure after the tag exposes the parsed tag'] = test =>
|
|
1452
|
+
asyncWrapper(test, async test => {
|
|
1453
|
+
// The connection settles the in-flight command from an unparseable tagged
|
|
1454
|
+
// completion using this tag - re-deriving it from the raw bytes instead would
|
|
1455
|
+
// bypass the leading-NUL workaround and strand the command
|
|
1456
|
+
let err = null;
|
|
1457
|
+
try {
|
|
1458
|
+
await parser(Buffer.from('A5 OK [\x01BAD-CODE] done', 'binary'));
|
|
1459
|
+
} catch (e) {
|
|
1460
|
+
err = e;
|
|
1461
|
+
}
|
|
1462
|
+
test.ok(err, 'the line must fail to parse');
|
|
1463
|
+
test.equal(err && err.parsedTag, 'A5');
|
|
1464
|
+
|
|
1465
|
+
// The NUL-padding workaround is inherited: the exposed tag is the stripped one
|
|
1466
|
+
err = null;
|
|
1467
|
+
try {
|
|
1468
|
+
await parser(Buffer.from('\x00\x00A6 OK [\x01BAD-CODE] done', 'binary'));
|
|
1469
|
+
} catch (e) {
|
|
1470
|
+
err = e;
|
|
1471
|
+
}
|
|
1472
|
+
test.ok(err, 'the padded line must fail to parse');
|
|
1473
|
+
test.equal(err && err.parsedTag, 'A6');
|
|
1474
|
+
});
|
|
@@ -456,3 +456,75 @@ module.exports['ImapStream: _destroy drains pending input queue callbacks'] = te
|
|
|
456
456
|
test.done();
|
|
457
457
|
});
|
|
458
458
|
};
|
|
459
|
+
|
|
460
|
+
module.exports['Literal marker scan stays linear on a long digit run'] = test => {
|
|
461
|
+
// A backwards scan that accumulated digits one at a time cost O(n^2), so a few
|
|
462
|
+
// hundred KB of digits blocked the event loop for seconds before the size was
|
|
463
|
+
// even known - all inside the default line-length budget
|
|
464
|
+
const stream = new ImapStream({ cid: 'test' });
|
|
465
|
+
stream.on('error', () => {});
|
|
466
|
+
stream.resume();
|
|
467
|
+
|
|
468
|
+
const line = Buffer.concat([Buffer.from('* OK {'), Buffer.from('9'.repeat(200000)), Buffer.from('}\r\n')]);
|
|
469
|
+
|
|
470
|
+
const started = process.hrtime.bigint();
|
|
471
|
+
stream.checkLiteralMarker(line);
|
|
472
|
+
const elapsedMs = Number(process.hrtime.bigint() - started) / 1e6;
|
|
473
|
+
|
|
474
|
+
test.ok(elapsedMs < 250, `scan should stay cheap, took ${elapsedMs.toFixed(1)}ms`);
|
|
475
|
+
test.done();
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
module.exports['Literal marker fails the stream on an oversized digit run'] = test => {
|
|
479
|
+
// An impossible size is still a syntactically valid marker. Treating it as an
|
|
480
|
+
// ordinary line instead would feed the announced literal body to the line parser
|
|
481
|
+
// and desynchronize the session, so the stream must end with LiteralTooLarge.
|
|
482
|
+
const stream = new ImapStream({ cid: 'test' });
|
|
483
|
+
let streamErr = null;
|
|
484
|
+
stream.on('error', err => {
|
|
485
|
+
streamErr = err;
|
|
486
|
+
});
|
|
487
|
+
stream.resume();
|
|
488
|
+
|
|
489
|
+
const line = Buffer.concat([Buffer.from('* OK {'), Buffer.from('1'.repeat(40)), Buffer.from('}\r\n')]);
|
|
490
|
+
|
|
491
|
+
test.equal(stream.checkLiteralMarker(line), false, 'an impossible size must not start literal mode');
|
|
492
|
+
test.ok(stream.destroyed, 'the stream must fail closed instead of continuing as if the marker were text');
|
|
493
|
+
setImmediate(() => {
|
|
494
|
+
test.ok(streamErr && streamErr.code === 'LiteralTooLarge', 'must fail with LiteralTooLarge');
|
|
495
|
+
test.done();
|
|
496
|
+
});
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
module.exports['Literal marker accepts a zero-padded size'] = test => {
|
|
500
|
+
// The RFC "number" production is 1*DIGIT, so leading zeros are legal - a long
|
|
501
|
+
// digit run can still denote a small size and must be consumed as a literal
|
|
502
|
+
const stream = new ImapStream({ cid: 'test' });
|
|
503
|
+
stream.on('error', () => {});
|
|
504
|
+
stream.resume();
|
|
505
|
+
|
|
506
|
+
const line = Buffer.from(`* 1 FETCH (BODY[] {${'0'.repeat(21)}123}\r\n`);
|
|
507
|
+
|
|
508
|
+
test.equal(stream.checkLiteralMarker(line), true, 'a zero-padded marker is a valid literal marker');
|
|
509
|
+
test.equal(stream.literalWaiting, 123, 'the padded size must parse to its numeric value');
|
|
510
|
+
stream.destroy();
|
|
511
|
+
test.done();
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
module.exports['Literal marker still accepts sizes at the digit-length bound'] = test => {
|
|
515
|
+
const stream = new ImapStream({ cid: 'test', maxLiteralSize: Number.MAX_SAFE_INTEGER });
|
|
516
|
+
stream.on('error', () => {});
|
|
517
|
+
stream.resume();
|
|
518
|
+
|
|
519
|
+
// 19 digits is the widest a number64 gets, so it must still be recognized
|
|
520
|
+
const line = Buffer.from(`* OK {${'9'.repeat(19)}}\r\n`);
|
|
521
|
+
|
|
522
|
+
test.equal(stream.checkLiteralMarker(line), false, 'a size beyond the configured maximum fails the stream rather than parsing');
|
|
523
|
+
|
|
524
|
+
const ok = new ImapStream({ cid: 'test' });
|
|
525
|
+
ok.on('error', () => {});
|
|
526
|
+
ok.resume();
|
|
527
|
+
test.equal(ok.checkLiteralMarker(Buffer.from('* OK {1024}\r\n')), true);
|
|
528
|
+
test.equal(ok.literalWaiting, 1024);
|
|
529
|
+
test.done();
|
|
530
|
+
};
|
|
@@ -150,7 +150,12 @@ module.exports['Live rev2: statusQuery is answered inline via LIST-STATUS'] = as
|
|
|
150
150
|
|
|
151
151
|
const folders = await client.list({ statusQuery: { messages: true, unseen: true } });
|
|
152
152
|
|
|
153
|
-
|
|
153
|
+
// Pin the inline LIST-STATUS behavior: the STATUS request must ride on the
|
|
154
|
+
// LIST command itself, and no standalone STATUS command may be issued -
|
|
155
|
+
// a plain substring check would also pass on the fallback path
|
|
156
|
+
const listLine = wireLines(logs).find(entry => entry.src === 'c' && /^\S+ LIST /.test(entry.msg));
|
|
157
|
+
test.ok(listLine && listLine.msg.includes('RETURN') && listLine.msg.includes('STATUS'), 'LIST should carry RETURN (STATUS ...)');
|
|
158
|
+
test.ok(!wireLines(logs).some(entry => entry.src === 'c' && /^\S+ STATUS /.test(entry.msg)), 'no standalone STATUS command should be needed');
|
|
154
159
|
const inbox = folders.find(folder => folder.path === 'INBOX');
|
|
155
160
|
test.equal(inbox.status.messages, 1, 'inline STATUS should report the appended message');
|
|
156
161
|
} finally {
|
|
@@ -207,6 +212,56 @@ module.exports['Live rev2: returnOptions search is answered via a real ESEARCH r
|
|
|
207
212
|
test.done();
|
|
208
213
|
};
|
|
209
214
|
|
|
215
|
+
module.exports['Live rev2: SEARCHRES saved result is usable as a sequence set'] = async test => {
|
|
216
|
+
// SEARCH RETURN (SAVE) stores the result server-side (RFC 5182) and '$'
|
|
217
|
+
// references it in later commands. '$' has to make it through the outgoing
|
|
218
|
+
// sequence-set validation for that to work at all.
|
|
219
|
+
const client = await connectClient();
|
|
220
|
+
try {
|
|
221
|
+
test.ok(client.capabilities.has('SEARCHRES'), 'Dovecot should advertise SEARCHRES');
|
|
222
|
+
|
|
223
|
+
await client.append('INBOX', Buffer.from('Subject: first\r\n\r\nfirst\r\n'));
|
|
224
|
+
await client.append('INBOX', Buffer.from('Subject: second\r\n\r\nsecond\r\n'));
|
|
225
|
+
|
|
226
|
+
await client.mailboxOpen('INBOX');
|
|
227
|
+
await client.search({ subject: 'second' }, { uid: true, returnOptions: ['SAVE'] });
|
|
228
|
+
|
|
229
|
+
let flagResult = await client.messageFlagsAdd('$', ['\\Flagged'], { uid: true });
|
|
230
|
+
test.ok(flagResult, 'STORE against the saved result must succeed');
|
|
231
|
+
|
|
232
|
+
let messages = [];
|
|
233
|
+
for await (let msg of client.fetch('$', { flags: true, envelope: true }, { uid: true })) {
|
|
234
|
+
messages.push(msg);
|
|
235
|
+
}
|
|
236
|
+
test.equal(messages.length, 1, 'the saved result references exactly the matched message');
|
|
237
|
+
test.equal(messages[0].envelope.subject, 'second');
|
|
238
|
+
test.ok(messages[0].flags.has('\\Flagged'), 'the flag change applied to the saved result');
|
|
239
|
+
} finally {
|
|
240
|
+
await client.logout();
|
|
241
|
+
}
|
|
242
|
+
test.done();
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
module.exports['Live rev2: MODSEQ search criterion surfaces modseq from the ESEARCH response'] = async test => {
|
|
246
|
+
const client = await connectClient();
|
|
247
|
+
try {
|
|
248
|
+
await client.append('INBOX', Buffer.from('Subject: first\r\n\r\nfirst\r\n'));
|
|
249
|
+
await client.append('INBOX', Buffer.from('Subject: second\r\n\r\nsecond\r\n'));
|
|
250
|
+
|
|
251
|
+
await client.mailboxOpen('INBOX');
|
|
252
|
+
// RFC 7162: a MODSEQ criterion on a CONDSTORE session makes the server
|
|
253
|
+
// append MODSEQ to the ESEARCH response
|
|
254
|
+
const result = await client.search({ modseq: 1 }, { returnOptions: ['ALL', 'COUNT'] });
|
|
255
|
+
|
|
256
|
+
test.equal(result.count, 2);
|
|
257
|
+
test.equal(result.all, '1:2');
|
|
258
|
+
test.ok(typeof result.modseq === 'bigint' && result.modseq > 0n, 'modseq should surface as a positive BigInt');
|
|
259
|
+
} finally {
|
|
260
|
+
await client.logout();
|
|
261
|
+
}
|
|
262
|
+
test.done();
|
|
263
|
+
};
|
|
264
|
+
|
|
210
265
|
module.exports['Live rev2: STATUS reports SIZE and DELETED'] = async test => {
|
|
211
266
|
const logs = [];
|
|
212
267
|
const client = await connectClient(null, logs);
|
|
@@ -253,8 +308,17 @@ module.exports['Live rev2: SELECT response carries an untagged LIST and re-selec
|
|
|
253
308
|
test.equal(mailbox.path, 'INBOX');
|
|
254
309
|
|
|
255
310
|
// 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
|
-
|
|
311
|
+
// selected mailbox - the client must consume it without issue. Scoped to
|
|
312
|
+
// the SELECT exchange itself: mailboxOpen() also issues its own LIST
|
|
313
|
+
// command first, whose untagged replies would satisfy a global check
|
|
314
|
+
// even if the SELECT response omitted the LIST
|
|
315
|
+
const lines = wireLines(logs);
|
|
316
|
+
const selectIdx = lines.findIndex(entry => entry.src === 'c' && /^\S+ SELECT /.test(entry.msg));
|
|
317
|
+
test.ok(selectIdx >= 0, 'SELECT command should be on the wire');
|
|
318
|
+
const selectTag = lines[selectIdx].msg.split(' ')[0];
|
|
319
|
+
const doneIdx = lines.findIndex((entry, i) => i > selectIdx && entry.src === 's' && entry.msg.startsWith(`${selectTag} `));
|
|
320
|
+
const listInSelect = lines.some((entry, i) => i > selectIdx && i < doneIdx && entry.src === 's' && /^\* LIST( |$)/.test(entry.msg));
|
|
321
|
+
test.ok(listInSelect, 'rev2 SELECT response should include an untagged LIST response');
|
|
258
322
|
|
|
259
323
|
// switching mailboxes must produce a CLOSED response code for the old one
|
|
260
324
|
await client.mailboxOpen('Closer');
|
|
@@ -87,13 +87,49 @@ module.exports['Search Compiler: SEQ with number'] = test => {
|
|
|
87
87
|
test.done();
|
|
88
88
|
};
|
|
89
89
|
|
|
90
|
-
module.exports['Search Compiler: SEQ
|
|
90
|
+
module.exports['Search Compiler: SEQ passes invalid values through to the compiler guard'] = test => {
|
|
91
91
|
let connection = createMockConnection();
|
|
92
92
|
|
|
93
|
-
//
|
|
93
|
+
// An invalid sequence string used to be dropped silently here, which turned the
|
|
94
|
+
// filter into an unrestricted search matching every message. It is now emitted as
|
|
95
|
+
// a SEQUENCE token so the IMAP compiler rejects it with a coded error instead.
|
|
94
96
|
let compiled = searchCompiler(connection, { seq: '1 2 3' });
|
|
95
|
-
|
|
97
|
+
let seqAttr = compiled.find(a => a.type === 'SEQUENCE');
|
|
98
|
+
test.ok(seqAttr, 'the sequence value must not be dropped');
|
|
99
|
+
test.equal(seqAttr.value, '1 2 3');
|
|
100
|
+
|
|
101
|
+
// Junk values pass through too, so they fail loudly at the compiler instead of
|
|
102
|
+
// silently widening the search
|
|
103
|
+
let junk = searchCompiler(connection, { seq: {} });
|
|
104
|
+
test.ok(
|
|
105
|
+
junk.find(a => a.type === 'SEQUENCE'),
|
|
106
|
+
'a junk value must not be dropped'
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Zero is not a valid IMAP sequence number but it is a valid filter value,
|
|
110
|
+
// so it must reach the compiler guard rather than be dropped as falsy
|
|
111
|
+
let zero = searchCompiler(connection, { seq: 0 });
|
|
112
|
+
test.equal(zero.find(a => a.type === 'SEQUENCE').value, '0', 'zero must not be dropped');
|
|
113
|
+
|
|
114
|
+
test.done();
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
module.exports['Search Compiler: SEQ array compiles to a single comma-joined set'] = test => {
|
|
118
|
+
let connection = createMockConnection();
|
|
119
|
+
let compiled = searchCompiler(connection, { seq: [1, 3] });
|
|
120
|
+
|
|
121
|
+
let seqAttr = compiled.find(a => a.type === 'SEQUENCE');
|
|
122
|
+
test.ok(seqAttr, 'array must produce a sequence set');
|
|
123
|
+
test.equal(seqAttr.value, '1,3');
|
|
124
|
+
test.done();
|
|
125
|
+
};
|
|
96
126
|
|
|
127
|
+
module.exports['Search Compiler: SEQ empty string compiles to nothing'] = test => {
|
|
128
|
+
let connection = createMockConnection();
|
|
129
|
+
let compiled = searchCompiler(connection, { seq: '' });
|
|
130
|
+
|
|
131
|
+
test.ok(!compiled.find(a => a.type === 'SEQUENCE'), 'an empty set adds no SEQUENCE attribute');
|
|
132
|
+
test.equal(compiled.length, 0);
|
|
97
133
|
test.done();
|
|
98
134
|
};
|
|
99
135
|
|
|
@@ -366,6 +402,30 @@ module.exports['Search Compiler: UID with number'] = test => {
|
|
|
366
402
|
test.done();
|
|
367
403
|
};
|
|
368
404
|
|
|
405
|
+
module.exports['Search Compiler: UID array compiles to a single comma-joined set'] = test => {
|
|
406
|
+
let connection = createMockConnection();
|
|
407
|
+
let compiled = searchCompiler(connection, { uid: [5, 7, 9] });
|
|
408
|
+
|
|
409
|
+
// Separate tokens ("UID 5 7 9") would be parsed by the server as extra
|
|
410
|
+
// sequence-number search keys ANDed to the query - the array means the set {5,7,9}
|
|
411
|
+
test.ok(hasAttr(compiled, 'UID'));
|
|
412
|
+
let uidValueAttr = compiled.find(a => a.value === '5,7,9');
|
|
413
|
+
test.ok(uidValueAttr, 'array must be joined into one sequence set');
|
|
414
|
+
test.equal(uidValueAttr.type, 'SEQUENCE');
|
|
415
|
+
test.done();
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
module.exports['Search Compiler: UID accepts the SEARCHRES $ marker'] = test => {
|
|
419
|
+
let connection = createMockConnection();
|
|
420
|
+
let compiled = searchCompiler(connection, { uid: '$' });
|
|
421
|
+
|
|
422
|
+
test.ok(hasAttr(compiled, 'UID'));
|
|
423
|
+
let uidValueAttr = compiled.find(a => a.value === '$');
|
|
424
|
+
test.ok(uidValueAttr, 'the saved-result marker must pass through');
|
|
425
|
+
test.equal(uidValueAttr.type, 'SEQUENCE');
|
|
426
|
+
test.done();
|
|
427
|
+
};
|
|
428
|
+
|
|
369
429
|
// ============================================
|
|
370
430
|
// EMAILID / THREADID tests
|
|
371
431
|
// ============================================
|
|
@@ -488,6 +548,19 @@ module.exports['Search Compiler: LABELS not compiles to -label: via X-GM-RAW'] =
|
|
|
488
548
|
test.done();
|
|
489
549
|
};
|
|
490
550
|
|
|
551
|
+
module.exports['Search Compiler: LABELS with a non-object value is ignored'] = test => {
|
|
552
|
+
let connection = createMockConnection();
|
|
553
|
+
|
|
554
|
+
// Neither a falsy value nor a plain string is a { has, not } filter - both
|
|
555
|
+
// must compile to nothing even without the Gmail extension
|
|
556
|
+
let compiledNull = searchCompiler(connection, { labels: null });
|
|
557
|
+
test.equal(compiledNull.length, 0);
|
|
558
|
+
|
|
559
|
+
let compiledString = searchCompiler(connection, { labels: 'Horizon' });
|
|
560
|
+
test.equal(compiledString.length, 0);
|
|
561
|
+
test.done();
|
|
562
|
+
};
|
|
563
|
+
|
|
491
564
|
module.exports['Search Compiler: LABELS has and not combined'] = test => {
|
|
492
565
|
let connection = createMockConnection({
|
|
493
566
|
capabilities: [['X-GM-EXT-1', true]]
|
|
@@ -991,6 +1064,20 @@ module.exports['Search Compiler: non-ASCII GMRAW adds CHARSET UTF-8'] = test =>
|
|
|
991
1064
|
test.done();
|
|
992
1065
|
};
|
|
993
1066
|
|
|
1067
|
+
module.exports['Search Compiler: non-ASCII LABELS adds CHARSET UTF-8'] = test => {
|
|
1068
|
+
let connection = createMockConnection({
|
|
1069
|
+
capabilities: [['X-GM-EXT-1', true]],
|
|
1070
|
+
enabled: new Set()
|
|
1071
|
+
});
|
|
1072
|
+
// The label filter compiles into an X-GM-RAW query, and a non-ASCII label name
|
|
1073
|
+
// must mark the compiled query as Unicode the same way a direct gmraw value does
|
|
1074
|
+
let compiled = searchCompiler(connection, { labels: { has: ['Tähtis'] } });
|
|
1075
|
+
test.ok(hasAttr(compiled, 'CHARSET'));
|
|
1076
|
+
test.ok(hasAttr(compiled, 'X-GM-RAW'));
|
|
1077
|
+
test.ok(hasAttr(compiled, 'label:Tähtis'));
|
|
1078
|
+
test.done();
|
|
1079
|
+
};
|
|
1080
|
+
|
|
994
1081
|
// ============================================
|
|
995
1082
|
// Complex query tests
|
|
996
1083
|
// ============================================
|
package/test/search-test.js
CHANGED
|
@@ -212,16 +212,45 @@ module.exports['ESEARCH: command path returns false on exec error'] = test => {
|
|
|
212
212
|
};
|
|
213
213
|
|
|
214
214
|
module.exports['ESEARCH: parseEsearchResponse ignores unknown keywords'] = test => {
|
|
215
|
-
//
|
|
215
|
+
// Unknown result keywords must be skipped with their value so the
|
|
216
|
+
// key/value stream stays aligned for the entries that follow
|
|
217
|
+
const attrs = [
|
|
218
|
+
{ type: 'ATOM', value: 'RELEVANCY' },
|
|
219
|
+
{ type: 'ATOM', value: '87' },
|
|
220
|
+
{ type: 'ATOM', value: 'COUNT' },
|
|
221
|
+
{ type: 'ATOM', value: '5' }
|
|
222
|
+
];
|
|
223
|
+
const result = parseEsearchResponse(attrs);
|
|
224
|
+
test.equal(result.count, 5);
|
|
225
|
+
test.equal(result.relevancy, undefined, 'unknown keys should not appear in result');
|
|
226
|
+
test.done();
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
module.exports['ESEARCH: parseEsearchResponse parses MODSEQ as BigInt'] = test => {
|
|
230
|
+
// RFC 7162: CONDSTORE sessions append MODSEQ to ESEARCH responses when the
|
|
231
|
+
// search used a MODSEQ criterion
|
|
216
232
|
const attrs = [
|
|
217
233
|
{ type: 'ATOM', value: 'COUNT' },
|
|
218
234
|
{ type: 'ATOM', value: '5' },
|
|
219
235
|
{ type: 'ATOM', value: 'MODSEQ' },
|
|
220
|
-
{ type: 'ATOM', value: '
|
|
236
|
+
{ type: 'ATOM', value: '9007199254740993' }
|
|
221
237
|
];
|
|
222
238
|
const result = parseEsearchResponse(attrs);
|
|
223
239
|
test.equal(result.count, 5);
|
|
224
|
-
test.
|
|
240
|
+
test.strictEqual(result.modseq, 9007199254740993n, 'modseq should be an exact BigInt');
|
|
241
|
+
test.done();
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
module.exports['ESEARCH: parseEsearchResponse drops non-numeric MODSEQ'] = test => {
|
|
245
|
+
const attrs = [
|
|
246
|
+
{ type: 'ATOM', value: 'MODSEQ' },
|
|
247
|
+
{ type: 'ATOM', value: 'bogus' },
|
|
248
|
+
{ type: 'ATOM', value: 'COUNT' },
|
|
249
|
+
{ type: 'ATOM', value: '3' }
|
|
250
|
+
];
|
|
251
|
+
const result = parseEsearchResponse(attrs);
|
|
252
|
+
test.equal(result.modseq, undefined, 'invalid modseq must be dropped');
|
|
253
|
+
test.equal(result.count, 3, 'stream must stay aligned after a dropped value');
|
|
225
254
|
test.done();
|
|
226
255
|
};
|
|
227
256
|
|
package/test/tools-test.js
CHANGED
|
@@ -234,8 +234,23 @@ module.exports['Tools: hasCapability with advertised token'] = test => {
|
|
|
234
234
|
|
|
235
235
|
module.exports['Tools: hasCapability folds extensions into active rev2'] = test => {
|
|
236
236
|
let connection = createMockConnection({ capabilities: [['IMAP4rev2', true]] });
|
|
237
|
-
// RFC 9051 Appendix E folds these into base IMAP4rev2
|
|
238
|
-
for (let capability of [
|
|
237
|
+
// RFC 9051 Appendix E folds these into base IMAP4rev2 - the complete set
|
|
238
|
+
for (let capability of [
|
|
239
|
+
'ENABLE',
|
|
240
|
+
'ESEARCH',
|
|
241
|
+
'IDLE',
|
|
242
|
+
'LIST-EXTENDED',
|
|
243
|
+
'LIST-STATUS',
|
|
244
|
+
'LITERAL-',
|
|
245
|
+
'MOVE',
|
|
246
|
+
'NAMESPACE',
|
|
247
|
+
'SASL-IR',
|
|
248
|
+
'SEARCHRES',
|
|
249
|
+
'SPECIAL-USE',
|
|
250
|
+
'STATUS=SIZE',
|
|
251
|
+
'UIDPLUS',
|
|
252
|
+
'UNSELECT'
|
|
253
|
+
]) {
|
|
239
254
|
test.equal(tools.hasCapability(connection, capability), true, `${capability} should be folded into rev2`);
|
|
240
255
|
}
|
|
241
256
|
// BINARY is intentionally not folded
|
|
@@ -336,6 +351,17 @@ module.exports['Tools: getErrorText with valid response'] = async test => {
|
|
|
336
351
|
test.done();
|
|
337
352
|
};
|
|
338
353
|
|
|
354
|
+
module.exports['Tools: getErrorText survives a response that cannot be re-encoded'] = async test => {
|
|
355
|
+
// The parser tolerates stray bytes inside an OK/NO/BAD atom, and those have no
|
|
356
|
+
// valid IMAP string encoding. The error text is diagnostic, so it must still be
|
|
357
|
+
// produced rather than replacing the server's error with an encoding failure.
|
|
358
|
+
let response = await parser(Buffer.from('A1 NO [SERVERBUG\x00X] it failed', 'binary'));
|
|
359
|
+
let result = await tools.getErrorText(response);
|
|
360
|
+
test.ok(typeof result === 'string', 'error text should still be produced');
|
|
361
|
+
test.ok(result.includes('it failed'), 'the human-readable part must survive');
|
|
362
|
+
test.done();
|
|
363
|
+
};
|
|
364
|
+
|
|
339
365
|
// ============================================
|
|
340
366
|
// getFlagColor tests
|
|
341
367
|
// ============================================
|