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.
@@ -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
+ };
@@ -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
- test.ok(clientSent(logs, 'STATUS'), 'LIST should carry RETURN (STATUS ...)');
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
- test.ok(serverSentUntagged(logs, 'LIST'), 'rev2 SELECT should include an untagged LIST response');
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 ignores invalid values'] = test => {
90
+ module.exports['Search Compiler: SEQ passes invalid values through to the compiler guard'] = test => {
91
91
  let connection = createMockConnection();
92
92
 
93
- // Whitespace in sequence is invalid
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
- test.equal(compiled.length, 0);
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
  // ============================================
@@ -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
- // Dovecot with CONDSTORE may append MODSEQ to ESEARCH responses
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: '12345' }
236
+ { type: 'ATOM', value: '9007199254740993' }
221
237
  ];
222
238
  const result = parseEsearchResponse(attrs);
223
239
  test.equal(result.count, 5);
224
- test.equal(result.modseq, undefined, 'unknown keys should not appear in result');
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
 
@@ -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 ['UIDPLUS', 'MOVE', 'NAMESPACE', 'ESEARCH', 'LITERAL-', 'LIST-EXTENDED', 'LIST-STATUS', 'SPECIAL-USE', 'ENABLE']) {
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
  // ============================================