imapkit 4.2.0 → 4.3.1

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.
package/README.md CHANGED
@@ -410,21 +410,16 @@ const server = imapkit({
410
410
  ]
411
411
  });
412
412
 
413
- // a rule added later, it returns a handle
414
- const rule = server.script.add({
415
- on: 'response',
416
- command: 'FETCH',
417
- untagged: true,
418
- // strings in the response tree become literals, valid IMAP that a client must handle
419
- mutate: response => {
420
- const walk = list =>
421
- list.forEach((node, i) => (Array.isArray(node) ? walk(node) : typeof node === 'string' && (list[i] = { type: 'LITERAL', value: node })));
422
- walk(response.attributes);
423
- }
424
- });
413
+ // rules added later return handles
414
+ const [literals, late] = server.script.add([
415
+ // every string the grammar allows is sent as a literal, valid IMAP that a client must handle
416
+ { on: 'response', untagged: true, literals: true },
417
+ // the FETCH response of UID 2 arrives after the tagged OK, like some servers do now and then
418
+ { on: 'response', command: 'UID FETCH', untagged: true, match: /UID 2\b/, times: 1, defer: 'tagged' }
419
+ ]);
425
420
  // ... run the client
426
- assert.strictEqual(rule.hits, 1);
427
- rule.remove();
421
+ assert.strictEqual(late.hits, 1);
422
+ literals.remove();
428
423
  ```
429
424
 
430
425
  Every rule watches one event (`on`):
@@ -452,17 +447,19 @@ The matchers of a rule all have to match. Rules are checked in the order they we
452
447
 
453
448
  The actions say what happens instead of the usual behavior. Strings are sent as they are (binary strings, one character per octet, or UTF-8 when they have characters above U+00FF) without an added CRLF, and `$TAG` in a string is replaced with the tag of the command. A Buffer is sent as it is, a function gets the event context and returns a string or a Buffer.
454
449
 
455
- | Action | Events | Effect |
456
- | --------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
457
- | `send` | all | output events: bytes sent instead of the output. command and input: bytes sent instead of processing the line |
458
- | `run` | command, input | process the line as usual after `send` (to add output before the real response) |
459
- | `drop` | all | output events: send nothing. command and input: ignore the line, the client gets no answer |
460
- | `mutate` | response, continuation | `(response, context)` gets a copy of the response object before it is compiled and changes it, or returns another one. Continuation requests have a response object only when a plugin sends them with `connection.send()` (the error challenges of XOAUTH2 and OAUTHBEARER), other events ignore `mutate`. A tagged response that does not compile then is sent as `NO [SERVERBUG]`, an untagged one is dropped. Use `send` for output that is not valid IMAP |
461
- | `before`, `after` | output events | bytes sent before or after the output, e.g. an unsolicited response |
462
- | `delay` | all but input | milliseconds to wait before the output goes out, all later output waits behind it. For a command, the wait before the rule acts or the command runs, later commands wait too |
463
- | `chunk`, `chunkDelay` | all | write the bytes in pieces of `chunk` octets, `chunkDelay` milliseconds apart (default 10) |
464
- | `truncate` | all | send only this many octets of the bytes, then close the connection |
465
- | `close` | all | close the connection after the bytes are sent, `'reset'` destroys the socket instead (a TCP RST where the runtime supports it) |
450
+ | Action | Events | Effect |
451
+ | --------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
452
+ | `send` | all | output events: bytes sent instead of the output. command and input: bytes sent instead of processing the line |
453
+ | `run` | command, input | process the line as usual after `send` (to add output before the real response) |
454
+ | `drop` | all | output events: send nothing. command and input: ignore the line, the client gets no answer |
455
+ | `mutate` | response, continuation | `(response, context)` gets a copy of the response object before it is compiled and changes it, or returns another one. Continuation requests have a response object only when a plugin sends them with `connection.send()` (the error challenges of XOAUTH2 and OAUTHBEARER), other events ignore `mutate`. A tagged response that does not compile then is sent as `NO [SERVERBUG]`, an untagged one is dropped. Use `send` for output that is not valid IMAP |
456
+ | `literals` | response | sends every string of the response that the grammar allows (string, nstring, astring) as a literal. The positions that take only a quoted string stay quoted: the hierarchy delimiter of LIST, LSUB and NAMESPACE, CHILDINFO values, INTERNALDATE and SAVEDATE, and the media types `"TEXT"` and `"MESSAGE" "RFC822"` in a body structure (RFC 9051 section 9, RFC 5258 section 6). Atoms, response codes and text stay as they are. Works together with `mutate`, after it |
457
+ | `defer` | response | holds an untagged response back: `'tagged'` sends it right after the tagged response of its command, `'next'` with the answer to the next command, before its first response. Needs `untagged: true`. `send`, `before` and `after` change the held output, `drop`, `delay`, `chunk`, `truncate` and `close` can not be combined with it. A response that does not belong to a command (one that arrives in IDLE belongs to IDLE) is held for the next tagged response or command. Held responses are dropped when the connection closes |
458
+ | `before`, `after` | output events | bytes sent before or after the output, e.g. an unsolicited response |
459
+ | `delay` | all but input | milliseconds to wait before the output goes out, all later output waits behind it. For a command, the wait before the rule acts or the command runs, later commands wait too |
460
+ | `chunk`, `chunkDelay` | all | write the bytes in pieces of `chunk` octets, `chunkDelay` milliseconds apart (default 10) |
461
+ | `truncate` | all | send only this many octets of the bytes, then close the connection |
462
+ | `close` | all | close the connection after the bytes are sent, `'reset'` destroys the socket instead (a TCP RST where the runtime supports it), 20 ms after the bytes so that the RST does not overtake them. Input that arrives meanwhile is not processed |
466
463
 
467
464
  A rule needs at least one action, and for command and input events `chunk` and `truncate` need `send`. A rule with only `delay` (and `run`) delays the line and then processes it as usual. Rules are checked when they are added: an unknown option, an option that does not apply to the event, or an invalid value throws a `TypeError`, so a typo can not turn into a rule that never fires.
468
465
 
@@ -56,6 +56,13 @@ export interface ScriptRule {
56
56
  nth?: number | undefined;
57
57
  /** the rule fires this many times at most (default unlimited) */
58
58
  times?: number | undefined;
59
+ /** sends every string of the response that the grammar allows as a literal (response events) */
60
+ literals?: boolean | undefined;
61
+ /**
62
+ * holds an untagged response back: `"tagged"` sends it after the tagged response of its command, `"next"` with the
63
+ * first response that belongs to another command (response events)
64
+ */
65
+ defer?: 'tagged' | 'next' | undefined;
59
66
  /** changes or replaces the response object before it is compiled (response events) */
60
67
  mutate?: ((response: IMAPResponse, context: ScriptContext) => IMAPResponse | void) | undefined;
61
68
  /** bytes to send instead of the output, or instead of answering the command or input line. `$TAG` in a string is the tag */
@@ -170,6 +177,32 @@ export declare class ServerScript {
170
177
  * @param {Buffer} output The output
171
178
  */
172
179
  export declare function sendOutput(connection: IMAPConnection, rule: ScriptRule, context: ScriptContext, output: Buffer): void;
180
+ /** Output held back by `defer`, see releaseDeferred */
181
+ export interface DeferredOutput {
182
+ mode: 'tagged' | 'next';
183
+ /** tag of the command the response belongs to, null for an unsolicited response */
184
+ tag: string | null;
185
+ data: Buffer;
186
+ }
187
+ /**
188
+ * Sends the held back responses whose turn has come (`defer`). Before a response of another command go the ones held
189
+ * with `"next"`, after the tagged response of their command the ones held with `"tagged"`. A response held without a
190
+ * command goes with the next command, or after the next tagged response
191
+ *
192
+ * @param {Object} connection IMAP connection
193
+ * @param {String|null} tag Tag of the command the response that is sent belongs to
194
+ * @param {Boolean} tagged false before the response is sent, true after a tagged response was sent
195
+ */
196
+ export declare function releaseDeferred(connection: IMAPConnection, tag: string | null, tagged: boolean): void;
197
+ /**
198
+ * Turns the strings of a response into literals wherever the grammar allows a string (RFC 9051 section 9: string,
199
+ * nstring and astring). Positions that take only a quoted string stay quoted: the hierarchy delimiter of LIST, LSUB
200
+ * and NAMESPACE, CHILDINFO values (RFC 5258 section 6), date-time values and the quoted media types of a body structure
201
+ *
202
+ * @param {Object} response Response object
203
+ * @return {Object} a copy of the response with literals, the response is not changed
204
+ */
205
+ export declare function literalResponse(response: IMAPResponse): IMAPResponse;
173
206
  /**
174
207
  * Handles a command or input line with the rule that matched it: sends the bytes of `send`, closes the connection,
175
208
  * and processes the line as usual with `run` (or when the rule only delays it)
@@ -2,6 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ServerScript = void 0;
4
4
  exports.sendOutput = sendOutput;
5
+ exports.releaseDeferred = releaseDeferred;
6
+ exports.literalResponse = literalResponse;
5
7
  exports.handleLine = handleLine;
6
8
  const EVENTS = ['greeting', 'command', 'input', 'response', 'continuation'];
7
9
  const OUTPUT_EVENTS = ['greeting', 'response', 'continuation'];
@@ -21,6 +23,8 @@ const KEYS = {
21
23
  nth: EVENTS,
22
24
  times: EVENTS,
23
25
  mutate: ['response', 'continuation'],
26
+ literals: ['response'],
27
+ defer: ['response'],
24
28
  send: EVENTS,
25
29
  before: OUTPUT_EVENTS,
26
30
  after: OUTPUT_EVENTS,
@@ -32,7 +36,7 @@ const KEYS = {
32
36
  truncate: EVENTS,
33
37
  close: EVENTS
34
38
  };
35
- const ACTIONS = ['mutate', 'send', 'before', 'after', 'run', 'drop', 'delay', 'chunk', 'truncate', 'close'];
39
+ const ACTIONS = ['mutate', 'literals', 'defer', 'send', 'before', 'after', 'run', 'drop', 'delay', 'chunk', 'truncate', 'close'];
36
40
  const DEFAULT_CHUNK_DELAY = 10;
37
41
  // matchers that list accepted values, kept as Sets so that matching an event allocates nothing
38
42
  const SET_MATCHERS = ['command', 'description', 'session', 'state', 'user', 'mailbox'];
@@ -110,6 +114,22 @@ function validateRule(rule) {
110
114
  throw new TypeError('Script rule option "' + key + '" must be a string, a Buffer or a function');
111
115
  }
112
116
  }
117
+ if (rule.literals !== undefined && typeof rule.literals !== 'boolean') {
118
+ throw new TypeError('Script rule option "literals" must be true or false');
119
+ }
120
+ if (rule.defer !== undefined) {
121
+ if (rule.defer !== 'tagged' && rule.defer !== 'next') {
122
+ throw new TypeError('Script rule option "defer" must be "tagged" or "next"');
123
+ }
124
+ if (rule.untagged !== true) {
125
+ // a tagged response ends its command, there is nothing to hold it back for
126
+ throw new TypeError('Script rule option "defer" needs "untagged": true');
127
+ }
128
+ const conflict = ['drop', 'delay', 'chunk', 'truncate', 'close'].find(key => rule[key] !== undefined && rule[key] !== false);
129
+ if (conflict) {
130
+ throw new TypeError('Script rule option "defer" can not be combined with "' + conflict + '"');
131
+ }
132
+ }
113
133
  if (rule.close !== undefined && typeof rule.close !== 'boolean' && rule.close !== 'reset') {
114
134
  throw new TypeError('Script rule option "close" must be true, false or "reset"');
115
135
  }
@@ -292,8 +312,137 @@ function scriptContext(connection, event, fields) {
292
312
  function sendOutput(connection, rule, context, output) {
293
313
  const replaced = rule.drop ? Buffer.alloc(0) : resolveBytes(rule.send, context) || output;
294
314
  const parts = [resolveBytes(rule.before, context), replaced, resolveBytes(rule.after, context)].filter((part) => !!part);
315
+ if (rule.defer) {
316
+ (connection._deferredOutput ||= []).push({ mode: rule.defer, tag: context.tag, data: Buffer.concat(parts) });
317
+ return;
318
+ }
295
319
  sendBytes(connection, rule, Buffer.concat(parts), rule.delay);
296
320
  }
321
+ /**
322
+ * Sends the held back responses whose turn has come (`defer`). Before a response of another command go the ones held
323
+ * with `"next"`, after the tagged response of their command the ones held with `"tagged"`. A response held without a
324
+ * command goes with the next command, or after the next tagged response
325
+ *
326
+ * @param {Object} connection IMAP connection
327
+ * @param {String|null} tag Tag of the command the response that is sent belongs to
328
+ * @param {Boolean} tagged false before the response is sent, true after a tagged response was sent
329
+ */
330
+ function releaseDeferred(connection, tag, tagged) {
331
+ const ready = [];
332
+ const waiting = [];
333
+ for (const entry of connection._deferredOutput) {
334
+ const due = tagged ? entry.mode === 'tagged' && (entry.tag === null || entry.tag === tag) : entry.mode === 'next' && tag !== null && entry.tag !== tag;
335
+ (due ? ready : waiting).push(entry);
336
+ }
337
+ if (ready.length) {
338
+ connection._deferredOutput = waiting.length ? waiting : null;
339
+ ready.forEach(entry => connection.write(entry.data));
340
+ }
341
+ }
342
+ /**
343
+ * Turns strings into literals: plain strings and STRING nodes, also in lists. Atoms, numbers, NIL, text and
344
+ * response codes (SECTION) stay as they are, a response code takes no literals (RFC 9051 section 9 resp-text-code)
345
+ *
346
+ * @param {*} node Response attribute
347
+ * @return {*} the attribute with literals, a new object where something changed
348
+ */
349
+ function literalValue(node) {
350
+ if (typeof node === 'string') {
351
+ return { type: 'LITERAL', value: node };
352
+ }
353
+ if (Array.isArray(node)) {
354
+ return node.map(literalValue);
355
+ }
356
+ if (node && typeof node === 'object' && node.type === 'STRING') {
357
+ return { type: 'LITERAL', value: node.value };
358
+ }
359
+ return node;
360
+ }
361
+ const nodeText = (node) => String(typeof node === 'string' ? node : node && typeof node === 'object' ? node.value : '').toUpperCase();
362
+ /**
363
+ * Turns the strings of a body structure into literals, except the media types that the grammar writes as quoted
364
+ * strings: `"TEXT"` (media-text) and `"MESSAGE" "RFC822"` or `"GLOBAL"` (media-message, RFC 9051 section 9). A literal
365
+ * "TEXT" would read as a basic part, and the lines count after it would break the grammar
366
+ *
367
+ * @param {*} body Body structure, a list
368
+ * @return {*} the body structure with literals
369
+ */
370
+ function literalBody(body) {
371
+ if (!Array.isArray(body)) {
372
+ return literalValue(body);
373
+ }
374
+ if (Array.isArray(body[0])) {
375
+ // body-type-mpart: the parts, then the subtype and the extension data
376
+ const subtype = body.findIndex(item => !Array.isArray(item));
377
+ return body.map((item, i) => (subtype < 0 || i < subtype ? literalBody(item) : literalValue(item)));
378
+ }
379
+ const type = nodeText(body[0]);
380
+ const message = type === 'MESSAGE' && ['RFC822', 'GLOBAL'].includes(nodeText(body[1]));
381
+ return body.map((item, i) => {
382
+ if ((i === 0 && (type === 'TEXT' || message)) || (i === 1 && message)) {
383
+ return item;
384
+ }
385
+ // body-type-msg: the envelope, then the body of the encapsulated message
386
+ return i === 8 && message ? literalBody(item) : literalValue(item);
387
+ });
388
+ }
389
+ // FETCH items with a date-time value, which is a quoted string (RFC 9051 section 9, SAVEDATE: RFC 8514 section 4)
390
+ const DATE_TIME_ITEMS = new Set(['INTERNALDATE', 'SAVEDATE']);
391
+ /**
392
+ * Turns the strings of a response into literals wherever the grammar allows a string (RFC 9051 section 9: string,
393
+ * nstring and astring). Positions that take only a quoted string stay quoted: the hierarchy delimiter of LIST, LSUB
394
+ * and NAMESPACE, CHILDINFO values (RFC 5258 section 6), date-time values and the quoted media types of a body structure
395
+ *
396
+ * @param {Object} response Response object
397
+ * @return {Object} a copy of the response with literals, the response is not changed
398
+ */
399
+ function literalResponse(response) {
400
+ const attributes = Array.isArray(response.attributes) ? response.attributes : null;
401
+ if (!attributes) {
402
+ return response;
403
+ }
404
+ const command = nodeText(response.command);
405
+ const name = nodeText(attributes[1]);
406
+ let converted;
407
+ if (name === 'FETCH' || name === 'UIDFETCH') {
408
+ // message-data: number SP "FETCH" SP msg-att, the items are name and value pairs
409
+ const items = Array.isArray(attributes[2]) ? attributes[2] : [];
410
+ const fetched = items.map((item, i) => {
411
+ if (i % 2 === 0) {
412
+ return item;
413
+ }
414
+ const key = items[i - 1];
415
+ const itemName = key && !key.section ? nodeText(key) : '';
416
+ if (DATE_TIME_ITEMS.has(itemName)) {
417
+ return item;
418
+ }
419
+ return itemName === 'BODY' || itemName === 'BODYSTRUCTURE' ? literalBody(item) : literalValue(item);
420
+ });
421
+ converted = attributes.map((item, i) => (i === 2 ? fetched : item));
422
+ }
423
+ else if (command === 'LIST' || command === 'LSUB') {
424
+ // mailbox-list: flags, the delimiter (quoted only), the name and the extended data items
425
+ converted = attributes.map((item, i) => {
426
+ if (i === 1) {
427
+ return item;
428
+ }
429
+ if (i === 3 && Array.isArray(item)) {
430
+ return item.map((value, j) => (j % 2 === 1 && nodeText(item[j - 1]) === 'CHILDINFO' ? value : literalValue(value)));
431
+ }
432
+ return literalValue(item);
433
+ });
434
+ }
435
+ else if (command === 'NAMESPACE') {
436
+ // namespace-descr: "(" string SP (DQUOTE QUOTED-CHAR DQUOTE / nil) [extensions] ")"
437
+ converted = attributes.map(namespace => Array.isArray(namespace)
438
+ ? namespace.map(descr => (Array.isArray(descr) ? descr.map((value, i) => (i === 1 ? value : literalValue(value))) : descr))
439
+ : namespace);
440
+ }
441
+ else {
442
+ converted = attributes.map(literalValue);
443
+ }
444
+ return Object.assign({}, response, { attributes: converted });
445
+ }
297
446
  /**
298
447
  * Sends the bytes of a rule with its timing, `truncate` closes the connection after the cut
299
448
  *
@@ -4,7 +4,7 @@ import tls from 'node:tls';
4
4
  import type { CompilerOptions, ParserOptions } from 'imap-handler';
5
5
  import type { ResolvedCommandOptions } from './command-states.js';
6
6
  import { ServerScript } from './script.js';
7
- import type { OutputOperation, ScriptContext, ScriptEvent, ScriptRule } from './script.js';
7
+ import type { DeferredOutput, OutputOperation, ScriptContext, ScriptEvent, ScriptRule } from './script.js';
8
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';
9
9
  declare const TAG_REGEX: RegExp;
10
10
  /**
@@ -482,6 +482,8 @@ declare class IMAPConnection {
482
482
  /** notifications are sent right away instead of before the next tagged response (IDLE) */
483
483
  directNotifications: boolean;
484
484
  notificationQueue: Notification[];
485
+ /** the plain socket that STARTTLS wrapped in TLS */
486
+ tcpSocket: net.Socket | null | undefined;
485
487
  /** the number of the connection, 1 for the first one the server accepted (script rules match it) */
486
488
  sessionNumber: number;
487
489
  /** the tag and name of the command whose input handler reads the lines that follow (IDLE, AUTHENTICATE) */
@@ -509,6 +511,10 @@ declare class IMAPConnection {
509
511
  /** output that waits behind a delay of a script rule, null when output is written right away */
510
512
  _outputQueue: OutputOperation[] | null;
511
513
  _outputTimer: ReturnType<typeof setTimeout> | null;
514
+ /** untagged responses that `defer` rules hold back, see releaseDeferred in src/script.ts */
515
+ _deferredOutput: DeferredOutput[] | null;
516
+ /** a script rule closes the connection with a RST, see closeNow */
517
+ _resetting: boolean | undefined;
512
518
  constructor(server: IMAPServer, socket: net.Socket);
513
519
  /**
514
520
  * Writes protocol output to the client, through the transport layer if there is one
@@ -528,7 +534,7 @@ declare class IMAPConnection {
528
534
  */
529
535
  flushOutput(): void;
530
536
  /**
531
- * Drops the output that waits for a delay of a script rule
537
+ * Drops the output that waits for a delay of a script rule or for its release (`defer`)
532
538
  */
533
539
  clearOutputQueue(): void;
534
540
  /**
@@ -824,6 +830,15 @@ declare class IMAPConnection {
824
830
  * FETCH result. (This may have other names when used, like "affected".)
825
831
  */
826
832
  send(response: IMAPResponse, description?: string, parsed?: CommandContext | null, data?: string | null, ...extra: any[]): void;
833
+ /**
834
+ * Sends a compiled response through the script rules that watch responses or continuations
835
+ *
836
+ * @param {Object} response Response object
837
+ * @param {String} compiled The compiled response
838
+ * @param {String} [description] Description of the response
839
+ * @param {Object} [parsed] The command the response belongs to
840
+ */
841
+ scriptResponse(response: IMAPResponse, compiled: string, description?: string, parsed?: CommandContext | null): void;
827
842
  /**
828
843
  * Compiles a response for the wire
829
844
  *
@@ -57,6 +57,8 @@ const bundledCert = __importStar(require("./cert.js"));
57
57
  const script_js_1 = require("./script.js");
58
58
  // longest command line (not counting literals) accepted from a client
59
59
  const MAX_LINE_LENGTH = 1024 * 1024;
60
+ // milliseconds between the last output and the RST of a script rule with `close: 'reset'`
61
+ const RESET_DELAY = 20;
60
62
  // largest literal accepted after login, override with the maxLiteralSize option
61
63
  const MAX_LITERAL_SIZE = 64 * 1024 * 1024;
62
64
  // largest literal accepted before login, enough for any user name or password
@@ -1261,6 +1263,7 @@ class IMAPConnection {
1261
1263
  this.sessionNumber = ++this.server.sessionCounter;
1262
1264
  this._outputQueue = null;
1263
1265
  this._outputTimer = null;
1266
+ this._deferredOutput = null;
1264
1267
  this.secureConnection = !!this.options.secureConnection;
1265
1268
  this._remainder = '';
1266
1269
  this._command = '';
@@ -1363,7 +1366,7 @@ class IMAPConnection {
1363
1366
  this._outputQueue = null;
1364
1367
  }
1365
1368
  /**
1366
- * Drops the output that waits for a delay of a script rule
1369
+ * Drops the output that waits for a delay of a script rule or for its release (`defer`)
1367
1370
  */
1368
1371
  clearOutputQueue() {
1369
1372
  if (this._outputTimer) {
@@ -1371,6 +1374,7 @@ class IMAPConnection {
1371
1374
  this._outputTimer = null;
1372
1375
  }
1373
1376
  this._outputQueue = null;
1377
+ this._deferredOutput = null;
1374
1378
  }
1375
1379
  /**
1376
1380
  * Writes output through a transport layer, or to the socket
@@ -1401,10 +1405,14 @@ class IMAPConnection {
1401
1405
  return;
1402
1406
  }
1403
1407
  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 ((rule.mutate || rule.literals) && compile && context.response) {
1409
+ let response = context.response;
1410
+ if (rule.mutate) {
1411
+ // a copy, a notification object is shared by every session
1412
+ const copy = cloneResponse(response);
1413
+ response = rule.mutate(copy, context) || copy;
1414
+ }
1415
+ const changed = compile(rule.literals ? (0, script_js_1.literalResponse)(response) : response);
1408
1416
  if (changed === null) {
1409
1417
  return;
1410
1418
  }
@@ -1478,13 +1486,17 @@ class IMAPConnection {
1478
1486
  this.clearOutputQueue();
1479
1487
  if (mode === 'reset') {
1480
1488
  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
- }
1489
+ // input that arrives while the reset waits is not processed, also input a transport layer still holds
1490
+ this._resetting = true;
1491
+ socket.pause();
1492
+ // a RST sent in the same tick as the last output can get lost: on macOS the client received the output but
1493
+ // never the RST (#81). Node has no signal for output that reached the peer, so the RST waits a little
1494
+ const tcpSocket = this.tcpSocket;
1495
+ setTimeout(() => {
1496
+ if (!socket.destroyed) {
1497
+ resetSocket(socket, tcpSocket);
1498
+ }
1499
+ }, RESET_DELAY);
1488
1500
  }
1489
1501
  else if (this.transport) {
1490
1502
  this.transport.end(() => socket.end());
@@ -1621,6 +1633,9 @@ class IMAPConnection {
1621
1633
  onData(chunk) {
1622
1634
  let match;
1623
1635
  let str;
1636
+ if (this._resetting) {
1637
+ return;
1638
+ }
1624
1639
  // everything in one read arrived before anything this read causes to be sent
1625
1640
  this._readCount = (this._readCount || 0) + 1;
1626
1641
  str = (chunk || '').toString('binary');
@@ -2100,6 +2115,9 @@ class IMAPConnection {
2100
2115
  };
2101
2116
  // STARTTLS runs on a live connection
2102
2117
  const socket = this.socket;
2118
+ // the RST of a script rule goes to the TCP socket under the TLS layer, see resetSocket. Node also has it as the
2119
+ // _parent of the TLS socket, Bun does not
2120
+ this.tcpSocket = socket;
2103
2121
  // remove all listeners from the original socket besides the error handler
2104
2122
  socket.removeAllListeners();
2105
2123
  socket.on('error', this.onError.bind(this));
@@ -2240,6 +2258,24 @@ class IMAPConnection {
2240
2258
  if (compiled === null) {
2241
2259
  return;
2242
2260
  }
2261
+ if (this._deferredOutput) {
2262
+ // responses that `defer` rules hold back go before the first response of another command
2263
+ (0, script_js_1.releaseDeferred)(this, parsed && parsed.command && parsed.tag && parsed.tag !== '*' ? parsed.tag : null, false);
2264
+ }
2265
+ this.scriptResponse(response, compiled, description, parsed);
2266
+ if (this._deferredOutput && response.tag !== '*' && response.tag !== '+') {
2267
+ (0, script_js_1.releaseDeferred)(this, response.tag, true);
2268
+ }
2269
+ }
2270
+ /**
2271
+ * Sends a compiled response through the script rules that watch responses or continuations
2272
+ *
2273
+ * @param {Object} response Response object
2274
+ * @param {String} compiled The compiled response
2275
+ * @param {String} [description] Description of the response
2276
+ * @param {Object} [parsed] The command the response belongs to
2277
+ */
2278
+ scriptResponse(response, compiled, description, parsed) {
2243
2279
  const event = response.tag === '+' ? 'continuation' : 'response';
2244
2280
  if (!this.server.script.watches(event)) {
2245
2281
  this.write(compiled);
@@ -2791,6 +2827,32 @@ class IMAPConnection {
2791
2827
  }
2792
2828
  }
2793
2829
  exports.IMAPConnection = IMAPConnection;
2830
+ /**
2831
+ * Destroys a socket with a TCP RST (`close: 'reset'` of a script rule). resetAndDestroy (Node 16.17) needs a socket with
2832
+ * a TCP handle, a TLS socket throws ERR_INVALID_HANDLE_TYPE (#84), so the RST goes to the TCP socket under the TLS
2833
+ * layer: the plain socket of STARTTLS, or the parent socket of implicit TLS (Node). A runtime without resetAndDestroy,
2834
+ * or a socket that can not be reset, is destroyed without a RST
2835
+ *
2836
+ * @param {Object} socket The socket of the connection
2837
+ * @param {Object} [tcpSocket] The plain socket that STARTTLS wrapped
2838
+ */
2839
+ function resetSocket(socket, tcpSocket) {
2840
+ const parent = socket._parent;
2841
+ const candidates = [tcpSocket, parent instanceof node_net_1.default.Socket ? parent : null, socket];
2842
+ for (const candidate of candidates) {
2843
+ if (candidate && !candidate.destroyed && typeof candidate.resetAndDestroy === 'function') {
2844
+ try {
2845
+ candidate.resetAndDestroy();
2846
+ break;
2847
+ }
2848
+ catch {
2849
+ // not a TCP handle, try the next one
2850
+ }
2851
+ }
2852
+ }
2853
+ // the TLS socket closes with the TCP socket under it, and emits close for the connection
2854
+ socket.destroy();
2855
+ }
2794
2856
  /**
2795
2857
  * Copies a response tree for the `mutate` action of a script rule: arrays and plain objects are copied,
2796
2858
  * other values (Buffers) are shared
@@ -56,6 +56,13 @@ export interface ScriptRule {
56
56
  nth?: number | undefined;
57
57
  /** the rule fires this many times at most (default unlimited) */
58
58
  times?: number | undefined;
59
+ /** sends every string of the response that the grammar allows as a literal (response events) */
60
+ literals?: boolean | undefined;
61
+ /**
62
+ * holds an untagged response back: `"tagged"` sends it after the tagged response of its command, `"next"` with the
63
+ * first response that belongs to another command (response events)
64
+ */
65
+ defer?: 'tagged' | 'next' | undefined;
59
66
  /** changes or replaces the response object before it is compiled (response events) */
60
67
  mutate?: ((response: IMAPResponse, context: ScriptContext) => IMAPResponse | void) | undefined;
61
68
  /** bytes to send instead of the output, or instead of answering the command or input line. `$TAG` in a string is the tag */
@@ -170,6 +177,32 @@ export declare class ServerScript {
170
177
  * @param {Buffer} output The output
171
178
  */
172
179
  export declare function sendOutput(connection: IMAPConnection, rule: ScriptRule, context: ScriptContext, output: Buffer): void;
180
+ /** Output held back by `defer`, see releaseDeferred */
181
+ export interface DeferredOutput {
182
+ mode: 'tagged' | 'next';
183
+ /** tag of the command the response belongs to, null for an unsolicited response */
184
+ tag: string | null;
185
+ data: Buffer;
186
+ }
187
+ /**
188
+ * Sends the held back responses whose turn has come (`defer`). Before a response of another command go the ones held
189
+ * with `"next"`, after the tagged response of their command the ones held with `"tagged"`. A response held without a
190
+ * command goes with the next command, or after the next tagged response
191
+ *
192
+ * @param {Object} connection IMAP connection
193
+ * @param {String|null} tag Tag of the command the response that is sent belongs to
194
+ * @param {Boolean} tagged false before the response is sent, true after a tagged response was sent
195
+ */
196
+ export declare function releaseDeferred(connection: IMAPConnection, tag: string | null, tagged: boolean): void;
197
+ /**
198
+ * Turns the strings of a response into literals wherever the grammar allows a string (RFC 9051 section 9: string,
199
+ * nstring and astring). Positions that take only a quoted string stay quoted: the hierarchy delimiter of LIST, LSUB
200
+ * and NAMESPACE, CHILDINFO values (RFC 5258 section 6), date-time values and the quoted media types of a body structure
201
+ *
202
+ * @param {Object} response Response object
203
+ * @return {Object} a copy of the response with literals, the response is not changed
204
+ */
205
+ export declare function literalResponse(response: IMAPResponse): IMAPResponse;
173
206
  /**
174
207
  * Handles a command or input line with the rule that matched it: sends the bytes of `send`, closes the connection,
175
208
  * and processes the line as usual with `run` (or when the rule only delays it)
@@ -16,6 +16,8 @@ const KEYS = {
16
16
  nth: EVENTS,
17
17
  times: EVENTS,
18
18
  mutate: ['response', 'continuation'],
19
+ literals: ['response'],
20
+ defer: ['response'],
19
21
  send: EVENTS,
20
22
  before: OUTPUT_EVENTS,
21
23
  after: OUTPUT_EVENTS,
@@ -27,7 +29,7 @@ const KEYS = {
27
29
  truncate: EVENTS,
28
30
  close: EVENTS
29
31
  };
30
- const ACTIONS = ['mutate', 'send', 'before', 'after', 'run', 'drop', 'delay', 'chunk', 'truncate', 'close'];
32
+ const ACTIONS = ['mutate', 'literals', 'defer', 'send', 'before', 'after', 'run', 'drop', 'delay', 'chunk', 'truncate', 'close'];
31
33
  const DEFAULT_CHUNK_DELAY = 10;
32
34
  // matchers that list accepted values, kept as Sets so that matching an event allocates nothing
33
35
  const SET_MATCHERS = ['command', 'description', 'session', 'state', 'user', 'mailbox'];
@@ -105,6 +107,22 @@ function validateRule(rule) {
105
107
  throw new TypeError('Script rule option "' + key + '" must be a string, a Buffer or a function');
106
108
  }
107
109
  }
110
+ if (rule.literals !== undefined && typeof rule.literals !== 'boolean') {
111
+ throw new TypeError('Script rule option "literals" must be true or false');
112
+ }
113
+ if (rule.defer !== undefined) {
114
+ if (rule.defer !== 'tagged' && rule.defer !== 'next') {
115
+ throw new TypeError('Script rule option "defer" must be "tagged" or "next"');
116
+ }
117
+ if (rule.untagged !== true) {
118
+ // a tagged response ends its command, there is nothing to hold it back for
119
+ throw new TypeError('Script rule option "defer" needs "untagged": true');
120
+ }
121
+ const conflict = ['drop', 'delay', 'chunk', 'truncate', 'close'].find(key => rule[key] !== undefined && rule[key] !== false);
122
+ if (conflict) {
123
+ throw new TypeError('Script rule option "defer" can not be combined with "' + conflict + '"');
124
+ }
125
+ }
108
126
  if (rule.close !== undefined && typeof rule.close !== 'boolean' && rule.close !== 'reset') {
109
127
  throw new TypeError('Script rule option "close" must be true, false or "reset"');
110
128
  }
@@ -286,8 +304,137 @@ function scriptContext(connection, event, fields) {
286
304
  export function sendOutput(connection, rule, context, output) {
287
305
  const replaced = rule.drop ? Buffer.alloc(0) : resolveBytes(rule.send, context) || output;
288
306
  const parts = [resolveBytes(rule.before, context), replaced, resolveBytes(rule.after, context)].filter((part) => !!part);
307
+ if (rule.defer) {
308
+ (connection._deferredOutput ||= []).push({ mode: rule.defer, tag: context.tag, data: Buffer.concat(parts) });
309
+ return;
310
+ }
289
311
  sendBytes(connection, rule, Buffer.concat(parts), rule.delay);
290
312
  }
313
+ /**
314
+ * Sends the held back responses whose turn has come (`defer`). Before a response of another command go the ones held
315
+ * with `"next"`, after the tagged response of their command the ones held with `"tagged"`. A response held without a
316
+ * command goes with the next command, or after the next tagged response
317
+ *
318
+ * @param {Object} connection IMAP connection
319
+ * @param {String|null} tag Tag of the command the response that is sent belongs to
320
+ * @param {Boolean} tagged false before the response is sent, true after a tagged response was sent
321
+ */
322
+ export function releaseDeferred(connection, tag, tagged) {
323
+ const ready = [];
324
+ const waiting = [];
325
+ for (const entry of connection._deferredOutput) {
326
+ const due = tagged ? entry.mode === 'tagged' && (entry.tag === null || entry.tag === tag) : entry.mode === 'next' && tag !== null && entry.tag !== tag;
327
+ (due ? ready : waiting).push(entry);
328
+ }
329
+ if (ready.length) {
330
+ connection._deferredOutput = waiting.length ? waiting : null;
331
+ ready.forEach(entry => connection.write(entry.data));
332
+ }
333
+ }
334
+ /**
335
+ * Turns strings into literals: plain strings and STRING nodes, also in lists. Atoms, numbers, NIL, text and
336
+ * response codes (SECTION) stay as they are, a response code takes no literals (RFC 9051 section 9 resp-text-code)
337
+ *
338
+ * @param {*} node Response attribute
339
+ * @return {*} the attribute with literals, a new object where something changed
340
+ */
341
+ function literalValue(node) {
342
+ if (typeof node === 'string') {
343
+ return { type: 'LITERAL', value: node };
344
+ }
345
+ if (Array.isArray(node)) {
346
+ return node.map(literalValue);
347
+ }
348
+ if (node && typeof node === 'object' && node.type === 'STRING') {
349
+ return { type: 'LITERAL', value: node.value };
350
+ }
351
+ return node;
352
+ }
353
+ const nodeText = (node) => String(typeof node === 'string' ? node : node && typeof node === 'object' ? node.value : '').toUpperCase();
354
+ /**
355
+ * Turns the strings of a body structure into literals, except the media types that the grammar writes as quoted
356
+ * strings: `"TEXT"` (media-text) and `"MESSAGE" "RFC822"` or `"GLOBAL"` (media-message, RFC 9051 section 9). A literal
357
+ * "TEXT" would read as a basic part, and the lines count after it would break the grammar
358
+ *
359
+ * @param {*} body Body structure, a list
360
+ * @return {*} the body structure with literals
361
+ */
362
+ function literalBody(body) {
363
+ if (!Array.isArray(body)) {
364
+ return literalValue(body);
365
+ }
366
+ if (Array.isArray(body[0])) {
367
+ // body-type-mpart: the parts, then the subtype and the extension data
368
+ const subtype = body.findIndex(item => !Array.isArray(item));
369
+ return body.map((item, i) => (subtype < 0 || i < subtype ? literalBody(item) : literalValue(item)));
370
+ }
371
+ const type = nodeText(body[0]);
372
+ const message = type === 'MESSAGE' && ['RFC822', 'GLOBAL'].includes(nodeText(body[1]));
373
+ return body.map((item, i) => {
374
+ if ((i === 0 && (type === 'TEXT' || message)) || (i === 1 && message)) {
375
+ return item;
376
+ }
377
+ // body-type-msg: the envelope, then the body of the encapsulated message
378
+ return i === 8 && message ? literalBody(item) : literalValue(item);
379
+ });
380
+ }
381
+ // FETCH items with a date-time value, which is a quoted string (RFC 9051 section 9, SAVEDATE: RFC 8514 section 4)
382
+ const DATE_TIME_ITEMS = new Set(['INTERNALDATE', 'SAVEDATE']);
383
+ /**
384
+ * Turns the strings of a response into literals wherever the grammar allows a string (RFC 9051 section 9: string,
385
+ * nstring and astring). Positions that take only a quoted string stay quoted: the hierarchy delimiter of LIST, LSUB
386
+ * and NAMESPACE, CHILDINFO values (RFC 5258 section 6), date-time values and the quoted media types of a body structure
387
+ *
388
+ * @param {Object} response Response object
389
+ * @return {Object} a copy of the response with literals, the response is not changed
390
+ */
391
+ export function literalResponse(response) {
392
+ const attributes = Array.isArray(response.attributes) ? response.attributes : null;
393
+ if (!attributes) {
394
+ return response;
395
+ }
396
+ const command = nodeText(response.command);
397
+ const name = nodeText(attributes[1]);
398
+ let converted;
399
+ if (name === 'FETCH' || name === 'UIDFETCH') {
400
+ // message-data: number SP "FETCH" SP msg-att, the items are name and value pairs
401
+ const items = Array.isArray(attributes[2]) ? attributes[2] : [];
402
+ const fetched = items.map((item, i) => {
403
+ if (i % 2 === 0) {
404
+ return item;
405
+ }
406
+ const key = items[i - 1];
407
+ const itemName = key && !key.section ? nodeText(key) : '';
408
+ if (DATE_TIME_ITEMS.has(itemName)) {
409
+ return item;
410
+ }
411
+ return itemName === 'BODY' || itemName === 'BODYSTRUCTURE' ? literalBody(item) : literalValue(item);
412
+ });
413
+ converted = attributes.map((item, i) => (i === 2 ? fetched : item));
414
+ }
415
+ else if (command === 'LIST' || command === 'LSUB') {
416
+ // mailbox-list: flags, the delimiter (quoted only), the name and the extended data items
417
+ converted = attributes.map((item, i) => {
418
+ if (i === 1) {
419
+ return item;
420
+ }
421
+ if (i === 3 && Array.isArray(item)) {
422
+ return item.map((value, j) => (j % 2 === 1 && nodeText(item[j - 1]) === 'CHILDINFO' ? value : literalValue(value)));
423
+ }
424
+ return literalValue(item);
425
+ });
426
+ }
427
+ else if (command === 'NAMESPACE') {
428
+ // namespace-descr: "(" string SP (DQUOTE QUOTED-CHAR DQUOTE / nil) [extensions] ")"
429
+ converted = attributes.map(namespace => Array.isArray(namespace)
430
+ ? namespace.map(descr => (Array.isArray(descr) ? descr.map((value, i) => (i === 1 ? value : literalValue(value))) : descr))
431
+ : namespace);
432
+ }
433
+ else {
434
+ converted = attributes.map(literalValue);
435
+ }
436
+ return Object.assign({}, response, { attributes: converted });
437
+ }
291
438
  /**
292
439
  * Sends the bytes of a rule with its timing, `truncate` closes the connection after the cut
293
440
  *
@@ -4,7 +4,7 @@ import tls from 'node:tls';
4
4
  import type { CompilerOptions, ParserOptions } from 'imap-handler';
5
5
  import type { ResolvedCommandOptions } from './command-states.js';
6
6
  import { ServerScript } from './script.js';
7
- import type { OutputOperation, ScriptContext, ScriptEvent, ScriptRule } from './script.js';
7
+ import type { DeferredOutput, OutputOperation, ScriptContext, ScriptEvent, ScriptRule } from './script.js';
8
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';
9
9
  declare const TAG_REGEX: RegExp;
10
10
  /**
@@ -482,6 +482,8 @@ declare class IMAPConnection {
482
482
  /** notifications are sent right away instead of before the next tagged response (IDLE) */
483
483
  directNotifications: boolean;
484
484
  notificationQueue: Notification[];
485
+ /** the plain socket that STARTTLS wrapped in TLS */
486
+ tcpSocket: net.Socket | null | undefined;
485
487
  /** the number of the connection, 1 for the first one the server accepted (script rules match it) */
486
488
  sessionNumber: number;
487
489
  /** the tag and name of the command whose input handler reads the lines that follow (IDLE, AUTHENTICATE) */
@@ -509,6 +511,10 @@ declare class IMAPConnection {
509
511
  /** output that waits behind a delay of a script rule, null when output is written right away */
510
512
  _outputQueue: OutputOperation[] | null;
511
513
  _outputTimer: ReturnType<typeof setTimeout> | null;
514
+ /** untagged responses that `defer` rules hold back, see releaseDeferred in src/script.ts */
515
+ _deferredOutput: DeferredOutput[] | null;
516
+ /** a script rule closes the connection with a RST, see closeNow */
517
+ _resetting: boolean | undefined;
512
518
  constructor(server: IMAPServer, socket: net.Socket);
513
519
  /**
514
520
  * Writes protocol output to the client, through the transport layer if there is one
@@ -528,7 +534,7 @@ declare class IMAPConnection {
528
534
  */
529
535
  flushOutput(): void;
530
536
  /**
531
- * Drops the output that waits for a delay of a script rule
537
+ * Drops the output that waits for a delay of a script rule or for its release (`defer`)
532
538
  */
533
539
  clearOutputQueue(): void;
534
540
  /**
@@ -824,6 +830,15 @@ declare class IMAPConnection {
824
830
  * FETCH result. (This may have other names when used, like "affected".)
825
831
  */
826
832
  send(response: IMAPResponse, description?: string, parsed?: CommandContext | null, data?: string | null, ...extra: any[]): void;
833
+ /**
834
+ * Sends a compiled response through the script rules that watch responses or continuations
835
+ *
836
+ * @param {Object} response Response object
837
+ * @param {String} compiled The compiled response
838
+ * @param {String} [description] Description of the response
839
+ * @param {Object} [parsed] The command the response belongs to
840
+ */
841
+ scriptResponse(response: IMAPResponse, compiled: string, description?: string, parsed?: CommandContext | null): void;
827
842
  /**
828
843
  * Compiles a response for the wire
829
844
  *
@@ -14,9 +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 } from './script.js';
17
+ import { ServerScript, sendOutput, handleLine, literalResponse, releaseDeferred } from './script.js';
18
18
  // longest command line (not counting literals) accepted from a client
19
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;
20
22
  // largest literal accepted after login, override with the maxLiteralSize option
21
23
  const MAX_LITERAL_SIZE = 64 * 1024 * 1024;
22
24
  // largest literal accepted before login, enough for any user name or password
@@ -1219,6 +1221,7 @@ class IMAPConnection {
1219
1221
  this.sessionNumber = ++this.server.sessionCounter;
1220
1222
  this._outputQueue = null;
1221
1223
  this._outputTimer = null;
1224
+ this._deferredOutput = null;
1222
1225
  this.secureConnection = !!this.options.secureConnection;
1223
1226
  this._remainder = '';
1224
1227
  this._command = '';
@@ -1321,7 +1324,7 @@ class IMAPConnection {
1321
1324
  this._outputQueue = null;
1322
1325
  }
1323
1326
  /**
1324
- * Drops the output that waits for a delay of a script rule
1327
+ * Drops the output that waits for a delay of a script rule or for its release (`defer`)
1325
1328
  */
1326
1329
  clearOutputQueue() {
1327
1330
  if (this._outputTimer) {
@@ -1329,6 +1332,7 @@ class IMAPConnection {
1329
1332
  this._outputTimer = null;
1330
1333
  }
1331
1334
  this._outputQueue = null;
1335
+ this._deferredOutput = null;
1332
1336
  }
1333
1337
  /**
1334
1338
  * Writes output through a transport layer, or to the socket
@@ -1359,10 +1363,14 @@ class IMAPConnection {
1359
1363
  return;
1360
1364
  }
1361
1365
  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 ((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);
1366
1374
  if (changed === null) {
1367
1375
  return;
1368
1376
  }
@@ -1436,13 +1444,17 @@ class IMAPConnection {
1436
1444
  this.clearOutputQueue();
1437
1445
  if (mode === 'reset') {
1438
1446
  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
- }
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
+ const tcpSocket = this.tcpSocket;
1453
+ setTimeout(() => {
1454
+ if (!socket.destroyed) {
1455
+ resetSocket(socket, tcpSocket);
1456
+ }
1457
+ }, RESET_DELAY);
1446
1458
  }
1447
1459
  else if (this.transport) {
1448
1460
  this.transport.end(() => socket.end());
@@ -1579,6 +1591,9 @@ class IMAPConnection {
1579
1591
  onData(chunk) {
1580
1592
  let match;
1581
1593
  let str;
1594
+ if (this._resetting) {
1595
+ return;
1596
+ }
1582
1597
  // everything in one read arrived before anything this read causes to be sent
1583
1598
  this._readCount = (this._readCount || 0) + 1;
1584
1599
  str = (chunk || '').toString('binary');
@@ -2058,6 +2073,9 @@ class IMAPConnection {
2058
2073
  };
2059
2074
  // STARTTLS runs on a live connection
2060
2075
  const socket = this.socket;
2076
+ // the RST of a script rule goes to the TCP socket under the TLS layer, see resetSocket. Node also has it as the
2077
+ // _parent of the TLS socket, Bun does not
2078
+ this.tcpSocket = socket;
2061
2079
  // remove all listeners from the original socket besides the error handler
2062
2080
  socket.removeAllListeners();
2063
2081
  socket.on('error', this.onError.bind(this));
@@ -2198,6 +2216,24 @@ class IMAPConnection {
2198
2216
  if (compiled === null) {
2199
2217
  return;
2200
2218
  }
2219
+ if (this._deferredOutput) {
2220
+ // responses that `defer` rules hold back go before the first response of another command
2221
+ releaseDeferred(this, parsed && parsed.command && parsed.tag && parsed.tag !== '*' ? parsed.tag : null, false);
2222
+ }
2223
+ this.scriptResponse(response, compiled, description, parsed);
2224
+ if (this._deferredOutput && response.tag !== '*' && response.tag !== '+') {
2225
+ releaseDeferred(this, response.tag, true);
2226
+ }
2227
+ }
2228
+ /**
2229
+ * Sends a compiled response through the script rules that watch responses or continuations
2230
+ *
2231
+ * @param {Object} response Response object
2232
+ * @param {String} compiled The compiled response
2233
+ * @param {String} [description] Description of the response
2234
+ * @param {Object} [parsed] The command the response belongs to
2235
+ */
2236
+ scriptResponse(response, compiled, description, parsed) {
2201
2237
  const event = response.tag === '+' ? 'continuation' : 'response';
2202
2238
  if (!this.server.script.watches(event)) {
2203
2239
  this.write(compiled);
@@ -2748,6 +2784,32 @@ class IMAPConnection {
2748
2784
  }, mailbox, ignoreSelf || ignoreExists ? this : false);
2749
2785
  }
2750
2786
  }
2787
+ /**
2788
+ * Destroys a socket with a TCP RST (`close: 'reset'` of a script rule). resetAndDestroy (Node 16.17) needs a socket with
2789
+ * a TCP handle, a TLS socket throws ERR_INVALID_HANDLE_TYPE (#84), so the RST goes to the TCP socket under the TLS
2790
+ * layer: the plain socket of STARTTLS, or the parent socket of implicit TLS (Node). A runtime without resetAndDestroy,
2791
+ * or a socket that can not be reset, is destroyed without a RST
2792
+ *
2793
+ * @param {Object} socket The socket of the connection
2794
+ * @param {Object} [tcpSocket] The plain socket that STARTTLS wrapped
2795
+ */
2796
+ function resetSocket(socket, tcpSocket) {
2797
+ const parent = socket._parent;
2798
+ const candidates = [tcpSocket, parent instanceof net.Socket ? parent : null, socket];
2799
+ for (const candidate of candidates) {
2800
+ if (candidate && !candidate.destroyed && typeof candidate.resetAndDestroy === 'function') {
2801
+ try {
2802
+ candidate.resetAndDestroy();
2803
+ break;
2804
+ }
2805
+ catch {
2806
+ // not a TCP handle, try the next one
2807
+ }
2808
+ }
2809
+ }
2810
+ // the TLS socket closes with the TCP socket under it, and emits close for the connection
2811
+ socket.destroy();
2812
+ }
2751
2813
  /**
2752
2814
  * Copies a response tree for the `mutate` action of a script rule: arrays and plain objects are copied,
2753
2815
  * other values (Buffers) are shared
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapkit",
3
- "version": "4.2.0",
3
+ "version": "4.3.1",
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",