imapkit 4.1.1 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,216 @@
1
+ import type { IMAPConnection, IMAPServer } from './server.js';
2
+ import type { IMAPResponse, Transport } from './types.js';
3
+ /**
4
+ * Scripted faults: rules that make the server deviate from the protocol on purpose, to test how a client
5
+ * copes with a broken or unusual server. Every rule watches one kind of event, the first rule that matches
6
+ * an event handles it. Output rules see the exact bytes that are about to go out, after every plugin and the
7
+ * core changed the response, so they can send anything at all, also what the strict server never would.
8
+ */
9
+ /** The events a rule can watch */
10
+ export type ScriptEvent = 'greeting' | 'command' | 'input' | 'response' | 'continuation';
11
+ /** Bytes a rule sends, a function gets the context of the event */
12
+ export type ScriptBytes = string | Buffer | ((context: ScriptContext) => string | Buffer);
13
+ /** The event a rule is checked against */
14
+ export interface ScriptContext {
15
+ event: ScriptEvent;
16
+ connection: IMAPConnection;
17
+ /** the number of the connection, 1 for the first one the server accepted */
18
+ session: number;
19
+ state: string;
20
+ /** the authenticated user, or null */
21
+ user: string | null;
22
+ /** path of the selected mailbox, or null */
23
+ mailbox: string | null;
24
+ /** tag of the command the event belongs to, or null (greeting, unsolicited responses) */
25
+ tag: string | null;
26
+ /** name of the command the event belongs to in upper case (`"UID FETCH"`), or null */
27
+ command: string | null;
28
+ /** the received line (command, input) or the bytes about to be sent (output events), as a binary string */
29
+ data: string;
30
+ /** the description of the response, see `connection.send()` (response and continuation events) */
31
+ description?: string | null | undefined;
32
+ /** the response object (response and continuation events sent with `connection.send()`) */
33
+ response?: IMAPResponse | undefined;
34
+ }
35
+ /** A rule as it is given to `server.script.add()` or the `script` option */
36
+ export interface ScriptRule {
37
+ on: ScriptEvent;
38
+ /** command name or names, case-insensitive (`"FETCH"`, `"UID FETCH"`) */
39
+ command?: string | string[] | undefined;
40
+ /** command tag, a string matches exactly */
41
+ tag?: string | RegExp | undefined;
42
+ /** response description or descriptions (response and continuation events) */
43
+ description?: string | string[] | undefined;
44
+ /** connection number or numbers, 1 for the first connection */
45
+ session?: number | number[] | undefined;
46
+ user?: string | undefined;
47
+ state?: string | string[] | undefined;
48
+ /** path of the selected mailbox */
49
+ mailbox?: string | undefined;
50
+ /** only untagged (true) or only tagged (false) responses (response events) */
51
+ untagged?: boolean | undefined;
52
+ /** tested against `context.data`, a string is a regular expression source */
53
+ match?: string | RegExp | undefined;
54
+ when?: ((context: ScriptContext) => boolean) | undefined;
55
+ /** the rule fires from the nth matching event on (default 1) */
56
+ nth?: number | undefined;
57
+ /** the rule fires this many times at most (default unlimited) */
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;
66
+ /** changes or replaces the response object before it is compiled (response events) */
67
+ mutate?: ((response: IMAPResponse, context: ScriptContext) => IMAPResponse | void) | undefined;
68
+ /** bytes to send instead of the output, or instead of answering the command or input line. `$TAG` in a string is the tag */
69
+ send?: ScriptBytes | undefined;
70
+ /** bytes to send before the output (output events) */
71
+ before?: ScriptBytes | undefined;
72
+ /** bytes to send after the output (output events) */
73
+ after?: ScriptBytes | undefined;
74
+ /** process the command or input line as usual after `send` (command and input events) */
75
+ run?: boolean | undefined;
76
+ /** send nothing (output events), or ignore the command or input line */
77
+ drop?: boolean | undefined;
78
+ /** milliseconds to wait before the output goes out, or before the command is handled. Later output waits too */
79
+ delay?: number | undefined;
80
+ /** write the bytes in pieces of this many octets */
81
+ chunk?: number | undefined;
82
+ /** milliseconds between the pieces of `chunk` (default 10) */
83
+ chunkDelay?: number | undefined;
84
+ /** send only this many octets of the bytes, then close the connection */
85
+ truncate?: number | undefined;
86
+ /** close the connection after the bytes are sent, `"reset"` destroys the socket instead of ending it */
87
+ close?: boolean | 'reset' | undefined;
88
+ }
89
+ /** A rule that was added, `server.script.add()` returns it */
90
+ export interface ScriptHandle {
91
+ readonly rule: ScriptRule;
92
+ /** events that matched the rule, also before `nth` */
93
+ readonly matched: number;
94
+ /** events the rule handled */
95
+ readonly hits: number;
96
+ remove(): void;
97
+ }
98
+ /** Output that waits in the output queue of a connection, see `IMAPConnection#queueOutput` */
99
+ export interface OutputOperation {
100
+ data?: Buffer | undefined;
101
+ delay?: number | undefined;
102
+ chunk?: number | undefined;
103
+ chunkDelay?: number | undefined;
104
+ close?: boolean | 'reset' | undefined;
105
+ /** the transport layer of the connection when the output was queued */
106
+ transport?: Transport | null | undefined;
107
+ }
108
+ declare const SET_MATCHERS: readonly ["command", "description", "session", "state", "user", "mailbox"];
109
+ type SetMatcher = (typeof SET_MATCHERS)[number];
110
+ interface Entry extends ScriptHandle {
111
+ matched: number;
112
+ hits: number;
113
+ match: RegExp | null;
114
+ sets: Partial<Record<SetMatcher, Set<string | number>>>;
115
+ }
116
+ /**
117
+ * The script of a server, `server.script`
118
+ */
119
+ export declare class ServerScript {
120
+ server: IMAPServer;
121
+ entries: Entry[];
122
+ /** the events that rules watch, so that an event nobody watches costs one lookup */
123
+ watched: Set<ScriptEvent>;
124
+ constructor(server: IMAPServer, rules?: ScriptRule | ScriptRule[] | undefined);
125
+ /** the rules that were added, in the order they are checked */
126
+ get rules(): ScriptHandle[];
127
+ /**
128
+ * Adds a rule, or a list of rules, after the rules added earlier
129
+ *
130
+ * @param {Object|Array} rules Rule or rules
131
+ * @return {Object|Array} handle or handles with `hits`, `matched` and `remove()`
132
+ */
133
+ add(rule: ScriptRule): ScriptHandle;
134
+ add(rules: ScriptRule[]): ScriptHandle[];
135
+ add(rules: ScriptRule | ScriptRule[]): ScriptHandle | ScriptHandle[];
136
+ /** removes every rule */
137
+ clear(): void;
138
+ /**
139
+ * Checks if any rule watches an event, so that the event can skip building a context
140
+ *
141
+ * @param {String} event Event name
142
+ * @return {Boolean} true if a rule watches the event
143
+ */
144
+ watches(event: ScriptEvent): boolean;
145
+ /**
146
+ * Finds the rule that handles an event of a connection
147
+ *
148
+ * @param {Object} connection IMAP connection
149
+ * @param {String} event Event name
150
+ * @param {Object} fields `data`, and `tag`, `command`, `description`, `response` where they are known
151
+ * @return {Object|null} `{ rule, context }`, or null if no rule handles the event
152
+ */
153
+ check(connection: IMAPConnection, event: ScriptEvent, fields: Partial<ScriptContext> & {
154
+ data: string;
155
+ }): {
156
+ rule: ScriptRule;
157
+ context: ScriptContext;
158
+ } | null;
159
+ private setEntries;
160
+ private addEntry;
161
+ /**
162
+ * Finds the rule that handles an event and counts it
163
+ *
164
+ * @param {Object} context Event context
165
+ * @return {Object|null} the rule, or null if no rule handles the event
166
+ */
167
+ find(context: ScriptContext): ScriptRule | null;
168
+ private matches;
169
+ }
170
+ /**
171
+ * Sends output through the rule that handles it: the output itself (unless dropped or replaced with `send`)
172
+ * between `before` and `after`, cut by `truncate`, with the timing of `delay` and `chunk`
173
+ *
174
+ * @param {Object} connection IMAP connection
175
+ * @param {Object} rule Rule that handles the output
176
+ * @param {Object} context Event context
177
+ * @param {Buffer} output The output
178
+ */
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;
206
+ /**
207
+ * Handles a command or input line with the rule that matched it: sends the bytes of `send`, closes the connection,
208
+ * and processes the line as usual with `run` (or when the rule only delays it)
209
+ *
210
+ * @param {Object} connection IMAP connection
211
+ * @param {Object} rule Rule that matched
212
+ * @param {Object} context Event context
213
+ * @param {Function} run Processes the line as usual
214
+ */
215
+ export declare function handleLine(connection: IMAPConnection, rule: ScriptRule, context: ScriptContext, run: () => void): void;
216
+ export {};
@@ -0,0 +1,473 @@
1
+ const EVENTS = ['greeting', 'command', 'input', 'response', 'continuation'];
2
+ const OUTPUT_EVENTS = ['greeting', 'response', 'continuation'];
3
+ // the events where a key is valid, every other key is a typo and refused
4
+ const KEYS = {
5
+ on: EVENTS,
6
+ command: ['command', 'input', 'response', 'continuation'],
7
+ tag: ['command', 'input', 'response', 'continuation'],
8
+ description: ['response', 'continuation'],
9
+ session: EVENTS,
10
+ user: EVENTS,
11
+ state: EVENTS,
12
+ mailbox: EVENTS,
13
+ untagged: ['response'],
14
+ match: EVENTS,
15
+ when: EVENTS,
16
+ nth: EVENTS,
17
+ times: EVENTS,
18
+ mutate: ['response', 'continuation'],
19
+ literals: ['response'],
20
+ defer: ['response'],
21
+ send: EVENTS,
22
+ before: OUTPUT_EVENTS,
23
+ after: OUTPUT_EVENTS,
24
+ run: ['command', 'input'],
25
+ drop: EVENTS,
26
+ delay: ['greeting', 'command', 'response', 'continuation'],
27
+ chunk: EVENTS,
28
+ chunkDelay: EVENTS,
29
+ truncate: EVENTS,
30
+ close: EVENTS
31
+ };
32
+ const ACTIONS = ['mutate', 'literals', 'defer', 'send', 'before', 'after', 'run', 'drop', 'delay', 'chunk', 'truncate', 'close'];
33
+ const DEFAULT_CHUNK_DELAY = 10;
34
+ // matchers that list accepted values, kept as Sets so that matching an event allocates nothing
35
+ const SET_MATCHERS = ['command', 'description', 'session', 'state', 'user', 'mailbox'];
36
+ /**
37
+ * Turns bytes of a rule into a Buffer. A string is a binary string (one character per octet) like everywhere
38
+ * in ImapKit, unless it has characters above U+00FF, then it is sent as UTF-8
39
+ */
40
+ function resolveBytes(value, context) {
41
+ if (value === undefined) {
42
+ return null;
43
+ }
44
+ const bytes = typeof value === 'function' ? value(context) : value;
45
+ if (Buffer.isBuffer(bytes)) {
46
+ return bytes;
47
+ }
48
+ const text = String(bytes).replace(/\$TAG/g, () => context.tag || '*');
49
+ return Buffer.from(text, /[\u0100-\uffff]/.test(text) ? 'utf-8' : 'binary');
50
+ }
51
+ /**
52
+ * Checks a rule given by the caller, a mistake is a TypeError right away instead of a rule that silently never fires
53
+ *
54
+ * @param {Object} rule Rule to check
55
+ * @return {RegExp|null} the `match` expression
56
+ */
57
+ function validateRule(rule) {
58
+ if (!rule || typeof rule !== 'object') {
59
+ throw new TypeError('A script rule must be an object');
60
+ }
61
+ if (!EVENTS.includes(rule.on)) {
62
+ throw new TypeError('Script rule "on" must be one of ' + EVENTS.join(', '));
63
+ }
64
+ for (const key of Object.keys(rule)) {
65
+ const events = Object.hasOwn(KEYS, key) ? KEYS[key] : null;
66
+ if (!events) {
67
+ throw new TypeError('Unknown script rule option "' + key + '"');
68
+ }
69
+ if (!events.includes(rule.on)) {
70
+ throw new TypeError('Script rule option "' + key + '" can not be used with "on": "' + rule.on + '"');
71
+ }
72
+ }
73
+ if (!ACTIONS.some(key => rule[key] !== undefined && rule[key] !== false)) {
74
+ throw new TypeError('A script rule needs an action (' + ACTIONS.join(', ') + ')');
75
+ }
76
+ if (rule.drop && (rule.send !== undefined || rule.run)) {
77
+ throw new TypeError('Script rule option "drop" can not be combined with "send" or "run"');
78
+ }
79
+ if (rule.run && (rule.close || rule.truncate !== undefined)) {
80
+ throw new TypeError('Script rule option "run" can not be combined with "close" or "truncate"');
81
+ }
82
+ if (rule.chunkDelay !== undefined && rule.chunk === undefined) {
83
+ throw new TypeError('Script rule option "chunkDelay" needs "chunk"');
84
+ }
85
+ if (!OUTPUT_EVENTS.includes(rule.on) && rule.send === undefined && (rule.chunk !== undefined || rule.truncate !== undefined)) {
86
+ // a command or input line has no output of its own to cut or split
87
+ throw new TypeError('Script rule options "chunk" and "truncate" need "send" with "on": "' + rule.on + '"');
88
+ }
89
+ for (const key of ['nth', 'times', 'chunk']) {
90
+ if (rule[key] !== undefined && !(Number.isInteger(rule[key]) && rule[key] > 0)) {
91
+ throw new TypeError('Script rule option "' + key + '" must be a positive integer');
92
+ }
93
+ }
94
+ for (const key of ['delay', 'chunkDelay', 'truncate']) {
95
+ if (rule[key] !== undefined && !(Number.isInteger(rule[key]) && rule[key] >= 0)) {
96
+ throw new TypeError('Script rule option "' + key + '" must be a non-negative integer');
97
+ }
98
+ }
99
+ for (const key of ['when', 'mutate']) {
100
+ if (rule[key] !== undefined && typeof rule[key] !== 'function') {
101
+ throw new TypeError('Script rule option "' + key + '" must be a function');
102
+ }
103
+ }
104
+ for (const key of ['send', 'before', 'after']) {
105
+ const value = rule[key];
106
+ if (value !== undefined && typeof value !== 'string' && typeof value !== 'function' && !Buffer.isBuffer(value)) {
107
+ throw new TypeError('Script rule option "' + key + '" must be a string, a Buffer or a function');
108
+ }
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
+ }
126
+ if (rule.close !== undefined && typeof rule.close !== 'boolean' && rule.close !== 'reset') {
127
+ throw new TypeError('Script rule option "close" must be true, false or "reset"');
128
+ }
129
+ if (rule.match === undefined) {
130
+ return null;
131
+ }
132
+ if (rule.match instanceof RegExp) {
133
+ // a global or sticky expression would keep its position between events
134
+ return new RegExp(rule.match.source, rule.match.flags.replace(/[gy]/g, ''));
135
+ }
136
+ if (typeof rule.match !== 'string') {
137
+ throw new TypeError('Script rule option "match" must be a string or a RegExp');
138
+ }
139
+ try {
140
+ return new RegExp(rule.match);
141
+ }
142
+ catch (err) {
143
+ throw new TypeError('Script rule option "match" is not a valid regular expression: ' + err.message, { cause: err });
144
+ }
145
+ }
146
+ /**
147
+ * The script of a server, `server.script`
148
+ */
149
+ export class ServerScript {
150
+ constructor(server, rules) {
151
+ this.server = server;
152
+ this.entries = [];
153
+ this.watched = new Set();
154
+ if (rules) {
155
+ this.add(rules);
156
+ }
157
+ }
158
+ /** the rules that were added, in the order they are checked */
159
+ get rules() {
160
+ return this.entries.slice();
161
+ }
162
+ add(rules) {
163
+ if (Array.isArray(rules)) {
164
+ // all rules are checked before any is added
165
+ const matches = rules.map(validateRule);
166
+ return rules.map((rule, i) => this.addEntry(rule, matches[i]));
167
+ }
168
+ return this.addEntry(rules, validateRule(rules));
169
+ }
170
+ /** removes every rule */
171
+ clear() {
172
+ this.setEntries([]);
173
+ }
174
+ /**
175
+ * Checks if any rule watches an event, so that the event can skip building a context
176
+ *
177
+ * @param {String} event Event name
178
+ * @return {Boolean} true if a rule watches the event
179
+ */
180
+ watches(event) {
181
+ return this.watched.has(event);
182
+ }
183
+ /**
184
+ * Finds the rule that handles an event of a connection
185
+ *
186
+ * @param {Object} connection IMAP connection
187
+ * @param {String} event Event name
188
+ * @param {Object} fields `data`, and `tag`, `command`, `description`, `response` where they are known
189
+ * @return {Object|null} `{ rule, context }`, or null if no rule handles the event
190
+ */
191
+ check(connection, event, fields) {
192
+ if (!this.watched.has(event)) {
193
+ return null;
194
+ }
195
+ const context = scriptContext(connection, event, fields);
196
+ const rule = this.find(context);
197
+ return rule ? { rule, context } : null;
198
+ }
199
+ setEntries(entries) {
200
+ this.entries = entries;
201
+ this.watched = new Set(entries.map(entry => entry.rule.on));
202
+ }
203
+ addEntry(rule, match) {
204
+ // a copy, so that changing the caller's object later does not change the rule
205
+ const copy = Object.freeze(Object.assign({}, rule));
206
+ const sets = {};
207
+ for (const key of SET_MATCHERS) {
208
+ if (copy[key] !== undefined) {
209
+ // command names are compared in upper case
210
+ const values = [].concat(copy[key]);
211
+ sets[key] = new Set(key === 'command' ? values.map(value => String(value).toUpperCase()) : values);
212
+ }
213
+ }
214
+ const entry = {
215
+ rule: copy,
216
+ match,
217
+ sets,
218
+ matched: 0,
219
+ hits: 0,
220
+ remove: () => this.setEntries(this.entries.filter(item => item !== entry))
221
+ };
222
+ this.setEntries(this.entries.concat(entry));
223
+ return entry;
224
+ }
225
+ /**
226
+ * Finds the rule that handles an event and counts it
227
+ *
228
+ * @param {Object} context Event context
229
+ * @return {Object|null} the rule, or null if no rule handles the event
230
+ */
231
+ find(context) {
232
+ for (const entry of this.entries) {
233
+ const rule = entry.rule;
234
+ if (rule.on !== context.event || !this.matches(entry, context)) {
235
+ continue;
236
+ }
237
+ entry.matched++;
238
+ if (entry.matched < (rule.nth || 1) || (rule.times !== undefined && entry.hits >= rule.times)) {
239
+ continue;
240
+ }
241
+ entry.hits++;
242
+ this.server.emit('script', { rule, event: context.event, session: context.session, tag: context.tag, command: context.command });
243
+ return rule;
244
+ }
245
+ return null;
246
+ }
247
+ matches(entry, context) {
248
+ const rule = entry.rule;
249
+ for (const key of SET_MATCHERS) {
250
+ const set = entry.sets[key];
251
+ const value = context[key];
252
+ if (set && (value === null || value === undefined || !set.has(value))) {
253
+ return false;
254
+ }
255
+ }
256
+ if (rule.tag !== undefined && (context.tag === null || (rule.tag instanceof RegExp ? !rule.tag.test(context.tag) : rule.tag !== context.tag))) {
257
+ return false;
258
+ }
259
+ if (rule.untagged !== undefined && rule.untagged !== (!context.response || context.response.tag === '*')) {
260
+ return false;
261
+ }
262
+ if (entry.match && !entry.match.test(context.data)) {
263
+ return false;
264
+ }
265
+ return !rule.when || !!rule.when(context);
266
+ }
267
+ }
268
+ /**
269
+ * Builds the context of an event on a connection
270
+ *
271
+ * @param {Object} connection IMAP connection
272
+ * @param {String} event Event name
273
+ * @param {Object} fields `data`, and `tag`, `command`, `description`, `response` where they are known
274
+ * @return {Object} Event context
275
+ */
276
+ function scriptContext(connection, event, fields) {
277
+ // after its handler returned, a command whose input handler reads the lines (IDLE, AUTHENTICATE) is the one
278
+ // the events belong to
279
+ const running = connection._runningCommand
280
+ ? connection._runningCommand.parsed
281
+ : connection.inputHandler
282
+ ? connection.inputCommand
283
+ : null;
284
+ return Object.assign({
285
+ event,
286
+ connection,
287
+ session: connection.sessionNumber,
288
+ state: connection.state,
289
+ user: connection.username || null,
290
+ mailbox: connection.selectedMailbox ? connection.selectedMailbox.path : null,
291
+ tag: running ? running.tag : null,
292
+ command: running ? String(running.command).toUpperCase() : null
293
+ }, fields);
294
+ }
295
+ /**
296
+ * Sends output through the rule that handles it: the output itself (unless dropped or replaced with `send`)
297
+ * between `before` and `after`, cut by `truncate`, with the timing of `delay` and `chunk`
298
+ *
299
+ * @param {Object} connection IMAP connection
300
+ * @param {Object} rule Rule that handles the output
301
+ * @param {Object} context Event context
302
+ * @param {Buffer} output The output
303
+ */
304
+ export function sendOutput(connection, rule, context, output) {
305
+ const replaced = rule.drop ? Buffer.alloc(0) : resolveBytes(rule.send, context) || output;
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
+ }
311
+ sendBytes(connection, rule, Buffer.concat(parts), rule.delay);
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
+ }
438
+ /**
439
+ * Sends the bytes of a rule with its timing, `truncate` closes the connection after the cut
440
+ *
441
+ * @param {Object} connection IMAP connection
442
+ * @param {Object} rule Rule
443
+ * @param {Buffer} data Bytes to send
444
+ * @param {Number} [delay] Milliseconds to wait first
445
+ */
446
+ function sendBytes(connection, rule, data, delay) {
447
+ connection.queueOutput({
448
+ data: rule.truncate === undefined ? data : data.subarray(0, rule.truncate),
449
+ delay,
450
+ chunk: rule.chunk,
451
+ chunkDelay: rule.chunkDelay === undefined ? DEFAULT_CHUNK_DELAY : rule.chunkDelay,
452
+ close: rule.close || (rule.truncate === undefined ? undefined : true)
453
+ });
454
+ }
455
+ /**
456
+ * Handles a command or input line with the rule that matched it: sends the bytes of `send`, closes the connection,
457
+ * and processes the line as usual with `run` (or when the rule only delays it)
458
+ *
459
+ * @param {Object} connection IMAP connection
460
+ * @param {Object} rule Rule that matched
461
+ * @param {Object} context Event context
462
+ * @param {Function} run Processes the line as usual
463
+ */
464
+ export function handleLine(connection, rule, context, run) {
465
+ const bytes = resolveBytes(rule.send, context);
466
+ if (bytes || rule.close) {
467
+ sendBytes(connection, rule, bytes || Buffer.alloc(0));
468
+ }
469
+ // a rule that only delays the line does not replace it
470
+ if (rule.run || (!rule.drop && !bytes && !rule.close)) {
471
+ run();
472
+ }
473
+ }