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 +22 -25
- package/dist/cjs/script.d.ts +33 -0
- package/dist/cjs/script.js +150 -1
- package/dist/cjs/server.d.ts +17 -2
- package/dist/cjs/server.js +74 -12
- package/dist/esm/script.d.ts +33 -0
- package/dist/esm/script.js +148 -1
- package/dist/esm/server.d.ts +17 -2
- package/dist/esm/server.js +75 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -410,21 +410,16 @@ const server = imapkit({
|
|
|
410
410
|
]
|
|
411
411
|
});
|
|
412
412
|
|
|
413
|
-
//
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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(
|
|
427
|
-
|
|
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
|
-
| `
|
|
462
|
-
| `
|
|
463
|
-
| `
|
|
464
|
-
| `
|
|
465
|
-
| `
|
|
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
|
|
package/dist/cjs/script.d.ts
CHANGED
|
@@ -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)
|
package/dist/cjs/script.js
CHANGED
|
@@ -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
|
*
|
package/dist/cjs/server.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/cjs/server.js
CHANGED
|
@@ -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
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
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
|
-
//
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
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
|
package/dist/esm/script.d.ts
CHANGED
|
@@ -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)
|
package/dist/esm/script.js
CHANGED
|
@@ -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
|
*
|
package/dist/esm/server.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/esm/server.js
CHANGED
|
@@ -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
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
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
|
-
//
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
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
|