imapkit 4.1.1 → 4.2.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.
@@ -14,6 +14,7 @@ import { isSequenceSet } from './numbers.js';
14
14
  import { restoreNilAtoms } from './arguments.js';
15
15
  import { refuseMissingTarget } from './commands/append.js';
16
16
  import * as bundledCert from './cert.js';
17
+ import { ServerScript, sendOutput, handleLine } from './script.js';
17
18
  // longest command line (not counting literals) accepted from a client
18
19
  const MAX_LINE_LENGTH = 1024 * 1024;
19
20
  // largest literal accepted after login, override with the maxLiteralSize option
@@ -155,6 +156,9 @@ class IMAPServer extends Stream {
155
156
  this.referenceNamespace = false;
156
157
  // the session whose command is running, see IMAPServer#notify
157
158
  this.activeConnection = null;
159
+ this.sessionCounter = 0;
160
+ // rules that make the server misbehave on purpose, from the script option or server.script.add()
161
+ this.script = new ServerScript(this, this.options.script);
158
162
  // users and storage are deep copied, so that runtime changes never leak into
159
163
  // the caller's objects or into other servers built from the same fixture.
160
164
  // Without a prototype, user names like "__proto__" or "toString" are plain keys
@@ -1212,6 +1216,9 @@ class IMAPConnection {
1212
1216
  this.socket = socket;
1213
1217
  this.options = this.server.options;
1214
1218
  this.state = 'Not Authenticated';
1219
+ this.sessionNumber = ++this.server.sessionCounter;
1220
+ this._outputQueue = null;
1221
+ this._outputTimer = null;
1215
1222
  this.secureConnection = !!this.options.secureConnection;
1216
1223
  this._remainder = '';
1217
1224
  this._command = '';
@@ -1241,7 +1248,7 @@ class IMAPConnection {
1241
1248
  this.notificationQueue = [];
1242
1249
  this.server.on('notify', this._notificationCallback);
1243
1250
  this.server.connections.add(this);
1244
- this.write('* OK ImapKit ready for rumble\r\n');
1251
+ this.scriptOutput('greeting', '* OK ImapKit ready for rumble\r\n', {});
1245
1252
  }
1246
1253
  /**
1247
1254
  * Writes protocol output to the client, through the transport layer if there is one
@@ -1250,12 +1257,134 @@ class IMAPConnection {
1250
1257
  */
1251
1258
  write(data) {
1252
1259
  const buffer = typeof data === 'string' ? Buffer.from(data, 'binary') : data;
1253
- if (this.transport) {
1254
- this.transport.write(buffer);
1260
+ if (this._outputQueue) {
1261
+ this._outputQueue.push({ data: buffer, transport: this.transport });
1262
+ }
1263
+ else {
1264
+ this.writeLayer(buffer, this.transport);
1265
+ }
1266
+ }
1267
+ /**
1268
+ * Writes output, or puts it in the output queue while earlier output waits for a delay of a script rule.
1269
+ * The transport layer is taken when the output is queued, so output from before COMPRESS is not compressed
1270
+ *
1271
+ * @param {Object} operation `{ data, delay, chunk, chunkDelay, close }`, see OutputOperation in src/script.ts
1272
+ */
1273
+ queueOutput(operation) {
1274
+ if (!this._outputQueue && !operation.delay && !operation.chunk) {
1275
+ if (operation.data) {
1276
+ this.writeLayer(operation.data, this.transport);
1277
+ }
1278
+ if (operation.close) {
1279
+ this.closeNow(operation.close);
1280
+ }
1281
+ return;
1282
+ }
1283
+ this._outputQueue = this._outputQueue || [];
1284
+ this._outputQueue.push(Object.assign({}, operation, { transport: this.transport }));
1285
+ if (!this._outputTimer) {
1286
+ this.flushOutput();
1287
+ }
1288
+ }
1289
+ /**
1290
+ * Writes the output queue until it is empty or a delay stops it
1291
+ */
1292
+ flushOutput() {
1293
+ const queue = this._outputQueue || [];
1294
+ while (queue.length) {
1295
+ const operation = queue[0];
1296
+ if (operation.delay) {
1297
+ const delay = operation.delay;
1298
+ operation.delay = 0;
1299
+ this._outputTimer = setTimeout(() => {
1300
+ this._outputTimer = null;
1301
+ this.flushOutput();
1302
+ }, delay);
1303
+ return;
1304
+ }
1305
+ if (operation.data && operation.chunk && operation.data.length > operation.chunk) {
1306
+ // the rest waits for chunkDelay, like a delay of its own
1307
+ this.writeLayer(operation.data.subarray(0, operation.chunk), operation.transport || null);
1308
+ operation.data = operation.data.subarray(operation.chunk);
1309
+ operation.delay = operation.chunkDelay;
1310
+ continue;
1311
+ }
1312
+ if (operation.data) {
1313
+ this.writeLayer(operation.data, operation.transport || null);
1314
+ }
1315
+ queue.shift();
1316
+ if (operation.close) {
1317
+ this.closeNow(operation.close);
1318
+ return;
1319
+ }
1320
+ }
1321
+ this._outputQueue = null;
1322
+ }
1323
+ /**
1324
+ * Drops the output that waits for a delay of a script rule
1325
+ */
1326
+ clearOutputQueue() {
1327
+ if (this._outputTimer) {
1328
+ clearTimeout(this._outputTimer);
1329
+ this._outputTimer = null;
1330
+ }
1331
+ this._outputQueue = null;
1332
+ }
1333
+ /**
1334
+ * Writes output through a transport layer, or to the socket
1335
+ *
1336
+ * @param {Buffer} data Output
1337
+ * @param {Object|null} transport Transport layer
1338
+ */
1339
+ writeLayer(data, transport) {
1340
+ if (transport) {
1341
+ transport.write(data);
1255
1342
  }
1256
1343
  else {
1257
- this.writeRaw(buffer);
1344
+ this.writeRaw(data);
1345
+ }
1346
+ }
1347
+ /**
1348
+ * Sends output through the script rule that handles its event, if there is one
1349
+ *
1350
+ * @param {String} event Event name: greeting, response or continuation
1351
+ * @param {String} output The output as a binary string
1352
+ * @param {Object} fields Context fields of the event (tag, command, description, response)
1353
+ * @param {Function} [compile] Compiles the response that a `mutate` action changed, null drops the output
1354
+ */
1355
+ scriptOutput(event, output, fields, compile) {
1356
+ const found = this.server.script.check(this, event, Object.assign({}, fields, { data: output }));
1357
+ if (!found) {
1358
+ this.write(output);
1359
+ return;
1360
+ }
1361
+ const { rule, context } = found;
1362
+ if (rule.mutate && compile && context.response) {
1363
+ // a copy, a notification object is shared by every session
1364
+ const copy = cloneResponse(context.response);
1365
+ const changed = compile(rule.mutate(copy, context) || copy);
1366
+ if (changed === null) {
1367
+ return;
1368
+ }
1369
+ context.data = changed;
1370
+ }
1371
+ sendOutput(this, rule, context, Buffer.from(context.data, 'binary'));
1372
+ }
1373
+ /**
1374
+ * Sends a continuation request, `+ text`
1375
+ *
1376
+ * @param {String} text Human readable text, can be empty (SASL)
1377
+ * @param {String} description Description for script rules
1378
+ * @param {Function} [getLine] Returns the command line received so far, for the tag and the command of a literal continuation
1379
+ */
1380
+ sendContinuation(text, description, getLine) {
1381
+ const output = '+ ' + text + '\r\n';
1382
+ if (!this.server.script.watches('continuation')) {
1383
+ this.write(output);
1384
+ return;
1258
1385
  }
1386
+ const line = getLine ? getLine() : null;
1387
+ this.scriptOutput('continuation', output, line === null ? { description } : { description, tag: getResponseTag(line), command: getLineCommand(line) });
1259
1388
  }
1260
1389
  /**
1261
1390
  * Writes data to the socket, below the transport layer
@@ -1285,12 +1414,37 @@ class IMAPConnection {
1285
1414
  * holds, is written out
1286
1415
  */
1287
1416
  end() {
1417
+ if (this._outputQueue && this.socket) {
1418
+ // output still waits for a delay of a script rule
1419
+ this._closing = true;
1420
+ this._outputQueue.push({ close: true });
1421
+ return;
1422
+ }
1423
+ this.closeNow(true);
1424
+ }
1425
+ /**
1426
+ * Closes the connection now, after the output written so far
1427
+ *
1428
+ * @param {Boolean|String} mode true ends the connection gracefully, "reset" destroys the socket (script rules)
1429
+ */
1430
+ closeNow(mode) {
1288
1431
  const socket = this.socket;
1289
1432
  if (!socket) {
1290
1433
  return;
1291
1434
  }
1292
1435
  this._closing = true;
1293
- if (this.transport) {
1436
+ this.clearOutputQueue();
1437
+ if (mode === 'reset') {
1438
+ this.discardInput();
1439
+ // resetAndDestroy sends a TCP RST (Node 16.17), not every runtime has it
1440
+ if (typeof socket.resetAndDestroy === 'function') {
1441
+ socket.resetAndDestroy();
1442
+ }
1443
+ else {
1444
+ socket.destroy();
1445
+ }
1446
+ }
1447
+ else if (this.transport) {
1294
1448
  this.transport.end(() => socket.end());
1295
1449
  }
1296
1450
  else {
@@ -1391,6 +1545,7 @@ class IMAPConnection {
1391
1545
  this.transport.destroy();
1392
1546
  this.transport = null;
1393
1547
  }
1548
+ this.clearOutputQueue();
1394
1549
  this.server.removeListener('notify', this._notificationCallback);
1395
1550
  this.server.connections.delete(this);
1396
1551
  }
@@ -1406,6 +1561,21 @@ class IMAPConnection {
1406
1561
  // socket is already gone
1407
1562
  }
1408
1563
  }
1564
+ /**
1565
+ * Passes a line to the input handler (IDLE, AUTHENTICATE), unless a script rule handles it
1566
+ *
1567
+ * @param {String} line Input line without CRLF
1568
+ */
1569
+ handleInput(line) {
1570
+ const inputHandler = this.inputHandler;
1571
+ const found = this.server.script.check(this, 'input', { data: line });
1572
+ if (found) {
1573
+ handleLine(this, found.rule, found.context, () => inputHandler(line));
1574
+ }
1575
+ else {
1576
+ inputHandler(line);
1577
+ }
1578
+ }
1409
1579
  onData(chunk) {
1410
1580
  let match;
1411
1581
  let str;
@@ -1477,7 +1647,7 @@ class IMAPConnection {
1477
1647
  this.sendBad(getResponseTag(line), 'Literal data must wait for the continuation request', 'LITERAL TOO EARLY', line);
1478
1648
  }
1479
1649
  else if (this.inputHandler) {
1480
- this.inputHandler(line);
1650
+ this.handleInput(line);
1481
1651
  }
1482
1652
  else {
1483
1653
  this.scheduleCommand(line);
@@ -1526,7 +1696,9 @@ class IMAPConnection {
1526
1696
  this._earlyLiteral = true;
1527
1697
  }
1528
1698
  else if (!this._earlyLiteral) {
1529
- this.write('+ Go ahead\r\n');
1699
+ // the line is needed only by script rules that watch continuations
1700
+ const head = str.substr(0, match.index) + marker;
1701
+ this.sendContinuation('Go ahead', 'LITERAL', () => this._command + head);
1530
1702
  }
1531
1703
  }
1532
1704
  this._remainder = '';
@@ -1602,12 +1774,7 @@ class IMAPConnection {
1602
1774
  // not a command, e.g. a SASL response
1603
1775
  return literal8 ? refuse('Literal8 is not allowed here') : false;
1604
1776
  }
1605
- // tag SP command, and for UID and AUTHENTICATE the word that follows
1606
- const words = line.match(/^[^ ]* ([^ ]*)(?: ([^ ]*))?/);
1607
- let command = ((words && words[1]) || '').toUpperCase();
1608
- if (command === 'UID' || command === 'AUTHENTICATE') {
1609
- command += ' ' + ((words && words[2]) || '').toUpperCase();
1610
- }
1777
+ const command = getLineCommand(line);
1611
1778
  if (!COMMAND_REGEX.test(command) || !this.server.getCommandHandler(command)) {
1612
1779
  return refuse('Unknown command');
1613
1780
  }
@@ -2027,6 +2194,26 @@ class IMAPConnection {
2027
2194
  });
2028
2195
  }
2029
2196
  }
2197
+ const compiled = this.compileResponse(response);
2198
+ if (compiled === null) {
2199
+ return;
2200
+ }
2201
+ const event = response.tag === '+' ? 'continuation' : 'response';
2202
+ if (!this.server.script.watches(event)) {
2203
+ this.write(compiled);
2204
+ return;
2205
+ }
2206
+ // script rules see the response after every plugin and the core changed it. Without a command, an
2207
+ // unsolicited response belongs to the command that runs or idles
2208
+ this.scriptOutput(event, compiled, Object.assign({ description: description || null, response }, parsed && parsed.command ? { tag: parsed.tag || null, command: String(parsed.command).toUpperCase() } : {}), output => this.compileResponse(output));
2209
+ }
2210
+ /**
2211
+ * Compiles a response for the wire
2212
+ *
2213
+ * @param {Object} response Response object
2214
+ * @return {String|null} the response with its CRLF as a binary string, or null for an untagged response that does not compile
2215
+ */
2216
+ compileResponse(response) {
2030
2217
  let compiled;
2031
2218
  try {
2032
2219
  compiled = imapHandler.compiler(response, this.compilerOptions);
@@ -2037,14 +2224,14 @@ class IMAPConnection {
2037
2224
  console.log('Failed to compile response: %s', err.message);
2038
2225
  }
2039
2226
  if (response.tag === '*') {
2040
- return;
2227
+ return null;
2041
2228
  }
2042
2229
  compiled = response.tag + ' NO [SERVERBUG] Failed to compile response';
2043
2230
  }
2044
2231
  if (this.options.debug) {
2045
2232
  console.log('SEND: %s', compiled);
2046
2233
  }
2047
- this.write(compiled + '\r\n');
2234
+ return compiled + '\r\n';
2048
2235
  }
2049
2236
  /**
2050
2237
  * Sends a tagged status response to a command
@@ -2231,9 +2418,23 @@ class IMAPConnection {
2231
2418
  exportMailboxName(path) {
2232
2419
  return path;
2233
2420
  }
2234
- scheduleCommand(data) {
2421
+ /**
2422
+ * Parses a command line and queues the command, or answers it right away when it can not run
2423
+ *
2424
+ * @param {String} data Command line with its literals, without the final CRLF
2425
+ * @param {Boolean} [scripted] The line comes from a script rule with `run`, it is next in the queue
2426
+ */
2427
+ scheduleCommand(data, scripted) {
2235
2428
  let parsed;
2236
2429
  const tag = getResponseTag(data);
2430
+ // the rule is chosen when the line arrives, the state it matches is the state at that moment. The rule
2431
+ // acts when the command's turn comes, so that its output keeps the order of the responses
2432
+ const found = !scripted && this.server.script.watches('command') ? this.server.script.check(this, 'command', { data, tag, command: getLineCommand(data) }) : null;
2433
+ if (found) {
2434
+ this._commandQueue.push({ parsed: { tag, command: found.context.command || '' }, data, script: found });
2435
+ this.processQueue();
2436
+ return;
2437
+ }
2237
2438
  try {
2238
2439
  // server.parserOptions are the defaults of plugins, connection.parserOptions win
2239
2440
  parsed = imapHandler.parser(data, Object.assign({ literalPlus: this.server.literalPlus }, this.server.parserOptions, this.parserOptions));
@@ -2280,10 +2481,14 @@ class IMAPConnection {
2280
2481
  this.sendStatus(parsed, data, 'BAD', 'Commands with message sequence numbers must wait for the completion of earlier commands');
2281
2482
  return;
2282
2483
  }
2283
- this._commandQueue.push({
2284
- parsed: parsed,
2285
- data: data
2286
- });
2484
+ const element = { parsed, data };
2485
+ if (scripted) {
2486
+ // processQueue runs it once the script rule released the queue
2487
+ this._commandQueue.unshift(element);
2488
+ }
2489
+ else {
2490
+ this._commandQueue.push(element);
2491
+ }
2287
2492
  this.processQueue();
2288
2493
  }
2289
2494
  else if (/^AUTHENTICATE /i.test(parsed.command)) {
@@ -2312,6 +2517,33 @@ class IMAPConnection {
2312
2517
  }, 'UNKNOWN COMMAND', parsed, data);
2313
2518
  }
2314
2519
  }
2520
+ /**
2521
+ * Handles a command line with the script rule that matched it, after the rule's delay. With `run` the line
2522
+ * goes through the parser and the command handler as usual afterwards
2523
+ *
2524
+ * @param {Object} element Queued command with the rule
2525
+ * @param {Function} next Releases the queue
2526
+ */
2527
+ runScriptedCommand(element, next) {
2528
+ const { rule, context } = element.script;
2529
+ const act = () => {
2530
+ if (!this.socket || this._closing) {
2531
+ return next();
2532
+ }
2533
+ handleLine(this, rule, context, () => {
2534
+ // the line is not running yet, it must not count as an earlier command (RFC 3501 section 5.5)
2535
+ this._runningCommand = null;
2536
+ this.scheduleCommand(element.data, true);
2537
+ });
2538
+ next();
2539
+ };
2540
+ if (rule.delay) {
2541
+ setTimeout(act, rule.delay);
2542
+ }
2543
+ else {
2544
+ act();
2545
+ }
2546
+ }
2315
2547
  processQueue(force) {
2316
2548
  if (!force && this._processing) {
2317
2549
  return;
@@ -2344,6 +2576,10 @@ class IMAPConnection {
2344
2576
  this.processQueue(true);
2345
2577
  }
2346
2578
  };
2579
+ if (element.script) {
2580
+ this.runScriptedCommand(element, next);
2581
+ return;
2582
+ }
2347
2583
  if (options.states && options.states.indexOf(this.state) < 0) {
2348
2584
  this.sendStatus(element.parsed, element.data, 'BAD', stateError(command, this.state));
2349
2585
  return next();
@@ -2386,7 +2622,12 @@ class IMAPConnection {
2386
2622
  try {
2387
2623
  // changes made while the handler runs are attributed to this session (the `origin` of notifications)
2388
2624
  this.server.activeConnection = this;
2625
+ const inputHandler = this.inputHandler;
2389
2626
  this.server.getCommandHandler(element.parsed.command)(this, element.parsed, element.data, next);
2627
+ if (this.inputHandler && this.inputHandler !== inputHandler) {
2628
+ // the command reads the lines that follow (IDLE, AUTHENTICATE), script rules match them with it
2629
+ this.inputCommand = { tag: element.parsed.tag, command: element.parsed.command };
2630
+ }
2390
2631
  }
2391
2632
  catch (E) {
2392
2633
  const ex = E;
@@ -2507,6 +2748,41 @@ class IMAPConnection {
2507
2748
  }, mailbox, ignoreSelf || ignoreExists ? this : false);
2508
2749
  }
2509
2750
  }
2751
+ /**
2752
+ * Copies a response tree for the `mutate` action of a script rule: arrays and plain objects are copied,
2753
+ * other values (Buffers) are shared
2754
+ *
2755
+ * @param {*} value Response or a part of it
2756
+ * @return {*} Copy
2757
+ */
2758
+ function cloneResponse(value) {
2759
+ if (Array.isArray(value)) {
2760
+ return value.map(cloneResponse);
2761
+ }
2762
+ if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
2763
+ const copy = {};
2764
+ for (const key of Object.keys(value)) {
2765
+ copy[key] = cloneResponse(value[key]);
2766
+ }
2767
+ return copy;
2768
+ }
2769
+ return value;
2770
+ }
2771
+ /**
2772
+ * Finds the command name in a command line that may not parse: tag SP command, and for UID and AUTHENTICATE
2773
+ * the word that follows
2774
+ *
2775
+ * @param {String} line Command line
2776
+ * @return {String} Command name in upper case, can be empty
2777
+ */
2778
+ function getLineCommand(line) {
2779
+ const words = line.match(/^[^ ]* ([^ ]*)(?: ([^ ]*))?/);
2780
+ let command = ((words && words[1]) || '').toUpperCase();
2781
+ if (command === 'UID' || command === 'AUTHENTICATE') {
2782
+ command += ' ' + ((words && words[2]) || '').toUpperCase();
2783
+ }
2784
+ return command;
2785
+ }
2510
2786
  /**
2511
2787
  * Formats a mailbox name for a response: an atom when possible, otherwise a string. NIL and names
2512
2788
  * like \\Foo would not read back as mailbox names, so these are strings as well
@@ -1,4 +1,5 @@
1
1
  import type { IMAPServer, IMAPConnection } from './server.js';
2
+ import type { ScriptRule } from './script.js';
2
3
  export type { IMAPServer, IMAPConnection };
3
4
  /**
4
5
  * A value of the parsed command or of a response, as imap-handler parses and compiles it: an
@@ -229,6 +230,8 @@ export interface IMAPServerOptions {
229
230
  systemFlags?: string[] | undefined;
230
231
  /** largest literal accepted after login, in octets */
231
232
  maxLiteralSize?: number | undefined;
233
+ /** script rules that make the server misbehave on purpose, see src/script.ts and README "Scripted faults" */
234
+ script?: ScriptRule | ScriptRule[] | undefined;
232
235
  [key: string]: any;
233
236
  }
234
237
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapkit",
3
- "version": "4.1.1",
3
+ "version": "4.2.0",
4
4
  "description": "Scriptable, strictly RFC compliant in-memory IMAP server for testing IMAP clients",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",
@@ -93,7 +93,7 @@
93
93
  "eslint": "10.12.0",
94
94
  "eslint-config-prettier": "10.1.8",
95
95
  "globals": "17.13.0",
96
- "imapflow": "2.2.8",
96
+ "imapflow": "2.2.9",
97
97
  "prettier": "3.9.9",
98
98
  "tsx": "4.23.15",
99
99
  "typescript": "6.0.3",