imapkit 4.1.1 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -1
- package/bin/help.txt +27 -1
- package/bin/imapkit.js +6 -0
- package/dist/cjs/index.d.ts +1 -0
- package/dist/cjs/plugins/auth-plain.js +1 -1
- package/dist/cjs/plugins/idle.js +1 -1
- package/dist/cjs/plugins/oauthbearer.js +1 -1
- package/dist/cjs/script.d.ts +183 -0
- package/dist/cjs/script.js +332 -0
- package/dist/cjs/server.d.ts +94 -1
- package/dist/cjs/server.js +296 -20
- package/dist/cjs/types.d.ts +3 -0
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/plugins/auth-plain.js +1 -1
- package/dist/esm/plugins/idle.js +1 -1
- package/dist/esm/plugins/oauthbearer.js +1 -1
- package/dist/esm/script.d.ts +183 -0
- package/dist/esm/script.js +326 -0
- package/dist/esm/server.d.ts +94 -1
- package/dist/esm/server.js +296 -20
- package/dist/esm/types.d.ts +3 -0
- package/package.json +2 -2
|
@@ -0,0 +1,183 @@
|
|
|
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
|
+
/** changes or replaces the response object before it is compiled (response events) */
|
|
60
|
+
mutate?: ((response: IMAPResponse, context: ScriptContext) => IMAPResponse | void) | undefined;
|
|
61
|
+
/** bytes to send instead of the output, or instead of answering the command or input line. `$TAG` in a string is the tag */
|
|
62
|
+
send?: ScriptBytes | undefined;
|
|
63
|
+
/** bytes to send before the output (output events) */
|
|
64
|
+
before?: ScriptBytes | undefined;
|
|
65
|
+
/** bytes to send after the output (output events) */
|
|
66
|
+
after?: ScriptBytes | undefined;
|
|
67
|
+
/** process the command or input line as usual after `send` (command and input events) */
|
|
68
|
+
run?: boolean | undefined;
|
|
69
|
+
/** send nothing (output events), or ignore the command or input line */
|
|
70
|
+
drop?: boolean | undefined;
|
|
71
|
+
/** milliseconds to wait before the output goes out, or before the command is handled. Later output waits too */
|
|
72
|
+
delay?: number | undefined;
|
|
73
|
+
/** write the bytes in pieces of this many octets */
|
|
74
|
+
chunk?: number | undefined;
|
|
75
|
+
/** milliseconds between the pieces of `chunk` (default 10) */
|
|
76
|
+
chunkDelay?: number | undefined;
|
|
77
|
+
/** send only this many octets of the bytes, then close the connection */
|
|
78
|
+
truncate?: number | undefined;
|
|
79
|
+
/** close the connection after the bytes are sent, `"reset"` destroys the socket instead of ending it */
|
|
80
|
+
close?: boolean | 'reset' | undefined;
|
|
81
|
+
}
|
|
82
|
+
/** A rule that was added, `server.script.add()` returns it */
|
|
83
|
+
export interface ScriptHandle {
|
|
84
|
+
readonly rule: ScriptRule;
|
|
85
|
+
/** events that matched the rule, also before `nth` */
|
|
86
|
+
readonly matched: number;
|
|
87
|
+
/** events the rule handled */
|
|
88
|
+
readonly hits: number;
|
|
89
|
+
remove(): void;
|
|
90
|
+
}
|
|
91
|
+
/** Output that waits in the output queue of a connection, see `IMAPConnection#queueOutput` */
|
|
92
|
+
export interface OutputOperation {
|
|
93
|
+
data?: Buffer | undefined;
|
|
94
|
+
delay?: number | undefined;
|
|
95
|
+
chunk?: number | undefined;
|
|
96
|
+
chunkDelay?: number | undefined;
|
|
97
|
+
close?: boolean | 'reset' | undefined;
|
|
98
|
+
/** the transport layer of the connection when the output was queued */
|
|
99
|
+
transport?: Transport | null | undefined;
|
|
100
|
+
}
|
|
101
|
+
declare const SET_MATCHERS: readonly ["command", "description", "session", "state", "user", "mailbox"];
|
|
102
|
+
type SetMatcher = (typeof SET_MATCHERS)[number];
|
|
103
|
+
interface Entry extends ScriptHandle {
|
|
104
|
+
matched: number;
|
|
105
|
+
hits: number;
|
|
106
|
+
match: RegExp | null;
|
|
107
|
+
sets: Partial<Record<SetMatcher, Set<string | number>>>;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The script of a server, `server.script`
|
|
111
|
+
*/
|
|
112
|
+
export declare class ServerScript {
|
|
113
|
+
server: IMAPServer;
|
|
114
|
+
entries: Entry[];
|
|
115
|
+
/** the events that rules watch, so that an event nobody watches costs one lookup */
|
|
116
|
+
watched: Set<ScriptEvent>;
|
|
117
|
+
constructor(server: IMAPServer, rules?: ScriptRule | ScriptRule[] | undefined);
|
|
118
|
+
/** the rules that were added, in the order they are checked */
|
|
119
|
+
get rules(): ScriptHandle[];
|
|
120
|
+
/**
|
|
121
|
+
* Adds a rule, or a list of rules, after the rules added earlier
|
|
122
|
+
*
|
|
123
|
+
* @param {Object|Array} rules Rule or rules
|
|
124
|
+
* @return {Object|Array} handle or handles with `hits`, `matched` and `remove()`
|
|
125
|
+
*/
|
|
126
|
+
add(rule: ScriptRule): ScriptHandle;
|
|
127
|
+
add(rules: ScriptRule[]): ScriptHandle[];
|
|
128
|
+
add(rules: ScriptRule | ScriptRule[]): ScriptHandle | ScriptHandle[];
|
|
129
|
+
/** removes every rule */
|
|
130
|
+
clear(): void;
|
|
131
|
+
/**
|
|
132
|
+
* Checks if any rule watches an event, so that the event can skip building a context
|
|
133
|
+
*
|
|
134
|
+
* @param {String} event Event name
|
|
135
|
+
* @return {Boolean} true if a rule watches the event
|
|
136
|
+
*/
|
|
137
|
+
watches(event: ScriptEvent): boolean;
|
|
138
|
+
/**
|
|
139
|
+
* Finds the rule that handles an event of a connection
|
|
140
|
+
*
|
|
141
|
+
* @param {Object} connection IMAP connection
|
|
142
|
+
* @param {String} event Event name
|
|
143
|
+
* @param {Object} fields `data`, and `tag`, `command`, `description`, `response` where they are known
|
|
144
|
+
* @return {Object|null} `{ rule, context }`, or null if no rule handles the event
|
|
145
|
+
*/
|
|
146
|
+
check(connection: IMAPConnection, event: ScriptEvent, fields: Partial<ScriptContext> & {
|
|
147
|
+
data: string;
|
|
148
|
+
}): {
|
|
149
|
+
rule: ScriptRule;
|
|
150
|
+
context: ScriptContext;
|
|
151
|
+
} | null;
|
|
152
|
+
private setEntries;
|
|
153
|
+
private addEntry;
|
|
154
|
+
/**
|
|
155
|
+
* Finds the rule that handles an event and counts it
|
|
156
|
+
*
|
|
157
|
+
* @param {Object} context Event context
|
|
158
|
+
* @return {Object|null} the rule, or null if no rule handles the event
|
|
159
|
+
*/
|
|
160
|
+
find(context: ScriptContext): ScriptRule | null;
|
|
161
|
+
private matches;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Sends output through the rule that handles it: the output itself (unless dropped or replaced with `send`)
|
|
165
|
+
* between `before` and `after`, cut by `truncate`, with the timing of `delay` and `chunk`
|
|
166
|
+
*
|
|
167
|
+
* @param {Object} connection IMAP connection
|
|
168
|
+
* @param {Object} rule Rule that handles the output
|
|
169
|
+
* @param {Object} context Event context
|
|
170
|
+
* @param {Buffer} output The output
|
|
171
|
+
*/
|
|
172
|
+
export declare function sendOutput(connection: IMAPConnection, rule: ScriptRule, context: ScriptContext, output: Buffer): void;
|
|
173
|
+
/**
|
|
174
|
+
* Handles a command or input line with the rule that matched it: sends the bytes of `send`, closes the connection,
|
|
175
|
+
* and processes the line as usual with `run` (or when the rule only delays it)
|
|
176
|
+
*
|
|
177
|
+
* @param {Object} connection IMAP connection
|
|
178
|
+
* @param {Object} rule Rule that matched
|
|
179
|
+
* @param {Object} context Event context
|
|
180
|
+
* @param {Function} run Processes the line as usual
|
|
181
|
+
*/
|
|
182
|
+
export declare function handleLine(connection: IMAPConnection, rule: ScriptRule, context: ScriptContext, run: () => void): void;
|
|
183
|
+
export {};
|
|
@@ -0,0 +1,326 @@
|
|
|
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
|
+
send: EVENTS,
|
|
20
|
+
before: OUTPUT_EVENTS,
|
|
21
|
+
after: OUTPUT_EVENTS,
|
|
22
|
+
run: ['command', 'input'],
|
|
23
|
+
drop: EVENTS,
|
|
24
|
+
delay: ['greeting', 'command', 'response', 'continuation'],
|
|
25
|
+
chunk: EVENTS,
|
|
26
|
+
chunkDelay: EVENTS,
|
|
27
|
+
truncate: EVENTS,
|
|
28
|
+
close: EVENTS
|
|
29
|
+
};
|
|
30
|
+
const ACTIONS = ['mutate', 'send', 'before', 'after', 'run', 'drop', 'delay', 'chunk', 'truncate', 'close'];
|
|
31
|
+
const DEFAULT_CHUNK_DELAY = 10;
|
|
32
|
+
// matchers that list accepted values, kept as Sets so that matching an event allocates nothing
|
|
33
|
+
const SET_MATCHERS = ['command', 'description', 'session', 'state', 'user', 'mailbox'];
|
|
34
|
+
/**
|
|
35
|
+
* Turns bytes of a rule into a Buffer. A string is a binary string (one character per octet) like everywhere
|
|
36
|
+
* in ImapKit, unless it has characters above U+00FF, then it is sent as UTF-8
|
|
37
|
+
*/
|
|
38
|
+
function resolveBytes(value, context) {
|
|
39
|
+
if (value === undefined) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const bytes = typeof value === 'function' ? value(context) : value;
|
|
43
|
+
if (Buffer.isBuffer(bytes)) {
|
|
44
|
+
return bytes;
|
|
45
|
+
}
|
|
46
|
+
const text = String(bytes).replace(/\$TAG/g, () => context.tag || '*');
|
|
47
|
+
return Buffer.from(text, /[\u0100-\uffff]/.test(text) ? 'utf-8' : 'binary');
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Checks a rule given by the caller, a mistake is a TypeError right away instead of a rule that silently never fires
|
|
51
|
+
*
|
|
52
|
+
* @param {Object} rule Rule to check
|
|
53
|
+
* @return {RegExp|null} the `match` expression
|
|
54
|
+
*/
|
|
55
|
+
function validateRule(rule) {
|
|
56
|
+
if (!rule || typeof rule !== 'object') {
|
|
57
|
+
throw new TypeError('A script rule must be an object');
|
|
58
|
+
}
|
|
59
|
+
if (!EVENTS.includes(rule.on)) {
|
|
60
|
+
throw new TypeError('Script rule "on" must be one of ' + EVENTS.join(', '));
|
|
61
|
+
}
|
|
62
|
+
for (const key of Object.keys(rule)) {
|
|
63
|
+
const events = Object.hasOwn(KEYS, key) ? KEYS[key] : null;
|
|
64
|
+
if (!events) {
|
|
65
|
+
throw new TypeError('Unknown script rule option "' + key + '"');
|
|
66
|
+
}
|
|
67
|
+
if (!events.includes(rule.on)) {
|
|
68
|
+
throw new TypeError('Script rule option "' + key + '" can not be used with "on": "' + rule.on + '"');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (!ACTIONS.some(key => rule[key] !== undefined && rule[key] !== false)) {
|
|
72
|
+
throw new TypeError('A script rule needs an action (' + ACTIONS.join(', ') + ')');
|
|
73
|
+
}
|
|
74
|
+
if (rule.drop && (rule.send !== undefined || rule.run)) {
|
|
75
|
+
throw new TypeError('Script rule option "drop" can not be combined with "send" or "run"');
|
|
76
|
+
}
|
|
77
|
+
if (rule.run && (rule.close || rule.truncate !== undefined)) {
|
|
78
|
+
throw new TypeError('Script rule option "run" can not be combined with "close" or "truncate"');
|
|
79
|
+
}
|
|
80
|
+
if (rule.chunkDelay !== undefined && rule.chunk === undefined) {
|
|
81
|
+
throw new TypeError('Script rule option "chunkDelay" needs "chunk"');
|
|
82
|
+
}
|
|
83
|
+
if (!OUTPUT_EVENTS.includes(rule.on) && rule.send === undefined && (rule.chunk !== undefined || rule.truncate !== undefined)) {
|
|
84
|
+
// a command or input line has no output of its own to cut or split
|
|
85
|
+
throw new TypeError('Script rule options "chunk" and "truncate" need "send" with "on": "' + rule.on + '"');
|
|
86
|
+
}
|
|
87
|
+
for (const key of ['nth', 'times', 'chunk']) {
|
|
88
|
+
if (rule[key] !== undefined && !(Number.isInteger(rule[key]) && rule[key] > 0)) {
|
|
89
|
+
throw new TypeError('Script rule option "' + key + '" must be a positive integer');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const key of ['delay', 'chunkDelay', 'truncate']) {
|
|
93
|
+
if (rule[key] !== undefined && !(Number.isInteger(rule[key]) && rule[key] >= 0)) {
|
|
94
|
+
throw new TypeError('Script rule option "' + key + '" must be a non-negative integer');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const key of ['when', 'mutate']) {
|
|
98
|
+
if (rule[key] !== undefined && typeof rule[key] !== 'function') {
|
|
99
|
+
throw new TypeError('Script rule option "' + key + '" must be a function');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const key of ['send', 'before', 'after']) {
|
|
103
|
+
const value = rule[key];
|
|
104
|
+
if (value !== undefined && typeof value !== 'string' && typeof value !== 'function' && !Buffer.isBuffer(value)) {
|
|
105
|
+
throw new TypeError('Script rule option "' + key + '" must be a string, a Buffer or a function');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (rule.close !== undefined && typeof rule.close !== 'boolean' && rule.close !== 'reset') {
|
|
109
|
+
throw new TypeError('Script rule option "close" must be true, false or "reset"');
|
|
110
|
+
}
|
|
111
|
+
if (rule.match === undefined) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
if (rule.match instanceof RegExp) {
|
|
115
|
+
// a global or sticky expression would keep its position between events
|
|
116
|
+
return new RegExp(rule.match.source, rule.match.flags.replace(/[gy]/g, ''));
|
|
117
|
+
}
|
|
118
|
+
if (typeof rule.match !== 'string') {
|
|
119
|
+
throw new TypeError('Script rule option "match" must be a string or a RegExp');
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
return new RegExp(rule.match);
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
throw new TypeError('Script rule option "match" is not a valid regular expression: ' + err.message, { cause: err });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The script of a server, `server.script`
|
|
130
|
+
*/
|
|
131
|
+
export class ServerScript {
|
|
132
|
+
constructor(server, rules) {
|
|
133
|
+
this.server = server;
|
|
134
|
+
this.entries = [];
|
|
135
|
+
this.watched = new Set();
|
|
136
|
+
if (rules) {
|
|
137
|
+
this.add(rules);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** the rules that were added, in the order they are checked */
|
|
141
|
+
get rules() {
|
|
142
|
+
return this.entries.slice();
|
|
143
|
+
}
|
|
144
|
+
add(rules) {
|
|
145
|
+
if (Array.isArray(rules)) {
|
|
146
|
+
// all rules are checked before any is added
|
|
147
|
+
const matches = rules.map(validateRule);
|
|
148
|
+
return rules.map((rule, i) => this.addEntry(rule, matches[i]));
|
|
149
|
+
}
|
|
150
|
+
return this.addEntry(rules, validateRule(rules));
|
|
151
|
+
}
|
|
152
|
+
/** removes every rule */
|
|
153
|
+
clear() {
|
|
154
|
+
this.setEntries([]);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Checks if any rule watches an event, so that the event can skip building a context
|
|
158
|
+
*
|
|
159
|
+
* @param {String} event Event name
|
|
160
|
+
* @return {Boolean} true if a rule watches the event
|
|
161
|
+
*/
|
|
162
|
+
watches(event) {
|
|
163
|
+
return this.watched.has(event);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Finds the rule that handles an event of a connection
|
|
167
|
+
*
|
|
168
|
+
* @param {Object} connection IMAP connection
|
|
169
|
+
* @param {String} event Event name
|
|
170
|
+
* @param {Object} fields `data`, and `tag`, `command`, `description`, `response` where they are known
|
|
171
|
+
* @return {Object|null} `{ rule, context }`, or null if no rule handles the event
|
|
172
|
+
*/
|
|
173
|
+
check(connection, event, fields) {
|
|
174
|
+
if (!this.watched.has(event)) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
const context = scriptContext(connection, event, fields);
|
|
178
|
+
const rule = this.find(context);
|
|
179
|
+
return rule ? { rule, context } : null;
|
|
180
|
+
}
|
|
181
|
+
setEntries(entries) {
|
|
182
|
+
this.entries = entries;
|
|
183
|
+
this.watched = new Set(entries.map(entry => entry.rule.on));
|
|
184
|
+
}
|
|
185
|
+
addEntry(rule, match) {
|
|
186
|
+
// a copy, so that changing the caller's object later does not change the rule
|
|
187
|
+
const copy = Object.freeze(Object.assign({}, rule));
|
|
188
|
+
const sets = {};
|
|
189
|
+
for (const key of SET_MATCHERS) {
|
|
190
|
+
if (copy[key] !== undefined) {
|
|
191
|
+
// command names are compared in upper case
|
|
192
|
+
const values = [].concat(copy[key]);
|
|
193
|
+
sets[key] = new Set(key === 'command' ? values.map(value => String(value).toUpperCase()) : values);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const entry = {
|
|
197
|
+
rule: copy,
|
|
198
|
+
match,
|
|
199
|
+
sets,
|
|
200
|
+
matched: 0,
|
|
201
|
+
hits: 0,
|
|
202
|
+
remove: () => this.setEntries(this.entries.filter(item => item !== entry))
|
|
203
|
+
};
|
|
204
|
+
this.setEntries(this.entries.concat(entry));
|
|
205
|
+
return entry;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Finds the rule that handles an event and counts it
|
|
209
|
+
*
|
|
210
|
+
* @param {Object} context Event context
|
|
211
|
+
* @return {Object|null} the rule, or null if no rule handles the event
|
|
212
|
+
*/
|
|
213
|
+
find(context) {
|
|
214
|
+
for (const entry of this.entries) {
|
|
215
|
+
const rule = entry.rule;
|
|
216
|
+
if (rule.on !== context.event || !this.matches(entry, context)) {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
entry.matched++;
|
|
220
|
+
if (entry.matched < (rule.nth || 1) || (rule.times !== undefined && entry.hits >= rule.times)) {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
entry.hits++;
|
|
224
|
+
this.server.emit('script', { rule, event: context.event, session: context.session, tag: context.tag, command: context.command });
|
|
225
|
+
return rule;
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
matches(entry, context) {
|
|
230
|
+
const rule = entry.rule;
|
|
231
|
+
for (const key of SET_MATCHERS) {
|
|
232
|
+
const set = entry.sets[key];
|
|
233
|
+
const value = context[key];
|
|
234
|
+
if (set && (value === null || value === undefined || !set.has(value))) {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (rule.tag !== undefined && (context.tag === null || (rule.tag instanceof RegExp ? !rule.tag.test(context.tag) : rule.tag !== context.tag))) {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
if (rule.untagged !== undefined && rule.untagged !== (!context.response || context.response.tag === '*')) {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
if (entry.match && !entry.match.test(context.data)) {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
return !rule.when || !!rule.when(context);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Builds the context of an event on a connection
|
|
252
|
+
*
|
|
253
|
+
* @param {Object} connection IMAP connection
|
|
254
|
+
* @param {String} event Event name
|
|
255
|
+
* @param {Object} fields `data`, and `tag`, `command`, `description`, `response` where they are known
|
|
256
|
+
* @return {Object} Event context
|
|
257
|
+
*/
|
|
258
|
+
function scriptContext(connection, event, fields) {
|
|
259
|
+
// after its handler returned, a command whose input handler reads the lines (IDLE, AUTHENTICATE) is the one
|
|
260
|
+
// the events belong to
|
|
261
|
+
const running = connection._runningCommand
|
|
262
|
+
? connection._runningCommand.parsed
|
|
263
|
+
: connection.inputHandler
|
|
264
|
+
? connection.inputCommand
|
|
265
|
+
: null;
|
|
266
|
+
return Object.assign({
|
|
267
|
+
event,
|
|
268
|
+
connection,
|
|
269
|
+
session: connection.sessionNumber,
|
|
270
|
+
state: connection.state,
|
|
271
|
+
user: connection.username || null,
|
|
272
|
+
mailbox: connection.selectedMailbox ? connection.selectedMailbox.path : null,
|
|
273
|
+
tag: running ? running.tag : null,
|
|
274
|
+
command: running ? String(running.command).toUpperCase() : null
|
|
275
|
+
}, fields);
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Sends output through the rule that handles it: the output itself (unless dropped or replaced with `send`)
|
|
279
|
+
* between `before` and `after`, cut by `truncate`, with the timing of `delay` and `chunk`
|
|
280
|
+
*
|
|
281
|
+
* @param {Object} connection IMAP connection
|
|
282
|
+
* @param {Object} rule Rule that handles the output
|
|
283
|
+
* @param {Object} context Event context
|
|
284
|
+
* @param {Buffer} output The output
|
|
285
|
+
*/
|
|
286
|
+
export function sendOutput(connection, rule, context, output) {
|
|
287
|
+
const replaced = rule.drop ? Buffer.alloc(0) : resolveBytes(rule.send, context) || output;
|
|
288
|
+
const parts = [resolveBytes(rule.before, context), replaced, resolveBytes(rule.after, context)].filter((part) => !!part);
|
|
289
|
+
sendBytes(connection, rule, Buffer.concat(parts), rule.delay);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Sends the bytes of a rule with its timing, `truncate` closes the connection after the cut
|
|
293
|
+
*
|
|
294
|
+
* @param {Object} connection IMAP connection
|
|
295
|
+
* @param {Object} rule Rule
|
|
296
|
+
* @param {Buffer} data Bytes to send
|
|
297
|
+
* @param {Number} [delay] Milliseconds to wait first
|
|
298
|
+
*/
|
|
299
|
+
function sendBytes(connection, rule, data, delay) {
|
|
300
|
+
connection.queueOutput({
|
|
301
|
+
data: rule.truncate === undefined ? data : data.subarray(0, rule.truncate),
|
|
302
|
+
delay,
|
|
303
|
+
chunk: rule.chunk,
|
|
304
|
+
chunkDelay: rule.chunkDelay === undefined ? DEFAULT_CHUNK_DELAY : rule.chunkDelay,
|
|
305
|
+
close: rule.close || (rule.truncate === undefined ? undefined : true)
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Handles a command or input line with the rule that matched it: sends the bytes of `send`, closes the connection,
|
|
310
|
+
* and processes the line as usual with `run` (or when the rule only delays it)
|
|
311
|
+
*
|
|
312
|
+
* @param {Object} connection IMAP connection
|
|
313
|
+
* @param {Object} rule Rule that matched
|
|
314
|
+
* @param {Object} context Event context
|
|
315
|
+
* @param {Function} run Processes the line as usual
|
|
316
|
+
*/
|
|
317
|
+
export function handleLine(connection, rule, context, run) {
|
|
318
|
+
const bytes = resolveBytes(rule.send, context);
|
|
319
|
+
if (bytes || rule.close) {
|
|
320
|
+
sendBytes(connection, rule, bytes || Buffer.alloc(0));
|
|
321
|
+
}
|
|
322
|
+
// a rule that only delays the line does not replace it
|
|
323
|
+
if (rule.run || (!rule.drop && !bytes && !rule.close)) {
|
|
324
|
+
run();
|
|
325
|
+
}
|
|
326
|
+
}
|
package/dist/esm/server.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ import net from 'node:net';
|
|
|
3
3
|
import tls from 'node:tls';
|
|
4
4
|
import type { CompilerOptions, ParserOptions } from 'imap-handler';
|
|
5
5
|
import type { ResolvedCommandOptions } from './command-states.js';
|
|
6
|
+
import { ServerScript } from './script.js';
|
|
7
|
+
import type { OutputOperation, ScriptContext, ScriptEvent, ScriptRule } from './script.js';
|
|
6
8
|
import type { AppendCheck, AppendCheckOptions, AppendDataHandler, AppendMessage, CapabilityCheck, CheckResult, ClosedCheck, CommandCheck, CommandContext, CommandOptions, CommandHandler, ConnectionHandler, ConnectionState, CopyHandler, FetchHandler, IMAPResponse, IMAPServerOptions, ListedMailbox, LiteralFilter, Mailbox, MailboxHandler, MailboxStatus, Message, MessageFilter, MessageHandler, MessageRange, Namespace, Notification, NotifyEvent, NotifyFilter, OutputHandler, ParsedCommand, RangeLimit, Refusal, SearchAccessCheck, SearchHandler, SearchLimit, ServerStorage, StatusHandler, StorageMailbox, StorageMessage, StoreHandler, SubscriptionStandIn, Transport, UrlAccessCheck, UserData } from './types.js';
|
|
7
9
|
declare const TAG_REGEX: RegExp;
|
|
8
10
|
/**
|
|
@@ -58,6 +60,10 @@ declare class IMAPServer extends Stream {
|
|
|
58
60
|
uidvalidityCounter: number;
|
|
59
61
|
subscriptions: Set<string>;
|
|
60
62
|
folderCache: Record<string, Mailbox>;
|
|
63
|
+
/** scripted faults, see src/script.ts */
|
|
64
|
+
script: ServerScript;
|
|
65
|
+
/** connections accepted so far, the number of a connection is `connection.sessionNumber` */
|
|
66
|
+
sessionCounter: number;
|
|
61
67
|
constructor(options?: IMAPServerOptions);
|
|
62
68
|
/**
|
|
63
69
|
* Starts accepting connections, takes the arguments of `net.Server#listen()`
|
|
@@ -446,6 +452,11 @@ declare class IMAPServer extends Stream {
|
|
|
446
452
|
interface QueuedCommand {
|
|
447
453
|
parsed: ParsedCommand;
|
|
448
454
|
data: string;
|
|
455
|
+
/** a command line that a script rule handles instead of the parser and the command handler */
|
|
456
|
+
script?: {
|
|
457
|
+
rule: ScriptRule;
|
|
458
|
+
context: ScriptContext;
|
|
459
|
+
} | undefined;
|
|
449
460
|
}
|
|
450
461
|
declare class IMAPConnection {
|
|
451
462
|
[key: string]: any;
|
|
@@ -471,6 +482,13 @@ declare class IMAPConnection {
|
|
|
471
482
|
/** notifications are sent right away instead of before the next tagged response (IDLE) */
|
|
472
483
|
directNotifications: boolean;
|
|
473
484
|
notificationQueue: Notification[];
|
|
485
|
+
/** the number of the connection, 1 for the first one the server accepted (script rules match it) */
|
|
486
|
+
sessionNumber: number;
|
|
487
|
+
/** the tag and name of the command whose input handler reads the lines that follow (IDLE, AUTHENTICATE) */
|
|
488
|
+
inputCommand: {
|
|
489
|
+
tag: string;
|
|
490
|
+
command: string;
|
|
491
|
+
} | null | undefined;
|
|
474
492
|
_remainder: string;
|
|
475
493
|
_command: string;
|
|
476
494
|
_literalRemaining: number;
|
|
@@ -488,6 +506,9 @@ declare class IMAPConnection {
|
|
|
488
506
|
command: string;
|
|
489
507
|
read: number | undefined;
|
|
490
508
|
} | undefined;
|
|
509
|
+
/** output that waits behind a delay of a script rule, null when output is written right away */
|
|
510
|
+
_outputQueue: OutputOperation[] | null;
|
|
511
|
+
_outputTimer: ReturnType<typeof setTimeout> | null;
|
|
491
512
|
constructor(server: IMAPServer, socket: net.Socket);
|
|
492
513
|
/**
|
|
493
514
|
* Writes protocol output to the client, through the transport layer if there is one
|
|
@@ -495,6 +516,45 @@ declare class IMAPConnection {
|
|
|
495
516
|
* @param {Buffer|String} data Data to send, a string is sent as a binary string
|
|
496
517
|
*/
|
|
497
518
|
write(data: Buffer | string): void;
|
|
519
|
+
/**
|
|
520
|
+
* Writes output, or puts it in the output queue while earlier output waits for a delay of a script rule.
|
|
521
|
+
* The transport layer is taken when the output is queued, so output from before COMPRESS is not compressed
|
|
522
|
+
*
|
|
523
|
+
* @param {Object} operation `{ data, delay, chunk, chunkDelay, close }`, see OutputOperation in src/script.ts
|
|
524
|
+
*/
|
|
525
|
+
queueOutput(operation: OutputOperation): void;
|
|
526
|
+
/**
|
|
527
|
+
* Writes the output queue until it is empty or a delay stops it
|
|
528
|
+
*/
|
|
529
|
+
flushOutput(): void;
|
|
530
|
+
/**
|
|
531
|
+
* Drops the output that waits for a delay of a script rule
|
|
532
|
+
*/
|
|
533
|
+
clearOutputQueue(): void;
|
|
534
|
+
/**
|
|
535
|
+
* Writes output through a transport layer, or to the socket
|
|
536
|
+
*
|
|
537
|
+
* @param {Buffer} data Output
|
|
538
|
+
* @param {Object|null} transport Transport layer
|
|
539
|
+
*/
|
|
540
|
+
writeLayer(data: Buffer, transport: Transport | null): void;
|
|
541
|
+
/**
|
|
542
|
+
* Sends output through the script rule that handles its event, if there is one
|
|
543
|
+
*
|
|
544
|
+
* @param {String} event Event name: greeting, response or continuation
|
|
545
|
+
* @param {String} output The output as a binary string
|
|
546
|
+
* @param {Object} fields Context fields of the event (tag, command, description, response)
|
|
547
|
+
* @param {Function} [compile] Compiles the response that a `mutate` action changed, null drops the output
|
|
548
|
+
*/
|
|
549
|
+
scriptOutput(event: ScriptEvent, output: string, fields: Partial<ScriptContext>, compile?: (response: IMAPResponse) => string | null): void;
|
|
550
|
+
/**
|
|
551
|
+
* Sends a continuation request, `+ text`
|
|
552
|
+
*
|
|
553
|
+
* @param {String} text Human readable text, can be empty (SASL)
|
|
554
|
+
* @param {String} description Description for script rules
|
|
555
|
+
* @param {Function} [getLine] Returns the command line received so far, for the tag and the command of a literal continuation
|
|
556
|
+
*/
|
|
557
|
+
sendContinuation(text: string, description: string, getLine?: () => string): void;
|
|
498
558
|
/**
|
|
499
559
|
* Writes data to the socket, below the transport layer
|
|
500
560
|
*
|
|
@@ -512,6 +572,12 @@ declare class IMAPConnection {
|
|
|
512
572
|
* holds, is written out
|
|
513
573
|
*/
|
|
514
574
|
end(): void;
|
|
575
|
+
/**
|
|
576
|
+
* Closes the connection now, after the output written so far
|
|
577
|
+
*
|
|
578
|
+
* @param {Boolean|String} mode true ends the connection gracefully, "reset" destroys the socket (script rules)
|
|
579
|
+
*/
|
|
580
|
+
closeNow(mode: boolean | 'reset'): void;
|
|
515
581
|
/**
|
|
516
582
|
* Sends an untagged BYE, drops unprocessed input and closes the connection (RFC 3501 section 7.1.5)
|
|
517
583
|
*
|
|
@@ -564,6 +630,12 @@ declare class IMAPConnection {
|
|
|
564
630
|
closeMailbox(): void;
|
|
565
631
|
onClose(): void;
|
|
566
632
|
onError(err: Error): void;
|
|
633
|
+
/**
|
|
634
|
+
* Passes a line to the input handler (IDLE, AUTHENTICATE), unless a script rule handles it
|
|
635
|
+
*
|
|
636
|
+
* @param {String} line Input line without CRLF
|
|
637
|
+
*/
|
|
638
|
+
handleInput(line: string): void;
|
|
567
639
|
onData(chunk: Buffer): void;
|
|
568
640
|
/**
|
|
569
641
|
* Reads literal data that the current command is waiting for. The data of a command that was
|
|
@@ -752,6 +824,13 @@ declare class IMAPConnection {
|
|
|
752
824
|
* FETCH result. (This may have other names when used, like "affected".)
|
|
753
825
|
*/
|
|
754
826
|
send(response: IMAPResponse, description?: string, parsed?: CommandContext | null, data?: string | null, ...extra: any[]): void;
|
|
827
|
+
/**
|
|
828
|
+
* Compiles a response for the wire
|
|
829
|
+
*
|
|
830
|
+
* @param {Object} response Response object
|
|
831
|
+
* @return {String|null} the response with its CRLF as a binary string, or null for an untagged response that does not compile
|
|
832
|
+
*/
|
|
833
|
+
compileResponse(response: IMAPResponse): string | null;
|
|
755
834
|
/**
|
|
756
835
|
* Sends a tagged status response to a command
|
|
757
836
|
*
|
|
@@ -848,7 +927,21 @@ declare class IMAPConnection {
|
|
|
848
927
|
* @return {String} Mailbox name as a binary string
|
|
849
928
|
*/
|
|
850
929
|
exportMailboxName(path: string): string;
|
|
851
|
-
|
|
930
|
+
/**
|
|
931
|
+
* Parses a command line and queues the command, or answers it right away when it can not run
|
|
932
|
+
*
|
|
933
|
+
* @param {String} data Command line with its literals, without the final CRLF
|
|
934
|
+
* @param {Boolean} [scripted] The line comes from a script rule with `run`, it is next in the queue
|
|
935
|
+
*/
|
|
936
|
+
scheduleCommand(data: string, scripted?: boolean): void;
|
|
937
|
+
/**
|
|
938
|
+
* Handles a command line with the script rule that matched it, after the rule's delay. With `run` the line
|
|
939
|
+
* goes through the parser and the command handler as usual afterwards
|
|
940
|
+
*
|
|
941
|
+
* @param {Object} element Queued command with the rule
|
|
942
|
+
* @param {Function} next Releases the queue
|
|
943
|
+
*/
|
|
944
|
+
runScriptedCommand(element: QueuedCommand, next: () => void): void;
|
|
852
945
|
processQueue(force?: boolean): void;
|
|
853
946
|
/**
|
|
854
947
|
* Removes messages with \Deleted flag
|