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.
@@ -54,6 +54,7 @@ const numbers_js_1 = require("./numbers.js");
54
54
  const arguments_js_1 = require("./arguments.js");
55
55
  const append_js_1 = require("./commands/append.js");
56
56
  const bundledCert = __importStar(require("./cert.js"));
57
+ const script_js_1 = require("./script.js");
57
58
  // longest command line (not counting literals) accepted from a client
58
59
  const MAX_LINE_LENGTH = 1024 * 1024;
59
60
  // largest literal accepted after login, override with the maxLiteralSize option
@@ -196,6 +197,9 @@ class IMAPServer extends node_stream_1.Stream {
196
197
  this.referenceNamespace = false;
197
198
  // the session whose command is running, see IMAPServer#notify
198
199
  this.activeConnection = null;
200
+ this.sessionCounter = 0;
201
+ // rules that make the server misbehave on purpose, from the script option or server.script.add()
202
+ this.script = new script_js_1.ServerScript(this, this.options.script);
199
203
  // users and storage are deep copied, so that runtime changes never leak into
200
204
  // the caller's objects or into other servers built from the same fixture.
201
205
  // Without a prototype, user names like "__proto__" or "toString" are plain keys
@@ -1254,6 +1258,9 @@ class IMAPConnection {
1254
1258
  this.socket = socket;
1255
1259
  this.options = this.server.options;
1256
1260
  this.state = 'Not Authenticated';
1261
+ this.sessionNumber = ++this.server.sessionCounter;
1262
+ this._outputQueue = null;
1263
+ this._outputTimer = null;
1257
1264
  this.secureConnection = !!this.options.secureConnection;
1258
1265
  this._remainder = '';
1259
1266
  this._command = '';
@@ -1283,7 +1290,7 @@ class IMAPConnection {
1283
1290
  this.notificationQueue = [];
1284
1291
  this.server.on('notify', this._notificationCallback);
1285
1292
  this.server.connections.add(this);
1286
- this.write('* OK ImapKit ready for rumble\r\n');
1293
+ this.scriptOutput('greeting', '* OK ImapKit ready for rumble\r\n', {});
1287
1294
  }
1288
1295
  /**
1289
1296
  * Writes protocol output to the client, through the transport layer if there is one
@@ -1292,12 +1299,134 @@ class IMAPConnection {
1292
1299
  */
1293
1300
  write(data) {
1294
1301
  const buffer = typeof data === 'string' ? Buffer.from(data, 'binary') : data;
1295
- if (this.transport) {
1296
- this.transport.write(buffer);
1302
+ if (this._outputQueue) {
1303
+ this._outputQueue.push({ data: buffer, transport: this.transport });
1304
+ }
1305
+ else {
1306
+ this.writeLayer(buffer, this.transport);
1307
+ }
1308
+ }
1309
+ /**
1310
+ * Writes output, or puts it in the output queue while earlier output waits for a delay of a script rule.
1311
+ * The transport layer is taken when the output is queued, so output from before COMPRESS is not compressed
1312
+ *
1313
+ * @param {Object} operation `{ data, delay, chunk, chunkDelay, close }`, see OutputOperation in src/script.ts
1314
+ */
1315
+ queueOutput(operation) {
1316
+ if (!this._outputQueue && !operation.delay && !operation.chunk) {
1317
+ if (operation.data) {
1318
+ this.writeLayer(operation.data, this.transport);
1319
+ }
1320
+ if (operation.close) {
1321
+ this.closeNow(operation.close);
1322
+ }
1323
+ return;
1324
+ }
1325
+ this._outputQueue = this._outputQueue || [];
1326
+ this._outputQueue.push(Object.assign({}, operation, { transport: this.transport }));
1327
+ if (!this._outputTimer) {
1328
+ this.flushOutput();
1329
+ }
1330
+ }
1331
+ /**
1332
+ * Writes the output queue until it is empty or a delay stops it
1333
+ */
1334
+ flushOutput() {
1335
+ const queue = this._outputQueue || [];
1336
+ while (queue.length) {
1337
+ const operation = queue[0];
1338
+ if (operation.delay) {
1339
+ const delay = operation.delay;
1340
+ operation.delay = 0;
1341
+ this._outputTimer = setTimeout(() => {
1342
+ this._outputTimer = null;
1343
+ this.flushOutput();
1344
+ }, delay);
1345
+ return;
1346
+ }
1347
+ if (operation.data && operation.chunk && operation.data.length > operation.chunk) {
1348
+ // the rest waits for chunkDelay, like a delay of its own
1349
+ this.writeLayer(operation.data.subarray(0, operation.chunk), operation.transport || null);
1350
+ operation.data = operation.data.subarray(operation.chunk);
1351
+ operation.delay = operation.chunkDelay;
1352
+ continue;
1353
+ }
1354
+ if (operation.data) {
1355
+ this.writeLayer(operation.data, operation.transport || null);
1356
+ }
1357
+ queue.shift();
1358
+ if (operation.close) {
1359
+ this.closeNow(operation.close);
1360
+ return;
1361
+ }
1362
+ }
1363
+ this._outputQueue = null;
1364
+ }
1365
+ /**
1366
+ * Drops the output that waits for a delay of a script rule
1367
+ */
1368
+ clearOutputQueue() {
1369
+ if (this._outputTimer) {
1370
+ clearTimeout(this._outputTimer);
1371
+ this._outputTimer = null;
1372
+ }
1373
+ this._outputQueue = null;
1374
+ }
1375
+ /**
1376
+ * Writes output through a transport layer, or to the socket
1377
+ *
1378
+ * @param {Buffer} data Output
1379
+ * @param {Object|null} transport Transport layer
1380
+ */
1381
+ writeLayer(data, transport) {
1382
+ if (transport) {
1383
+ transport.write(data);
1297
1384
  }
1298
1385
  else {
1299
- this.writeRaw(buffer);
1386
+ this.writeRaw(data);
1387
+ }
1388
+ }
1389
+ /**
1390
+ * Sends output through the script rule that handles its event, if there is one
1391
+ *
1392
+ * @param {String} event Event name: greeting, response or continuation
1393
+ * @param {String} output The output as a binary string
1394
+ * @param {Object} fields Context fields of the event (tag, command, description, response)
1395
+ * @param {Function} [compile] Compiles the response that a `mutate` action changed, null drops the output
1396
+ */
1397
+ scriptOutput(event, output, fields, compile) {
1398
+ const found = this.server.script.check(this, event, Object.assign({}, fields, { data: output }));
1399
+ if (!found) {
1400
+ this.write(output);
1401
+ return;
1402
+ }
1403
+ const { rule, context } = found;
1404
+ if (rule.mutate && compile && context.response) {
1405
+ // a copy, a notification object is shared by every session
1406
+ const copy = cloneResponse(context.response);
1407
+ const changed = compile(rule.mutate(copy, context) || copy);
1408
+ if (changed === null) {
1409
+ return;
1410
+ }
1411
+ context.data = changed;
1412
+ }
1413
+ (0, script_js_1.sendOutput)(this, rule, context, Buffer.from(context.data, 'binary'));
1414
+ }
1415
+ /**
1416
+ * Sends a continuation request, `+ text`
1417
+ *
1418
+ * @param {String} text Human readable text, can be empty (SASL)
1419
+ * @param {String} description Description for script rules
1420
+ * @param {Function} [getLine] Returns the command line received so far, for the tag and the command of a literal continuation
1421
+ */
1422
+ sendContinuation(text, description, getLine) {
1423
+ const output = '+ ' + text + '\r\n';
1424
+ if (!this.server.script.watches('continuation')) {
1425
+ this.write(output);
1426
+ return;
1300
1427
  }
1428
+ const line = getLine ? getLine() : null;
1429
+ this.scriptOutput('continuation', output, line === null ? { description } : { description, tag: getResponseTag(line), command: getLineCommand(line) });
1301
1430
  }
1302
1431
  /**
1303
1432
  * Writes data to the socket, below the transport layer
@@ -1327,12 +1456,37 @@ class IMAPConnection {
1327
1456
  * holds, is written out
1328
1457
  */
1329
1458
  end() {
1459
+ if (this._outputQueue && this.socket) {
1460
+ // output still waits for a delay of a script rule
1461
+ this._closing = true;
1462
+ this._outputQueue.push({ close: true });
1463
+ return;
1464
+ }
1465
+ this.closeNow(true);
1466
+ }
1467
+ /**
1468
+ * Closes the connection now, after the output written so far
1469
+ *
1470
+ * @param {Boolean|String} mode true ends the connection gracefully, "reset" destroys the socket (script rules)
1471
+ */
1472
+ closeNow(mode) {
1330
1473
  const socket = this.socket;
1331
1474
  if (!socket) {
1332
1475
  return;
1333
1476
  }
1334
1477
  this._closing = true;
1335
- if (this.transport) {
1478
+ this.clearOutputQueue();
1479
+ if (mode === 'reset') {
1480
+ this.discardInput();
1481
+ // resetAndDestroy sends a TCP RST (Node 16.17), not every runtime has it
1482
+ if (typeof socket.resetAndDestroy === 'function') {
1483
+ socket.resetAndDestroy();
1484
+ }
1485
+ else {
1486
+ socket.destroy();
1487
+ }
1488
+ }
1489
+ else if (this.transport) {
1336
1490
  this.transport.end(() => socket.end());
1337
1491
  }
1338
1492
  else {
@@ -1433,6 +1587,7 @@ class IMAPConnection {
1433
1587
  this.transport.destroy();
1434
1588
  this.transport = null;
1435
1589
  }
1590
+ this.clearOutputQueue();
1436
1591
  this.server.removeListener('notify', this._notificationCallback);
1437
1592
  this.server.connections.delete(this);
1438
1593
  }
@@ -1448,6 +1603,21 @@ class IMAPConnection {
1448
1603
  // socket is already gone
1449
1604
  }
1450
1605
  }
1606
+ /**
1607
+ * Passes a line to the input handler (IDLE, AUTHENTICATE), unless a script rule handles it
1608
+ *
1609
+ * @param {String} line Input line without CRLF
1610
+ */
1611
+ handleInput(line) {
1612
+ const inputHandler = this.inputHandler;
1613
+ const found = this.server.script.check(this, 'input', { data: line });
1614
+ if (found) {
1615
+ (0, script_js_1.handleLine)(this, found.rule, found.context, () => inputHandler(line));
1616
+ }
1617
+ else {
1618
+ inputHandler(line);
1619
+ }
1620
+ }
1451
1621
  onData(chunk) {
1452
1622
  let match;
1453
1623
  let str;
@@ -1519,7 +1689,7 @@ class IMAPConnection {
1519
1689
  this.sendBad(getResponseTag(line), 'Literal data must wait for the continuation request', 'LITERAL TOO EARLY', line);
1520
1690
  }
1521
1691
  else if (this.inputHandler) {
1522
- this.inputHandler(line);
1692
+ this.handleInput(line);
1523
1693
  }
1524
1694
  else {
1525
1695
  this.scheduleCommand(line);
@@ -1568,7 +1738,9 @@ class IMAPConnection {
1568
1738
  this._earlyLiteral = true;
1569
1739
  }
1570
1740
  else if (!this._earlyLiteral) {
1571
- this.write('+ Go ahead\r\n');
1741
+ // the line is needed only by script rules that watch continuations
1742
+ const head = str.substr(0, match.index) + marker;
1743
+ this.sendContinuation('Go ahead', 'LITERAL', () => this._command + head);
1572
1744
  }
1573
1745
  }
1574
1746
  this._remainder = '';
@@ -1644,12 +1816,7 @@ class IMAPConnection {
1644
1816
  // not a command, e.g. a SASL response
1645
1817
  return literal8 ? refuse('Literal8 is not allowed here') : false;
1646
1818
  }
1647
- // tag SP command, and for UID and AUTHENTICATE the word that follows
1648
- const words = line.match(/^[^ ]* ([^ ]*)(?: ([^ ]*))?/);
1649
- let command = ((words && words[1]) || '').toUpperCase();
1650
- if (command === 'UID' || command === 'AUTHENTICATE') {
1651
- command += ' ' + ((words && words[2]) || '').toUpperCase();
1652
- }
1819
+ const command = getLineCommand(line);
1653
1820
  if (!COMMAND_REGEX.test(command) || !this.server.getCommandHandler(command)) {
1654
1821
  return refuse('Unknown command');
1655
1822
  }
@@ -2069,6 +2236,26 @@ class IMAPConnection {
2069
2236
  });
2070
2237
  }
2071
2238
  }
2239
+ const compiled = this.compileResponse(response);
2240
+ if (compiled === null) {
2241
+ return;
2242
+ }
2243
+ const event = response.tag === '+' ? 'continuation' : 'response';
2244
+ if (!this.server.script.watches(event)) {
2245
+ this.write(compiled);
2246
+ return;
2247
+ }
2248
+ // script rules see the response after every plugin and the core changed it. Without a command, an
2249
+ // unsolicited response belongs to the command that runs or idles
2250
+ 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));
2251
+ }
2252
+ /**
2253
+ * Compiles a response for the wire
2254
+ *
2255
+ * @param {Object} response Response object
2256
+ * @return {String|null} the response with its CRLF as a binary string, or null for an untagged response that does not compile
2257
+ */
2258
+ compileResponse(response) {
2072
2259
  let compiled;
2073
2260
  try {
2074
2261
  compiled = imap_handler_1.default.compiler(response, this.compilerOptions);
@@ -2079,14 +2266,14 @@ class IMAPConnection {
2079
2266
  console.log('Failed to compile response: %s', err.message);
2080
2267
  }
2081
2268
  if (response.tag === '*') {
2082
- return;
2269
+ return null;
2083
2270
  }
2084
2271
  compiled = response.tag + ' NO [SERVERBUG] Failed to compile response';
2085
2272
  }
2086
2273
  if (this.options.debug) {
2087
2274
  console.log('SEND: %s', compiled);
2088
2275
  }
2089
- this.write(compiled + '\r\n');
2276
+ return compiled + '\r\n';
2090
2277
  }
2091
2278
  /**
2092
2279
  * Sends a tagged status response to a command
@@ -2273,9 +2460,23 @@ class IMAPConnection {
2273
2460
  exportMailboxName(path) {
2274
2461
  return path;
2275
2462
  }
2276
- scheduleCommand(data) {
2463
+ /**
2464
+ * Parses a command line and queues the command, or answers it right away when it can not run
2465
+ *
2466
+ * @param {String} data Command line with its literals, without the final CRLF
2467
+ * @param {Boolean} [scripted] The line comes from a script rule with `run`, it is next in the queue
2468
+ */
2469
+ scheduleCommand(data, scripted) {
2277
2470
  let parsed;
2278
2471
  const tag = getResponseTag(data);
2472
+ // the rule is chosen when the line arrives, the state it matches is the state at that moment. The rule
2473
+ // acts when the command's turn comes, so that its output keeps the order of the responses
2474
+ const found = !scripted && this.server.script.watches('command') ? this.server.script.check(this, 'command', { data, tag, command: getLineCommand(data) }) : null;
2475
+ if (found) {
2476
+ this._commandQueue.push({ parsed: { tag, command: found.context.command || '' }, data, script: found });
2477
+ this.processQueue();
2478
+ return;
2479
+ }
2279
2480
  try {
2280
2481
  // server.parserOptions are the defaults of plugins, connection.parserOptions win
2281
2482
  parsed = imap_handler_1.default.parser(data, Object.assign({ literalPlus: this.server.literalPlus }, this.server.parserOptions, this.parserOptions));
@@ -2322,10 +2523,14 @@ class IMAPConnection {
2322
2523
  this.sendStatus(parsed, data, 'BAD', 'Commands with message sequence numbers must wait for the completion of earlier commands');
2323
2524
  return;
2324
2525
  }
2325
- this._commandQueue.push({
2326
- parsed: parsed,
2327
- data: data
2328
- });
2526
+ const element = { parsed, data };
2527
+ if (scripted) {
2528
+ // processQueue runs it once the script rule released the queue
2529
+ this._commandQueue.unshift(element);
2530
+ }
2531
+ else {
2532
+ this._commandQueue.push(element);
2533
+ }
2329
2534
  this.processQueue();
2330
2535
  }
2331
2536
  else if (/^AUTHENTICATE /i.test(parsed.command)) {
@@ -2354,6 +2559,33 @@ class IMAPConnection {
2354
2559
  }, 'UNKNOWN COMMAND', parsed, data);
2355
2560
  }
2356
2561
  }
2562
+ /**
2563
+ * Handles a command line with the script rule that matched it, after the rule's delay. With `run` the line
2564
+ * goes through the parser and the command handler as usual afterwards
2565
+ *
2566
+ * @param {Object} element Queued command with the rule
2567
+ * @param {Function} next Releases the queue
2568
+ */
2569
+ runScriptedCommand(element, next) {
2570
+ const { rule, context } = element.script;
2571
+ const act = () => {
2572
+ if (!this.socket || this._closing) {
2573
+ return next();
2574
+ }
2575
+ (0, script_js_1.handleLine)(this, rule, context, () => {
2576
+ // the line is not running yet, it must not count as an earlier command (RFC 3501 section 5.5)
2577
+ this._runningCommand = null;
2578
+ this.scheduleCommand(element.data, true);
2579
+ });
2580
+ next();
2581
+ };
2582
+ if (rule.delay) {
2583
+ setTimeout(act, rule.delay);
2584
+ }
2585
+ else {
2586
+ act();
2587
+ }
2588
+ }
2357
2589
  processQueue(force) {
2358
2590
  if (!force && this._processing) {
2359
2591
  return;
@@ -2386,6 +2618,10 @@ class IMAPConnection {
2386
2618
  this.processQueue(true);
2387
2619
  }
2388
2620
  };
2621
+ if (element.script) {
2622
+ this.runScriptedCommand(element, next);
2623
+ return;
2624
+ }
2389
2625
  if (options.states && options.states.indexOf(this.state) < 0) {
2390
2626
  this.sendStatus(element.parsed, element.data, 'BAD', stateError(command, this.state));
2391
2627
  return next();
@@ -2428,7 +2664,12 @@ class IMAPConnection {
2428
2664
  try {
2429
2665
  // changes made while the handler runs are attributed to this session (the `origin` of notifications)
2430
2666
  this.server.activeConnection = this;
2667
+ const inputHandler = this.inputHandler;
2431
2668
  this.server.getCommandHandler(element.parsed.command)(this, element.parsed, element.data, next);
2669
+ if (this.inputHandler && this.inputHandler !== inputHandler) {
2670
+ // the command reads the lines that follow (IDLE, AUTHENTICATE), script rules match them with it
2671
+ this.inputCommand = { tag: element.parsed.tag, command: element.parsed.command };
2672
+ }
2432
2673
  }
2433
2674
  catch (E) {
2434
2675
  const ex = E;
@@ -2550,6 +2791,41 @@ class IMAPConnection {
2550
2791
  }
2551
2792
  }
2552
2793
  exports.IMAPConnection = IMAPConnection;
2794
+ /**
2795
+ * Copies a response tree for the `mutate` action of a script rule: arrays and plain objects are copied,
2796
+ * other values (Buffers) are shared
2797
+ *
2798
+ * @param {*} value Response or a part of it
2799
+ * @return {*} Copy
2800
+ */
2801
+ function cloneResponse(value) {
2802
+ if (Array.isArray(value)) {
2803
+ return value.map(cloneResponse);
2804
+ }
2805
+ if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
2806
+ const copy = {};
2807
+ for (const key of Object.keys(value)) {
2808
+ copy[key] = cloneResponse(value[key]);
2809
+ }
2810
+ return copy;
2811
+ }
2812
+ return value;
2813
+ }
2814
+ /**
2815
+ * Finds the command name in a command line that may not parse: tag SP command, and for UID and AUTHENTICATE
2816
+ * the word that follows
2817
+ *
2818
+ * @param {String} line Command line
2819
+ * @return {String} Command name in upper case, can be empty
2820
+ */
2821
+ function getLineCommand(line) {
2822
+ const words = line.match(/^[^ ]* ([^ ]*)(?: ([^ ]*))?/);
2823
+ let command = ((words && words[1]) || '').toUpperCase();
2824
+ if (command === 'UID' || command === 'AUTHENTICATE') {
2825
+ command += ' ' + ((words && words[2]) || '').toUpperCase();
2826
+ }
2827
+ return command;
2828
+ }
2553
2829
  /**
2554
2830
  * Formats a mailbox name for a response: an atom when possible, otherwise a string. NIL and names
2555
2831
  * 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
  /**
@@ -1,6 +1,7 @@
1
1
  import createServer, { TAG_REGEX, IMAPServer, IMAPConnection } from './server.js';
2
2
  export { TAG_REGEX, IMAPServer, IMAPConnection };
3
3
  export type { Attribute, ParsedCommand, IMAPResponse, Notification, Callback, CommandHandler, CommandOptions, Plugin, IMAPError, Message, Mailbox, StorageNamespace, UserData, IMAPServerOptions } from './types.js';
4
+ export type { ScriptRule, ScriptContext, ScriptEvent, ScriptBytes, ScriptHandle } from './script.js';
4
5
  declare const imapkit: typeof createServer & {
5
6
  TAG_REGEX: RegExp;
6
7
  IMAPServer: typeof IMAPServer;
@@ -54,7 +54,7 @@ export default function authPlainPlugin(server) {
54
54
  authenticate(connection, parsed, data, str);
55
55
  };
56
56
  // Send an empty continuation request to the client
57
- connection.write('+ \r\n');
57
+ connection.sendContinuation('', 'AUTHENTICATE PLAIN');
58
58
  }
59
59
  else if (parsed.attributes.length === 1 &&
60
60
  // second argument must be Base64 string as ATOM
@@ -63,7 +63,7 @@ export default function idlePlugin(server) {
63
63
  }, 'INVALID IDLE', parsed, data);
64
64
  }
65
65
  };
66
- connection.write('+ idling\r\n');
66
+ connection.sendContinuation('idling', 'IDLE');
67
67
  connection.processNotifications();
68
68
  return callback();
69
69
  }, { states: states.AUTHENTICATED, noArguments: true });
@@ -165,7 +165,7 @@ export default function oauthbearerPlugin(server) {
165
165
  if (!args.length) {
166
166
  // without an initial response the client sends its response after an empty challenge
167
167
  readResponse(connection, parsed, data, decoded => authenticate(connection, parsed, data, decoded));
168
- connection.write('+ \r\n');
168
+ connection.sendContinuation('', 'AUTHENTICATE OAUTHBEARER');
169
169
  return callback();
170
170
  }
171
171
  if (args.length !== 1 || !args[0] || args[0].type !== 'ATOM') {