imapflow 1.6.4 → 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.
@@ -75,37 +75,48 @@ const lineReader = (sock, onLine) => {
75
75
 
76
76
  const listen = server => new Promise(resolve => server.listen(0, '127.0.0.1', () => resolve(server.address().port)));
77
77
 
78
- // ---------------------------------------------------------------------------
79
- // STARTTLS happy path
80
- // ---------------------------------------------------------------------------
81
-
82
- module.exports['Secure: STARTTLS upgrade completes a session'] = async test => {
83
- let server = net.createServer(rawSocket => {
78
+ // Builds a STARTTLS mock server. Defaults give the happy path; options tweak a
79
+ // phase without another copy of the upgrade scaffold:
80
+ // - preTlsCaps / postTlsCaps: capability list advertised before / after the upgrade
81
+ // - startTlsOk: tag => string, overrides the response line to the STARTTLS command
82
+ // - onTlsLine: (tlsSocket, line) => boolean, intercepts post-upgrade lines; return
83
+ // true when the line was handled
84
+ const createStartTlsServer = (opts = {}) =>
85
+ net.createServer(rawSocket => {
84
86
  rawSocket.on('error', () => {});
85
87
 
88
+ let preTlsCaps = opts.preTlsCaps || `${CAPS} STARTTLS`;
89
+ let postTlsCaps = opts.postTlsCaps || CAPS;
90
+
86
91
  let detachPlain;
87
92
  detachPlain = lineReader(rawSocket, line => {
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
- );
93
+ let parts = line.split(' ');
94
+ let tag = parts[0];
95
+ let cmd = (parts[1] || '').toUpperCase();
96
+
97
+ if (cmd === 'STARTTLS') {
98
+ rawSocket.write(opts.startTlsOk ? opts.startTlsOk(tag) : `${tag} OK Begin TLS\r\n`);
99
+ detachPlain();
100
+ let tlsSocket = new tls.TLSSocket(rawSocket, { isServer: true, key, cert });
101
+ tlsSocket.on('error', () => {});
102
+ lineReader(tlsSocket, l => {
103
+ if (opts.onTlsLine && opts.onTlsLine(tlsSocket, l)) {
104
+ return;
105
+ }
106
+ handleLine(tlsSocket, l, null, postTlsCaps);
107
+ });
108
+ return;
109
+ }
110
+
111
+ handleLine(rawSocket, line, null, preTlsCaps);
102
112
  });
103
113
 
104
- rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS PRETLS-ONLY] ready\r\n`);
114
+ rawSocket.write(`* OK [CAPABILITY ${preTlsCaps}] ready\r\n`);
105
115
  });
106
116
 
107
- let port = await listen(server);
108
- let client = new ImapFlow({
117
+ // The client options shared by every STARTTLS test.
118
+ const makeStartTlsClient = (port, overrides = {}) =>
119
+ new ImapFlow({
109
120
  host: '127.0.0.1',
110
121
  port,
111
122
  secure: false,
@@ -115,8 +126,30 @@ module.exports['Secure: STARTTLS upgrade completes a session'] = async test => {
115
126
  disableAutoIdle: true,
116
127
  disableCompression: true,
117
128
  logger: false,
118
- auth: { user: 'test', pass: 'secret' }
129
+ auth: { user: 'test', pass: 'secret' },
130
+ ...overrides
119
131
  });
132
+
133
+ // Every terminal upgrade path must leave no upgrade state behind
134
+ const assertUpgradeSettled = (test, client) => {
135
+ test.equal(client.upgrading, false, 'upgrading flag cleared');
136
+ test.equal(client._upgradeReject, null, 'upgrade rejector cleared');
137
+ test.equal(client.upgradeTimeout, null, 'upgrade timer cleared');
138
+ };
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // STARTTLS happy path
142
+ // ---------------------------------------------------------------------------
143
+
144
+ module.exports['Secure: STARTTLS upgrade completes a session'] = async test => {
145
+ // post-TLS phase advertises a different capability set
146
+ let server = createStartTlsServer({
147
+ preTlsCaps: `${CAPS} STARTTLS PRETLS-ONLY`,
148
+ postTlsCaps: `${CAPS} POSTTLS-ONLY`
149
+ });
150
+
151
+ let port = await listen(server);
152
+ let client = makeStartTlsClient(port);
120
153
  client.on('error', () => {});
121
154
 
122
155
  await client.connect();
@@ -141,43 +174,15 @@ module.exports['Secure: STARTTLS discards capabilities even when the OK carries
141
174
  // a discard conditioned on "an update is still pending" would keep exactly the
142
175
  // pre-TLS list an attacker controls - the list that then chooses the AUTH
143
176
  // mechanism and answers LOGINDISABLED. RFC 9051 6.2.1 makes the discard mandatory.
144
- let server = net.createServer(rawSocket => {
145
- rawSocket.on('error', () => {});
146
-
147
- let detachPlain;
148
- detachPlain = lineReader(rawSocket, line => {
149
- let parts = line.split(' ');
150
- let tag = parts[0];
151
- let cmd = (parts[1] || '').toUpperCase();
152
-
153
- if (cmd === 'STARTTLS') {
154
- rawSocket.write(`${tag} OK [CAPABILITY ${CAPS} PRETLS-ONLY] Begin TLS\r\n`);
155
- detachPlain();
156
- let tlsSocket = new tls.TLSSocket(rawSocket, { isServer: true, key, cert });
157
- tlsSocket.on('error', () => {});
158
- lineReader(tlsSocket, l => handleLine(tlsSocket, l, null, `${CAPS} POSTTLS-ONLY`));
159
- return;
160
- }
161
-
162
- handleLine(rawSocket, line, null, `${CAPS} STARTTLS PRETLS-ONLY`);
163
- });
164
-
165
- rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS PRETLS-ONLY] ready\r\n`);
177
+ let server = createStartTlsServer({
178
+ preTlsCaps: `${CAPS} STARTTLS PRETLS-ONLY`,
179
+ postTlsCaps: `${CAPS} POSTTLS-ONLY`,
180
+ // the OK itself carries a (pre-TLS, attacker-rewritable) CAPABILITY code
181
+ startTlsOk: tag => `${tag} OK [CAPABILITY ${CAPS} PRETLS-ONLY] Begin TLS\r\n`
166
182
  });
167
183
 
168
184
  let port = await listen(server);
169
- let client = new ImapFlow({
170
- host: '127.0.0.1',
171
- port,
172
- secure: false,
173
- doSTARTTLS: true,
174
- servername: 'localhost',
175
- tls: { rejectUnauthorized: false },
176
- disableAutoIdle: true,
177
- disableCompression: true,
178
- logger: false,
179
- auth: { user: 'test', pass: 'secret' }
180
- });
185
+ let client = makeStartTlsClient(port);
181
186
  client.on('error', () => {});
182
187
 
183
188
  await client.connect();
@@ -191,23 +196,40 @@ module.exports['Secure: STARTTLS discards capabilities even when the OK carries
191
196
  test.done();
192
197
  };
193
198
 
194
- // Builds the STARTTLS happy-path server used by the watchdog and cleanup tests below.
195
- const createStartTlsServer = () =>
196
- net.createServer(rawSocket => {
197
- rawSocket.on('error', () => {});
199
+ module.exports['Secure: pre-TLS rawCapabilities do not survive a failed re-fetch'] = async test => {
200
+ // capabilities/authCapabilities are discarded at the upgrade, but rawCapabilities
201
+ // is public surface external consumers read. If the post-TLS CAPABILITY re-fetch
202
+ // fails, the pre-TLS list - the one an active attacker can rewrite - must not
203
+ // linger there either.
204
+ let server = createStartTlsServer({
205
+ preTlsCaps: `${CAPS} STARTTLS PRETLS-ONLY`,
206
+ postTlsCaps: `${CAPS} POSTTLS-ONLY`,
207
+ onTlsLine: (tlsSocket, line) => {
208
+ let parts = line.split(' ');
209
+ if ((parts[1] || '').toUpperCase() === 'CAPABILITY') {
210
+ // the re-fetch over TLS fails
211
+ tlsSocket.write(`${parts[0]} NO CAPABILITY not available\r\n`);
212
+ return true;
213
+ }
214
+ return false;
215
+ }
216
+ });
198
217
 
199
- let detachPlain;
200
- detachPlain = lineReader(rawSocket, line => {
201
- handleLine(rawSocket, line, () => {
202
- detachPlain();
203
- let tlsSocket = new tls.TLSSocket(rawSocket, { isServer: true, key, cert });
204
- tlsSocket.on('error', () => {});
205
- lineReader(tlsSocket, l => handleLine(tlsSocket, l, null, CAPS));
206
- });
207
- });
218
+ let port = await listen(server);
219
+ let client = makeStartTlsClient(port);
220
+ client.on('error', () => {});
208
221
 
209
- rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS] ready\r\n`);
210
- });
222
+ await client.connect();
223
+ test.ok(client.secureConnection, 'connection upgraded to TLS');
224
+ test.ok(!client.capabilities.has('PRETLS-ONLY'), 'pre-TLS capabilities were discarded');
225
+ let raw = [].concat(client.rawCapabilities || []);
226
+ test.ok(!raw.some(entry => entry && /PRETLS-ONLY/.test(entry.value || entry)), 'pre-TLS rawCapabilities were discarded');
227
+
228
+ await client.logout();
229
+ client.close();
230
+ server.close();
231
+ test.done();
232
+ };
211
233
 
212
234
  // Records every socket that went through configureSocket(), which is the single place the
213
235
  // transport options (keepalive + inactivity watchdog) are applied.
@@ -227,19 +249,7 @@ module.exports['Secure: STARTTLS session keeps the inactivity watchdog'] = async
227
249
  // and a dead connection was never noticed.
228
250
  let server = createStartTlsServer();
229
251
  let port = await listen(server);
230
- let client = new ImapFlow({
231
- host: '127.0.0.1',
232
- port,
233
- secure: false,
234
- doSTARTTLS: true,
235
- servername: 'localhost',
236
- tls: { rejectUnauthorized: false },
237
- socketTimeout: 200,
238
- disableAutoIdle: true,
239
- disableCompression: true,
240
- logger: false,
241
- auth: { user: 'test', pass: 'secret' }
242
- });
252
+ let client = makeStartTlsClient(port, { socketTimeout: 200 });
243
253
 
244
254
  let errors = [];
245
255
  client.on('error', err => errors.push(err));
@@ -271,25 +281,12 @@ module.exports['Secure: STARTTLS leaves no upgrade state behind on success'] = a
271
281
  // a successful handshake no timer, rejector, flag or temporary handler survives.
272
282
  let server = createStartTlsServer();
273
283
  let port = await listen(server);
274
- let client = new ImapFlow({
275
- host: '127.0.0.1',
276
- port,
277
- secure: false,
278
- doSTARTTLS: true,
279
- servername: 'localhost',
280
- tls: { rejectUnauthorized: false },
281
- disableAutoIdle: true,
282
- disableCompression: true,
283
- logger: false,
284
- auth: { user: 'test', pass: 'secret' }
285
- });
284
+ let client = makeStartTlsClient(port);
286
285
  client.on('error', () => {});
287
286
 
288
287
  await client.connect();
289
288
 
290
- test.equal(client.upgrading, false, 'upgrading flag cleared');
291
- test.equal(client._upgradeReject, null, 'upgrade rejector cleared');
292
- test.equal(client.upgradeTimeout, null, 'upgrade timer cleared');
289
+ assertUpgradeSettled(test, client);
293
290
  // The handshake-only handler is gone, leaving the generic socket error handler as the single
294
291
  // error path (during the handshake it is the other way round, which is what prevents a
295
292
  // handshake error from firing two handlers at once).
@@ -314,18 +311,7 @@ module.exports['Secure: close() during a STARTTLS upgrade settles the upgrade']
314
311
  rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS] ready\r\n`);
315
312
  });
316
313
  let port = await listen(server);
317
- let client = new ImapFlow({
318
- host: '127.0.0.1',
319
- port,
320
- secure: false,
321
- doSTARTTLS: true,
322
- servername: 'localhost',
323
- tls: { rejectUnauthorized: false },
324
- disableAutoIdle: true,
325
- disableCompression: true,
326
- logger: false,
327
- auth: { user: 'test', pass: 'secret' }
328
- });
314
+ let client = makeStartTlsClient(port);
329
315
  client.on('error', () => {});
330
316
 
331
317
  let connectResult = client.connect().then(
@@ -337,15 +323,46 @@ module.exports['Secure: close() during a STARTTLS upgrade settles the upgrade']
337
323
  while (!client.upgrading) {
338
324
  await new Promise(resolve => setTimeout(resolve, 10));
339
325
  }
326
+ // A late socket event would invoke the same settle helper the rejector exposes -
327
+ // capture it before close() consumes it
328
+ let settle = client._upgradeReject;
340
329
  client.close();
341
330
 
342
331
  let err = await connectResult;
343
332
  test.ok(err, 'connect rejected rather than hanging on the abandoned upgrade');
344
333
  test.ok(['ClosedAfterConnectText', 'ClosedAfterConnectTLS', 'NoConnection'].includes(err.code), `connect rejected with ${err.code}`);
345
- test.equal(client.upgrading, false, 'upgrading flag cleared');
346
- test.equal(client._upgradeReject, null, 'upgrade rejector cleared');
347
- test.equal(client.upgradeTimeout, null, 'upgrade timer cleared');
334
+ assertUpgradeSettled(test, client);
335
+
336
+ // The late event lands after the upgrade already settled - it must be a no-op:
337
+ // a settled upgrade neither claims the error as a TLS failure nor re-arms any state
338
+ let lateErr = new Error('late socket error');
339
+ settle(lateErr);
340
+ test.ok(!lateErr.tlsFailed, 'the late error was not marked as a TLS upgrade failure');
341
+ assertUpgradeSettled(test, client);
342
+
343
+ server.close();
344
+ test.done();
345
+ };
348
346
 
347
+ module.exports['Secure: STARTTLS handshake failure rejects connect'] = async test => {
348
+ let server = createStartTlsServer();
349
+ let port = await listen(server);
350
+ // The tls option is erased entirely, not merely relaxed: certificate validation
351
+ // stays on, so the mock server's self-signed certificate must fail the handshake,
352
+ // and the upgrade builds its TLS options from the no-options fallback
353
+ let client = makeStartTlsClient(port, { tls: undefined });
354
+ client.on('error', () => {});
355
+
356
+ let err = await client.connect().then(
357
+ () => null,
358
+ connectErr => connectErr
359
+ );
360
+
361
+ test.ok(err, 'connect rejected on the failed handshake');
362
+ test.ok(err.tlsFailed, 'the error is marked as a TLS upgrade failure');
363
+ assertUpgradeSettled(test, client);
364
+
365
+ client.close();
349
366
  server.close();
350
367
  test.done();
351
368
  };
@@ -1346,3 +1346,129 @@ module.exports['Server: an unparseable tagged completion fails the command inste
1346
1346
  server.close();
1347
1347
  test.done();
1348
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
+ };
@@ -1447,3 +1447,28 @@ module.exports['IMAP Parser: balanced brackets inside a response code are still
1447
1447
  test.equal(text, 'Flags permitted.');
1448
1448
  test.equal(parsed.attributes[0].section[0].value, 'PERMANENTFLAGS');
1449
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
+ });
@@ -475,15 +475,39 @@ module.exports['Literal marker scan stays linear on a long digit run'] = test =>
475
475
  test.done();
476
476
  };
477
477
 
478
- module.exports['Literal marker rejects an oversized digit run without parsing it'] = test => {
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.
479
482
  const stream = new ImapStream({ cid: 'test' });
480
- stream.on('error', () => {});
483
+ let streamErr = null;
484
+ stream.on('error', err => {
485
+ streamErr = err;
486
+ });
481
487
  stream.resume();
482
488
 
483
- // Longer than any number64 can be, so it cannot be a valid marker
484
489
  const line = Buffer.concat([Buffer.from('* OK {'), Buffer.from('1'.repeat(40)), Buffer.from('}\r\n')]);
485
490
 
486
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();
487
511
  test.done();
488
512
  };
489
513
 
@@ -212,6 +212,36 @@ module.exports['Live rev2: returnOptions search is answered via a real ESEARCH r
212
212
  test.done();
213
213
  };
214
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
+
215
245
  module.exports['Live rev2: MODSEQ search criterion surfaces modseq from the ESEARCH response'] = async test => {
216
246
  const client = await connectClient();
217
247
  try {