imapkit 4.1.1 → 4.3.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.
@@ -3,6 +3,8 @@ import net from 'node:net';
3
3
  import tls from 'node:tls';
4
4
  import type { CompilerOptions, ParserOptions } from 'imap-handler';
5
5
  import type { ResolvedCommandOptions } from './command-states.js';
6
+ import { ServerScript } from './script.js';
7
+ import type { DeferredOutput, OutputOperation, ScriptContext, ScriptEvent, ScriptRule } from './script.js';
6
8
  import type { AppendCheck, AppendCheckOptions, AppendDataHandler, AppendMessage, CapabilityCheck, CheckResult, ClosedCheck, CommandCheck, CommandContext, CommandOptions, CommandHandler, ConnectionHandler, ConnectionState, CopyHandler, FetchHandler, IMAPResponse, IMAPServerOptions, ListedMailbox, LiteralFilter, Mailbox, MailboxHandler, MailboxStatus, Message, MessageFilter, MessageHandler, MessageRange, Namespace, Notification, NotifyEvent, NotifyFilter, OutputHandler, ParsedCommand, RangeLimit, Refusal, SearchAccessCheck, SearchHandler, SearchLimit, ServerStorage, StatusHandler, StorageMailbox, StorageMessage, StoreHandler, SubscriptionStandIn, Transport, UrlAccessCheck, UserData } from './types.js';
7
9
  declare const TAG_REGEX: RegExp;
8
10
  /**
@@ -58,6 +60,10 @@ declare class IMAPServer extends Stream {
58
60
  uidvalidityCounter: number;
59
61
  subscriptions: Set<string>;
60
62
  folderCache: Record<string, Mailbox>;
63
+ /** scripted faults, see src/script.ts */
64
+ script: ServerScript;
65
+ /** connections accepted so far, the number of a connection is `connection.sessionNumber` */
66
+ sessionCounter: number;
61
67
  constructor(options?: IMAPServerOptions);
62
68
  /**
63
69
  * Starts accepting connections, takes the arguments of `net.Server#listen()`
@@ -446,6 +452,11 @@ declare class IMAPServer extends Stream {
446
452
  interface QueuedCommand {
447
453
  parsed: ParsedCommand;
448
454
  data: string;
455
+ /** a command line that a script rule handles instead of the parser and the command handler */
456
+ script?: {
457
+ rule: ScriptRule;
458
+ context: ScriptContext;
459
+ } | undefined;
449
460
  }
450
461
  declare class IMAPConnection {
451
462
  [key: string]: any;
@@ -471,6 +482,13 @@ declare class IMAPConnection {
471
482
  /** notifications are sent right away instead of before the next tagged response (IDLE) */
472
483
  directNotifications: boolean;
473
484
  notificationQueue: Notification[];
485
+ /** the number of the connection, 1 for the first one the server accepted (script rules match it) */
486
+ sessionNumber: number;
487
+ /** the tag and name of the command whose input handler reads the lines that follow (IDLE, AUTHENTICATE) */
488
+ inputCommand: {
489
+ tag: string;
490
+ command: string;
491
+ } | null | undefined;
474
492
  _remainder: string;
475
493
  _command: string;
476
494
  _literalRemaining: number;
@@ -488,6 +506,13 @@ declare class IMAPConnection {
488
506
  command: string;
489
507
  read: number | undefined;
490
508
  } | undefined;
509
+ /** output that waits behind a delay of a script rule, null when output is written right away */
510
+ _outputQueue: OutputOperation[] | null;
511
+ _outputTimer: ReturnType<typeof setTimeout> | null;
512
+ /** untagged responses that `defer` rules hold back, see releaseDeferred in src/script.ts */
513
+ _deferredOutput: DeferredOutput[] | null;
514
+ /** a script rule closes the connection with a RST, see closeNow */
515
+ _resetting: boolean | undefined;
491
516
  constructor(server: IMAPServer, socket: net.Socket);
492
517
  /**
493
518
  * Writes protocol output to the client, through the transport layer if there is one
@@ -495,6 +520,45 @@ declare class IMAPConnection {
495
520
  * @param {Buffer|String} data Data to send, a string is sent as a binary string
496
521
  */
497
522
  write(data: Buffer | string): void;
523
+ /**
524
+ * Writes output, or puts it in the output queue while earlier output waits for a delay of a script rule.
525
+ * The transport layer is taken when the output is queued, so output from before COMPRESS is not compressed
526
+ *
527
+ * @param {Object} operation `{ data, delay, chunk, chunkDelay, close }`, see OutputOperation in src/script.ts
528
+ */
529
+ queueOutput(operation: OutputOperation): void;
530
+ /**
531
+ * Writes the output queue until it is empty or a delay stops it
532
+ */
533
+ flushOutput(): void;
534
+ /**
535
+ * Drops the output that waits for a delay of a script rule or for its release (`defer`)
536
+ */
537
+ clearOutputQueue(): void;
538
+ /**
539
+ * Writes output through a transport layer, or to the socket
540
+ *
541
+ * @param {Buffer} data Output
542
+ * @param {Object|null} transport Transport layer
543
+ */
544
+ writeLayer(data: Buffer, transport: Transport | null): void;
545
+ /**
546
+ * Sends output through the script rule that handles its event, if there is one
547
+ *
548
+ * @param {String} event Event name: greeting, response or continuation
549
+ * @param {String} output The output as a binary string
550
+ * @param {Object} fields Context fields of the event (tag, command, description, response)
551
+ * @param {Function} [compile] Compiles the response that a `mutate` action changed, null drops the output
552
+ */
553
+ scriptOutput(event: ScriptEvent, output: string, fields: Partial<ScriptContext>, compile?: (response: IMAPResponse) => string | null): void;
554
+ /**
555
+ * Sends a continuation request, `+ text`
556
+ *
557
+ * @param {String} text Human readable text, can be empty (SASL)
558
+ * @param {String} description Description for script rules
559
+ * @param {Function} [getLine] Returns the command line received so far, for the tag and the command of a literal continuation
560
+ */
561
+ sendContinuation(text: string, description: string, getLine?: () => string): void;
498
562
  /**
499
563
  * Writes data to the socket, below the transport layer
500
564
  *
@@ -512,6 +576,12 @@ declare class IMAPConnection {
512
576
  * holds, is written out
513
577
  */
514
578
  end(): void;
579
+ /**
580
+ * Closes the connection now, after the output written so far
581
+ *
582
+ * @param {Boolean|String} mode true ends the connection gracefully, "reset" destroys the socket (script rules)
583
+ */
584
+ closeNow(mode: boolean | 'reset'): void;
515
585
  /**
516
586
  * Sends an untagged BYE, drops unprocessed input and closes the connection (RFC 3501 section 7.1.5)
517
587
  *
@@ -564,6 +634,12 @@ declare class IMAPConnection {
564
634
  closeMailbox(): void;
565
635
  onClose(): void;
566
636
  onError(err: Error): void;
637
+ /**
638
+ * Passes a line to the input handler (IDLE, AUTHENTICATE), unless a script rule handles it
639
+ *
640
+ * @param {String} line Input line without CRLF
641
+ */
642
+ handleInput(line: string): void;
567
643
  onData(chunk: Buffer): void;
568
644
  /**
569
645
  * Reads literal data that the current command is waiting for. The data of a command that was
@@ -752,6 +828,22 @@ declare class IMAPConnection {
752
828
  * FETCH result. (This may have other names when used, like "affected".)
753
829
  */
754
830
  send(response: IMAPResponse, description?: string, parsed?: CommandContext | null, data?: string | null, ...extra: any[]): void;
831
+ /**
832
+ * Sends a compiled response through the script rules that watch responses or continuations
833
+ *
834
+ * @param {Object} response Response object
835
+ * @param {String} compiled The compiled response
836
+ * @param {String} [description] Description of the response
837
+ * @param {Object} [parsed] The command the response belongs to
838
+ */
839
+ scriptResponse(response: IMAPResponse, compiled: string, description?: string, parsed?: CommandContext | null): void;
840
+ /**
841
+ * Compiles a response for the wire
842
+ *
843
+ * @param {Object} response Response object
844
+ * @return {String|null} the response with its CRLF as a binary string, or null for an untagged response that does not compile
845
+ */
846
+ compileResponse(response: IMAPResponse): string | null;
755
847
  /**
756
848
  * Sends a tagged status response to a command
757
849
  *
@@ -848,7 +940,21 @@ declare class IMAPConnection {
848
940
  * @return {String} Mailbox name as a binary string
849
941
  */
850
942
  exportMailboxName(path: string): string;
851
- scheduleCommand(data: string): void;
943
+ /**
944
+ * Parses a command line and queues the command, or answers it right away when it can not run
945
+ *
946
+ * @param {String} data Command line with its literals, without the final CRLF
947
+ * @param {Boolean} [scripted] The line comes from a script rule with `run`, it is next in the queue
948
+ */
949
+ scheduleCommand(data: string, scripted?: boolean): void;
950
+ /**
951
+ * Handles a command line with the script rule that matched it, after the rule's delay. With `run` the line
952
+ * goes through the parser and the command handler as usual afterwards
953
+ *
954
+ * @param {Object} element Queued command with the rule
955
+ * @param {Function} next Releases the queue
956
+ */
957
+ runScriptedCommand(element: QueuedCommand, next: () => void): void;
852
958
  processQueue(force?: boolean): void;
853
959
  /**
854
960
  * Removes messages with \Deleted flag
@@ -14,8 +14,11 @@ 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, literalResponse, releaseDeferred } from './script.js';
17
18
  // longest command line (not counting literals) accepted from a client
18
19
  const MAX_LINE_LENGTH = 1024 * 1024;
20
+ // milliseconds between the last output and the RST of a script rule with `close: 'reset'`
21
+ const RESET_DELAY = 20;
19
22
  // largest literal accepted after login, override with the maxLiteralSize option
20
23
  const MAX_LITERAL_SIZE = 64 * 1024 * 1024;
21
24
  // largest literal accepted before login, enough for any user name or password
@@ -155,6 +158,9 @@ class IMAPServer extends Stream {
155
158
  this.referenceNamespace = false;
156
159
  // the session whose command is running, see IMAPServer#notify
157
160
  this.activeConnection = null;
161
+ this.sessionCounter = 0;
162
+ // rules that make the server misbehave on purpose, from the script option or server.script.add()
163
+ this.script = new ServerScript(this, this.options.script);
158
164
  // users and storage are deep copied, so that runtime changes never leak into
159
165
  // the caller's objects or into other servers built from the same fixture.
160
166
  // Without a prototype, user names like "__proto__" or "toString" are plain keys
@@ -1212,6 +1218,10 @@ class IMAPConnection {
1212
1218
  this.socket = socket;
1213
1219
  this.options = this.server.options;
1214
1220
  this.state = 'Not Authenticated';
1221
+ this.sessionNumber = ++this.server.sessionCounter;
1222
+ this._outputQueue = null;
1223
+ this._outputTimer = null;
1224
+ this._deferredOutput = null;
1215
1225
  this.secureConnection = !!this.options.secureConnection;
1216
1226
  this._remainder = '';
1217
1227
  this._command = '';
@@ -1241,7 +1251,7 @@ class IMAPConnection {
1241
1251
  this.notificationQueue = [];
1242
1252
  this.server.on('notify', this._notificationCallback);
1243
1253
  this.server.connections.add(this);
1244
- this.write('* OK ImapKit ready for rumble\r\n');
1254
+ this.scriptOutput('greeting', '* OK ImapKit ready for rumble\r\n', {});
1245
1255
  }
1246
1256
  /**
1247
1257
  * Writes protocol output to the client, through the transport layer if there is one
@@ -1250,12 +1260,139 @@ class IMAPConnection {
1250
1260
  */
1251
1261
  write(data) {
1252
1262
  const buffer = typeof data === 'string' ? Buffer.from(data, 'binary') : data;
1253
- if (this.transport) {
1254
- this.transport.write(buffer);
1263
+ if (this._outputQueue) {
1264
+ this._outputQueue.push({ data: buffer, transport: this.transport });
1265
+ }
1266
+ else {
1267
+ this.writeLayer(buffer, this.transport);
1268
+ }
1269
+ }
1270
+ /**
1271
+ * Writes output, or puts it in the output queue while earlier output waits for a delay of a script rule.
1272
+ * The transport layer is taken when the output is queued, so output from before COMPRESS is not compressed
1273
+ *
1274
+ * @param {Object} operation `{ data, delay, chunk, chunkDelay, close }`, see OutputOperation in src/script.ts
1275
+ */
1276
+ queueOutput(operation) {
1277
+ if (!this._outputQueue && !operation.delay && !operation.chunk) {
1278
+ if (operation.data) {
1279
+ this.writeLayer(operation.data, this.transport);
1280
+ }
1281
+ if (operation.close) {
1282
+ this.closeNow(operation.close);
1283
+ }
1284
+ return;
1285
+ }
1286
+ this._outputQueue = this._outputQueue || [];
1287
+ this._outputQueue.push(Object.assign({}, operation, { transport: this.transport }));
1288
+ if (!this._outputTimer) {
1289
+ this.flushOutput();
1290
+ }
1291
+ }
1292
+ /**
1293
+ * Writes the output queue until it is empty or a delay stops it
1294
+ */
1295
+ flushOutput() {
1296
+ const queue = this._outputQueue || [];
1297
+ while (queue.length) {
1298
+ const operation = queue[0];
1299
+ if (operation.delay) {
1300
+ const delay = operation.delay;
1301
+ operation.delay = 0;
1302
+ this._outputTimer = setTimeout(() => {
1303
+ this._outputTimer = null;
1304
+ this.flushOutput();
1305
+ }, delay);
1306
+ return;
1307
+ }
1308
+ if (operation.data && operation.chunk && operation.data.length > operation.chunk) {
1309
+ // the rest waits for chunkDelay, like a delay of its own
1310
+ this.writeLayer(operation.data.subarray(0, operation.chunk), operation.transport || null);
1311
+ operation.data = operation.data.subarray(operation.chunk);
1312
+ operation.delay = operation.chunkDelay;
1313
+ continue;
1314
+ }
1315
+ if (operation.data) {
1316
+ this.writeLayer(operation.data, operation.transport || null);
1317
+ }
1318
+ queue.shift();
1319
+ if (operation.close) {
1320
+ this.closeNow(operation.close);
1321
+ return;
1322
+ }
1323
+ }
1324
+ this._outputQueue = null;
1325
+ }
1326
+ /**
1327
+ * Drops the output that waits for a delay of a script rule or for its release (`defer`)
1328
+ */
1329
+ clearOutputQueue() {
1330
+ if (this._outputTimer) {
1331
+ clearTimeout(this._outputTimer);
1332
+ this._outputTimer = null;
1333
+ }
1334
+ this._outputQueue = null;
1335
+ this._deferredOutput = null;
1336
+ }
1337
+ /**
1338
+ * Writes output through a transport layer, or to the socket
1339
+ *
1340
+ * @param {Buffer} data Output
1341
+ * @param {Object|null} transport Transport layer
1342
+ */
1343
+ writeLayer(data, transport) {
1344
+ if (transport) {
1345
+ transport.write(data);
1255
1346
  }
1256
1347
  else {
1257
- this.writeRaw(buffer);
1348
+ this.writeRaw(data);
1349
+ }
1350
+ }
1351
+ /**
1352
+ * Sends output through the script rule that handles its event, if there is one
1353
+ *
1354
+ * @param {String} event Event name: greeting, response or continuation
1355
+ * @param {String} output The output as a binary string
1356
+ * @param {Object} fields Context fields of the event (tag, command, description, response)
1357
+ * @param {Function} [compile] Compiles the response that a `mutate` action changed, null drops the output
1358
+ */
1359
+ scriptOutput(event, output, fields, compile) {
1360
+ const found = this.server.script.check(this, event, Object.assign({}, fields, { data: output }));
1361
+ if (!found) {
1362
+ this.write(output);
1363
+ return;
1364
+ }
1365
+ const { rule, context } = found;
1366
+ if ((rule.mutate || rule.literals) && compile && context.response) {
1367
+ let response = context.response;
1368
+ if (rule.mutate) {
1369
+ // a copy, a notification object is shared by every session
1370
+ const copy = cloneResponse(response);
1371
+ response = rule.mutate(copy, context) || copy;
1372
+ }
1373
+ const changed = compile(rule.literals ? literalResponse(response) : response);
1374
+ if (changed === null) {
1375
+ return;
1376
+ }
1377
+ context.data = changed;
1258
1378
  }
1379
+ sendOutput(this, rule, context, Buffer.from(context.data, 'binary'));
1380
+ }
1381
+ /**
1382
+ * Sends a continuation request, `+ text`
1383
+ *
1384
+ * @param {String} text Human readable text, can be empty (SASL)
1385
+ * @param {String} description Description for script rules
1386
+ * @param {Function} [getLine] Returns the command line received so far, for the tag and the command of a literal continuation
1387
+ */
1388
+ sendContinuation(text, description, getLine) {
1389
+ const output = '+ ' + text + '\r\n';
1390
+ if (!this.server.script.watches('continuation')) {
1391
+ this.write(output);
1392
+ return;
1393
+ }
1394
+ const line = getLine ? getLine() : null;
1395
+ this.scriptOutput('continuation', output, line === null ? { description } : { description, tag: getResponseTag(line), command: getLineCommand(line) });
1259
1396
  }
1260
1397
  /**
1261
1398
  * Writes data to the socket, below the transport layer
@@ -1285,12 +1422,47 @@ class IMAPConnection {
1285
1422
  * holds, is written out
1286
1423
  */
1287
1424
  end() {
1425
+ if (this._outputQueue && this.socket) {
1426
+ // output still waits for a delay of a script rule
1427
+ this._closing = true;
1428
+ this._outputQueue.push({ close: true });
1429
+ return;
1430
+ }
1431
+ this.closeNow(true);
1432
+ }
1433
+ /**
1434
+ * Closes the connection now, after the output written so far
1435
+ *
1436
+ * @param {Boolean|String} mode true ends the connection gracefully, "reset" destroys the socket (script rules)
1437
+ */
1438
+ closeNow(mode) {
1288
1439
  const socket = this.socket;
1289
1440
  if (!socket) {
1290
1441
  return;
1291
1442
  }
1292
1443
  this._closing = true;
1293
- if (this.transport) {
1444
+ this.clearOutputQueue();
1445
+ if (mode === 'reset') {
1446
+ this.discardInput();
1447
+ // input that arrives while the reset waits is not processed, also input a transport layer still holds
1448
+ this._resetting = true;
1449
+ socket.pause();
1450
+ // a RST sent in the same tick as the last output can get lost: on macOS the client received the output but
1451
+ // never the RST (#81). Node has no signal for output that reached the peer, so the RST waits a little
1452
+ setTimeout(() => {
1453
+ if (socket.destroyed) {
1454
+ return;
1455
+ }
1456
+ // resetAndDestroy sends a TCP RST (Node 16.17), not every runtime has it
1457
+ if (typeof socket.resetAndDestroy === 'function') {
1458
+ socket.resetAndDestroy();
1459
+ }
1460
+ else {
1461
+ socket.destroy();
1462
+ }
1463
+ }, RESET_DELAY);
1464
+ }
1465
+ else if (this.transport) {
1294
1466
  this.transport.end(() => socket.end());
1295
1467
  }
1296
1468
  else {
@@ -1391,6 +1563,7 @@ class IMAPConnection {
1391
1563
  this.transport.destroy();
1392
1564
  this.transport = null;
1393
1565
  }
1566
+ this.clearOutputQueue();
1394
1567
  this.server.removeListener('notify', this._notificationCallback);
1395
1568
  this.server.connections.delete(this);
1396
1569
  }
@@ -1406,9 +1579,27 @@ class IMAPConnection {
1406
1579
  // socket is already gone
1407
1580
  }
1408
1581
  }
1582
+ /**
1583
+ * Passes a line to the input handler (IDLE, AUTHENTICATE), unless a script rule handles it
1584
+ *
1585
+ * @param {String} line Input line without CRLF
1586
+ */
1587
+ handleInput(line) {
1588
+ const inputHandler = this.inputHandler;
1589
+ const found = this.server.script.check(this, 'input', { data: line });
1590
+ if (found) {
1591
+ handleLine(this, found.rule, found.context, () => inputHandler(line));
1592
+ }
1593
+ else {
1594
+ inputHandler(line);
1595
+ }
1596
+ }
1409
1597
  onData(chunk) {
1410
1598
  let match;
1411
1599
  let str;
1600
+ if (this._resetting) {
1601
+ return;
1602
+ }
1412
1603
  // everything in one read arrived before anything this read causes to be sent
1413
1604
  this._readCount = (this._readCount || 0) + 1;
1414
1605
  str = (chunk || '').toString('binary');
@@ -1477,7 +1668,7 @@ class IMAPConnection {
1477
1668
  this.sendBad(getResponseTag(line), 'Literal data must wait for the continuation request', 'LITERAL TOO EARLY', line);
1478
1669
  }
1479
1670
  else if (this.inputHandler) {
1480
- this.inputHandler(line);
1671
+ this.handleInput(line);
1481
1672
  }
1482
1673
  else {
1483
1674
  this.scheduleCommand(line);
@@ -1526,7 +1717,9 @@ class IMAPConnection {
1526
1717
  this._earlyLiteral = true;
1527
1718
  }
1528
1719
  else if (!this._earlyLiteral) {
1529
- this.write('+ Go ahead\r\n');
1720
+ // the line is needed only by script rules that watch continuations
1721
+ const head = str.substr(0, match.index) + marker;
1722
+ this.sendContinuation('Go ahead', 'LITERAL', () => this._command + head);
1530
1723
  }
1531
1724
  }
1532
1725
  this._remainder = '';
@@ -1602,12 +1795,7 @@ class IMAPConnection {
1602
1795
  // not a command, e.g. a SASL response
1603
1796
  return literal8 ? refuse('Literal8 is not allowed here') : false;
1604
1797
  }
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
- }
1798
+ const command = getLineCommand(line);
1611
1799
  if (!COMMAND_REGEX.test(command) || !this.server.getCommandHandler(command)) {
1612
1800
  return refuse('Unknown command');
1613
1801
  }
@@ -2027,6 +2215,44 @@ class IMAPConnection {
2027
2215
  });
2028
2216
  }
2029
2217
  }
2218
+ const compiled = this.compileResponse(response);
2219
+ if (compiled === null) {
2220
+ return;
2221
+ }
2222
+ if (this._deferredOutput) {
2223
+ // responses that `defer` rules hold back go before the first response of another command
2224
+ releaseDeferred(this, parsed && parsed.command && parsed.tag && parsed.tag !== '*' ? parsed.tag : null, false);
2225
+ }
2226
+ this.scriptResponse(response, compiled, description, parsed);
2227
+ if (this._deferredOutput && response.tag !== '*' && response.tag !== '+') {
2228
+ releaseDeferred(this, response.tag, true);
2229
+ }
2230
+ }
2231
+ /**
2232
+ * Sends a compiled response through the script rules that watch responses or continuations
2233
+ *
2234
+ * @param {Object} response Response object
2235
+ * @param {String} compiled The compiled response
2236
+ * @param {String} [description] Description of the response
2237
+ * @param {Object} [parsed] The command the response belongs to
2238
+ */
2239
+ scriptResponse(response, compiled, description, parsed) {
2240
+ const event = response.tag === '+' ? 'continuation' : 'response';
2241
+ if (!this.server.script.watches(event)) {
2242
+ this.write(compiled);
2243
+ return;
2244
+ }
2245
+ // script rules see the response after every plugin and the core changed it. Without a command, an
2246
+ // unsolicited response belongs to the command that runs or idles
2247
+ 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));
2248
+ }
2249
+ /**
2250
+ * Compiles a response for the wire
2251
+ *
2252
+ * @param {Object} response Response object
2253
+ * @return {String|null} the response with its CRLF as a binary string, or null for an untagged response that does not compile
2254
+ */
2255
+ compileResponse(response) {
2030
2256
  let compiled;
2031
2257
  try {
2032
2258
  compiled = imapHandler.compiler(response, this.compilerOptions);
@@ -2037,14 +2263,14 @@ class IMAPConnection {
2037
2263
  console.log('Failed to compile response: %s', err.message);
2038
2264
  }
2039
2265
  if (response.tag === '*') {
2040
- return;
2266
+ return null;
2041
2267
  }
2042
2268
  compiled = response.tag + ' NO [SERVERBUG] Failed to compile response';
2043
2269
  }
2044
2270
  if (this.options.debug) {
2045
2271
  console.log('SEND: %s', compiled);
2046
2272
  }
2047
- this.write(compiled + '\r\n');
2273
+ return compiled + '\r\n';
2048
2274
  }
2049
2275
  /**
2050
2276
  * Sends a tagged status response to a command
@@ -2231,9 +2457,23 @@ class IMAPConnection {
2231
2457
  exportMailboxName(path) {
2232
2458
  return path;
2233
2459
  }
2234
- scheduleCommand(data) {
2460
+ /**
2461
+ * Parses a command line and queues the command, or answers it right away when it can not run
2462
+ *
2463
+ * @param {String} data Command line with its literals, without the final CRLF
2464
+ * @param {Boolean} [scripted] The line comes from a script rule with `run`, it is next in the queue
2465
+ */
2466
+ scheduleCommand(data, scripted) {
2235
2467
  let parsed;
2236
2468
  const tag = getResponseTag(data);
2469
+ // the rule is chosen when the line arrives, the state it matches is the state at that moment. The rule
2470
+ // acts when the command's turn comes, so that its output keeps the order of the responses
2471
+ const found = !scripted && this.server.script.watches('command') ? this.server.script.check(this, 'command', { data, tag, command: getLineCommand(data) }) : null;
2472
+ if (found) {
2473
+ this._commandQueue.push({ parsed: { tag, command: found.context.command || '' }, data, script: found });
2474
+ this.processQueue();
2475
+ return;
2476
+ }
2237
2477
  try {
2238
2478
  // server.parserOptions are the defaults of plugins, connection.parserOptions win
2239
2479
  parsed = imapHandler.parser(data, Object.assign({ literalPlus: this.server.literalPlus }, this.server.parserOptions, this.parserOptions));
@@ -2280,10 +2520,14 @@ class IMAPConnection {
2280
2520
  this.sendStatus(parsed, data, 'BAD', 'Commands with message sequence numbers must wait for the completion of earlier commands');
2281
2521
  return;
2282
2522
  }
2283
- this._commandQueue.push({
2284
- parsed: parsed,
2285
- data: data
2286
- });
2523
+ const element = { parsed, data };
2524
+ if (scripted) {
2525
+ // processQueue runs it once the script rule released the queue
2526
+ this._commandQueue.unshift(element);
2527
+ }
2528
+ else {
2529
+ this._commandQueue.push(element);
2530
+ }
2287
2531
  this.processQueue();
2288
2532
  }
2289
2533
  else if (/^AUTHENTICATE /i.test(parsed.command)) {
@@ -2312,6 +2556,33 @@ class IMAPConnection {
2312
2556
  }, 'UNKNOWN COMMAND', parsed, data);
2313
2557
  }
2314
2558
  }
2559
+ /**
2560
+ * Handles a command line with the script rule that matched it, after the rule's delay. With `run` the line
2561
+ * goes through the parser and the command handler as usual afterwards
2562
+ *
2563
+ * @param {Object} element Queued command with the rule
2564
+ * @param {Function} next Releases the queue
2565
+ */
2566
+ runScriptedCommand(element, next) {
2567
+ const { rule, context } = element.script;
2568
+ const act = () => {
2569
+ if (!this.socket || this._closing) {
2570
+ return next();
2571
+ }
2572
+ handleLine(this, rule, context, () => {
2573
+ // the line is not running yet, it must not count as an earlier command (RFC 3501 section 5.5)
2574
+ this._runningCommand = null;
2575
+ this.scheduleCommand(element.data, true);
2576
+ });
2577
+ next();
2578
+ };
2579
+ if (rule.delay) {
2580
+ setTimeout(act, rule.delay);
2581
+ }
2582
+ else {
2583
+ act();
2584
+ }
2585
+ }
2315
2586
  processQueue(force) {
2316
2587
  if (!force && this._processing) {
2317
2588
  return;
@@ -2344,6 +2615,10 @@ class IMAPConnection {
2344
2615
  this.processQueue(true);
2345
2616
  }
2346
2617
  };
2618
+ if (element.script) {
2619
+ this.runScriptedCommand(element, next);
2620
+ return;
2621
+ }
2347
2622
  if (options.states && options.states.indexOf(this.state) < 0) {
2348
2623
  this.sendStatus(element.parsed, element.data, 'BAD', stateError(command, this.state));
2349
2624
  return next();
@@ -2386,7 +2661,12 @@ class IMAPConnection {
2386
2661
  try {
2387
2662
  // changes made while the handler runs are attributed to this session (the `origin` of notifications)
2388
2663
  this.server.activeConnection = this;
2664
+ const inputHandler = this.inputHandler;
2389
2665
  this.server.getCommandHandler(element.parsed.command)(this, element.parsed, element.data, next);
2666
+ if (this.inputHandler && this.inputHandler !== inputHandler) {
2667
+ // the command reads the lines that follow (IDLE, AUTHENTICATE), script rules match them with it
2668
+ this.inputCommand = { tag: element.parsed.tag, command: element.parsed.command };
2669
+ }
2390
2670
  }
2391
2671
  catch (E) {
2392
2672
  const ex = E;
@@ -2507,6 +2787,41 @@ class IMAPConnection {
2507
2787
  }, mailbox, ignoreSelf || ignoreExists ? this : false);
2508
2788
  }
2509
2789
  }
2790
+ /**
2791
+ * Copies a response tree for the `mutate` action of a script rule: arrays and plain objects are copied,
2792
+ * other values (Buffers) are shared
2793
+ *
2794
+ * @param {*} value Response or a part of it
2795
+ * @return {*} Copy
2796
+ */
2797
+ function cloneResponse(value) {
2798
+ if (Array.isArray(value)) {
2799
+ return value.map(cloneResponse);
2800
+ }
2801
+ if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
2802
+ const copy = {};
2803
+ for (const key of Object.keys(value)) {
2804
+ copy[key] = cloneResponse(value[key]);
2805
+ }
2806
+ return copy;
2807
+ }
2808
+ return value;
2809
+ }
2810
+ /**
2811
+ * Finds the command name in a command line that may not parse: tag SP command, and for UID and AUTHENTICATE
2812
+ * the word that follows
2813
+ *
2814
+ * @param {String} line Command line
2815
+ * @return {String} Command name in upper case, can be empty
2816
+ */
2817
+ function getLineCommand(line) {
2818
+ const words = line.match(/^[^ ]* ([^ ]*)(?: ([^ ]*))?/);
2819
+ let command = ((words && words[1]) || '').toUpperCase();
2820
+ if (command === 'UID' || command === 'AUTHENTICATE') {
2821
+ command += ' ' + ((words && words[2]) || '').toUpperCase();
2822
+ }
2823
+ return command;
2824
+ }
2510
2825
  /**
2511
2826
  * Formats a mailbox name for a response: an atom when possible, otherwise a string. NIL and names
2512
2827
  * like \\Foo would not read back as mailbox names, so these are strings as well