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 CHANGED
@@ -65,7 +65,7 @@ ImapKit is a single user, multiple connection IMAP server. Changes made over IMA
65
65
 
66
66
  Several clients can connect to the server simultaneously but all the clients share the same user account, even if login credentials are different. The ACL plugin can limit what users other than the owner can do (see [ACL](#acl)).
67
67
 
68
- ImapKit is extendable: any command can be overridden and plugins can be added (see [Creating custom plugins](#creating-custom-plugins), and `src/commands` and `src/plugins` for the built-in commands and plugins).
68
+ ImapKit is extendable: any command can be overridden and plugins can be added (see [Creating custom plugins](#creating-custom-plugins), and `src/commands` and `src/plugins` for the built-in commands and plugins). To test a client against a broken or unusual server, [script rules](#scripted-faults) make the server misbehave at chosen points.
69
69
 
70
70
  ## Strict by design
71
71
 
@@ -395,6 +395,92 @@ describe('IMAP tests', () => {
395
395
  });
396
396
  ```
397
397
 
398
+ ## Scripted faults
399
+
400
+ ImapKit is strict and correct by default. To test how a client copes with a server that is not, script rules make the server deviate from the protocol at chosen points: answer a command with a canned response, send a literal where a quoted string is expected, cut a response in the middle of a literal, delay or split output, or drop the connection. Rules come from the `script` server option (a rule or a list of rules), or are added at runtime with `server.script.add()`:
401
+
402
+ ```javascript
403
+ const server = imapkit({
404
+ plugins: ['IDLE'],
405
+ script: [
406
+ // the first SELECT gets NO, the next ones run as usual
407
+ { on: 'command', command: 'SELECT', times: 1, send: '$TAG NO [UNAVAILABLE] Try again later\r\n' },
408
+ // the body of message 1 is cut short and the connection dropped
409
+ { on: 'response', command: 'FETCH', match: /^\* 1 FETCH .*BODY\[\]/, truncate: 40 }
410
+ ]
411
+ });
412
+
413
+ // a rule added later, it returns a handle
414
+ const rule = server.script.add({
415
+ on: 'response',
416
+ command: 'FETCH',
417
+ untagged: true,
418
+ // strings in the response tree become literals, valid IMAP that a client must handle
419
+ mutate: response => {
420
+ const walk = list =>
421
+ list.forEach((node, i) => (Array.isArray(node) ? walk(node) : typeof node === 'string' && (list[i] = { type: 'LITERAL', value: node })));
422
+ walk(response.attributes);
423
+ }
424
+ });
425
+ // ... run the client
426
+ assert.strictEqual(rule.hits, 1);
427
+ rule.remove();
428
+ ```
429
+
430
+ Every rule watches one event (`on`):
431
+
432
+ - `greeting`: the `* OK` greeting of a new connection
433
+ - `command`: a complete command line (with its literals) from the client. The rule acts instead of the parser and the command handler, so it also matches lines that do not parse and commands that do not exist. The rule is chosen when the line arrives (matchers like `state` see the session at that moment), and acts in the order of the commands, so pipelined responses stay in order
434
+ - `input`: a line read by a command that takes over the input, like `DONE` of IDLE or a SASL response of AUTHENTICATE
435
+ - `response`: every response the server sends with `connection.send()`, tagged and untagged, as the exact bytes that are about to go out, after every plugin and the core changed the response
436
+ - `continuation`: a `+` continuation request (literals, IDLE, AUTHENTICATE)
437
+
438
+ The matchers of a rule all have to match. Rules are checked in the order they were added, the first rule that matches and is not used up handles the event, so a later rule can handle what an earlier one leaves alone.
439
+
440
+ | Matcher | Events | Matches |
441
+ | -------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
442
+ | `command` | all but greeting | the command name, or a list of names, case-insensitive (`'UID FETCH'`). An unsolicited response belongs to the command that runs, or that reads input (IDLE) |
443
+ | `tag` | all but greeting | the command tag, a string or a RegExp |
444
+ | `description` | response, continuation | the description passed to `connection.send()`, or a list of them. Continuation requests are `LITERAL`, `IDLE`, `AUTHENTICATE PLAIN` and `AUTHENTICATE OAUTHBEARER` |
445
+ | `untagged` | response | `true` for untagged responses only, `false` for tagged ones |
446
+ | `session` | all | the number of the connection, or a list of numbers, 1 for the first connection the server accepted |
447
+ | `state`, `user`, `mailbox` | all | the session state (`'Not Authenticated'`, `'Authenticated'`, `'Selected'`), the authenticated user, the path of the selected mailbox |
448
+ | `match` | all | a RegExp, or a string with a regular expression, tested against the command line or the output bytes (a binary string) |
449
+ | `when` | all | a function that gets the event context and returns true to match |
450
+ | `nth` | all | the rule fires from the nth matching event on (default 1) |
451
+ | `times` | all | the rule fires this many times at most, then lets later rules handle the event |
452
+
453
+ 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
+
455
+ | Action | Events | Effect |
456
+ | --------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
457
+ | `send` | all | output events: bytes sent instead of the output. command and input: bytes sent instead of processing the line |
458
+ | `run` | command, input | process the line as usual after `send` (to add output before the real response) |
459
+ | `drop` | all | output events: send nothing. command and input: ignore the line, the client gets no answer |
460
+ | `mutate` | response, continuation | `(response, context)` gets a copy of the response object before it is compiled and changes it, or returns another one. Continuation requests have a response object only when a plugin sends them with `connection.send()` (the error challenges of XOAUTH2 and OAUTHBEARER), other events ignore `mutate`. A tagged response that does not compile then is sent as `NO [SERVERBUG]`, an untagged one is dropped. Use `send` for output that is not valid IMAP |
461
+ | `before`, `after` | output events | bytes sent before or after the output, e.g. an unsolicited response |
462
+ | `delay` | all but input | milliseconds to wait before the output goes out, all later output waits behind it. For a command, the wait before the rule acts or the command runs, later commands wait too |
463
+ | `chunk`, `chunkDelay` | all | write the bytes in pieces of `chunk` octets, `chunkDelay` milliseconds apart (default 10) |
464
+ | `truncate` | all | send only this many octets of the bytes, then close the connection |
465
+ | `close` | all | close the connection after the bytes are sent, `'reset'` destroys the socket instead (a TCP RST where the runtime supports it) |
466
+
467
+ 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
+
469
+ The event context, which `when`, `mutate` and `send` functions get, has `event`, `connection`, `session`, `state`, `user`, `mailbox`, `tag`, `command`, `data` (the command line or the output bytes, as a binary string), and for responses `description` and `response`.
470
+
471
+ `server.script.add(rule)` returns a handle with `rule`, `matched` (events that matched the rule, also before `nth`), `hits` (events the rule handled) and `remove()`, `server.script.add([rules])` returns a list of handles. `server.script.rules` lists the handles in order, `server.script.clear()` removes every rule. The server emits a `script` event `{ rule, event, session, tag, command }` every time a rule fires.
472
+
473
+ The `imapkit` command takes the rules as JSON with `--script=<path>` (or `IMAPKIT_SCRIPT`), or as `script` in the `--config` file. JSON rules use strings for `match` and `send`, functions (`when`, `mutate`, function values of `send`) work only from JavaScript:
474
+
475
+ ```json
476
+ [
477
+ { "on": "greeting", "send": "* BYE Too many connections\r\n", "close": true, "times": 1 },
478
+ { "on": "response", "command": "FETCH", "untagged": true, "send": "* 1 FETCH (BODY[] {100}\r\nshort", "close": true }
479
+ ]
480
+ ```
481
+
482
+ Faults change only the output and the handling of the lines a rule matches, the state of the server stays consistent: a LOGIN answered by a rule with `OK` does not log the session in, and a dropped EXPUNGE response still removes the message. COMPRESS works with delayed output, as the output keeps the compression layer it was sent with. Script rules are for tests only, a rule can send anything.
483
+
398
484
  ## Creating custom plugins
399
485
 
400
486
  A plugin can be a string as a pointer to a built in plugin or a function. Plugin function is run when the server is created and gets server instance object as an argument.
@@ -589,6 +675,8 @@ server.resetHandlers.push(function (connection) {
589
675
 
590
676
  Any response sent to the client can be overridden or cancelled by other handlers. You should append your handler to `server.outputHandlers` array. If something is being sent to the client, the response object is passed through all handlers in this array.
591
677
 
678
+ Output handlers are meant for extensions that change valid responses. They run before the core finishes the response (response codes like `EXPUNGEISSUED`, mailbox names, the 7-bit status text) and before the compiler, which refuses output that is not valid IMAP. To make the server send something wrong on purpose, use [script rules](#scripted-faults) instead, they see the final bytes.
679
+
592
680
  server.outputHandlers.push(function(connection, /* arguments from connection.send */){})
593
681
 
594
682
  `response` arguments from `connection.send` is an object and thus any modifications will be passed on. If `skipResponse` property is added to the response object, the data is not sent to the client.
package/bin/help.txt CHANGED
@@ -16,6 +16,9 @@ Usage: imapkit [OPTS]
16
16
  -d, --debug=true Writes IMAP traffic to console
17
17
  --storage=<path> Path to JSON file with the directory tree
18
18
  --config=<config> Path to config JSON file
19
+ --script=<path> Path to a JSON file with script rules that
20
+ make the server misbehave on purpose (see
21
+ "Scripted faults" below)
19
22
  --plugin=<plugin> Enables a plugin for the server. See below for
20
23
  available plugins.
21
24
  --smtpPort=<port> Port number for incoming SMTP server. If not set
@@ -23,7 +26,7 @@ Usage: imapkit [OPTS]
23
26
 
24
27
  The options can also be set with environment variables: IMAPKIT_PORT,
25
28
  IMAPKIT_SECURE, IMAPKIT_DEBUG, IMAPKIT_STORAGE, IMAPKIT_CONFIG,
26
- IMAPKIT_PLUGINS (comma separated) and IMAPKIT_SMTPPORT.
29
+ IMAPKIT_SCRIPT, IMAPKIT_PLUGINS (comma separated) and IMAPKIT_SMTPPORT.
27
30
 
28
31
  NB! If port or smtpPort values are below 1024 you most probably need to
29
32
  use sudo or run the command with administrator rights.
@@ -61,6 +64,29 @@ Configuration file takes the following structure
61
64
  }
62
65
  }
63
66
 
67
+ Scripted faults
68
+ ---------------
69
+
70
+ A script file holds a list of rules. Each rule watches one event
71
+ ("greeting", "command", "input", "response" or "continuation") and
72
+ changes what the server does when its matchers fit. For example, this
73
+ script answers the first FETCH with a literal that is too short and
74
+ then drops the connection:
75
+
76
+ [
77
+ {
78
+ "on": "response",
79
+ "command": "FETCH",
80
+ "untagged": true,
81
+ "times": 1,
82
+ "send": "* 1 FETCH (BODY[] {100}\r\nshort",
83
+ "close": true
84
+ }
85
+ ]
86
+
87
+ Strings are sent as they are, "$TAG" stands for the command tag and
88
+ "match" is a regular expression. See the README for every option.
89
+
64
90
  Storage file is a tree like structure starting with namespace values.
65
91
  INBOX has its own namespace.
66
92
 
package/bin/imapkit.js CHANGED
@@ -19,6 +19,7 @@ const { values: argv } = parseArgs({
19
19
  help: { type: 'boolean', short: 'h' },
20
20
  config: { type: 'string' },
21
21
  storage: { type: 'string' },
22
+ script: { type: 'string' },
22
23
  plugin: { type: 'string', multiple: true },
23
24
  smtpPort: { type: 'string' }
24
25
  }
@@ -28,6 +29,7 @@ const isTrue = value => (value || '').toString().trim().toLowerCase() === 'true'
28
29
 
29
30
  const configLocation = argv.config || process.env.IMAPKIT_CONFIG;
30
31
  const storageLocation = argv.storage || process.env.IMAPKIT_STORAGE;
32
+ const scriptLocation = argv.script || process.env.IMAPKIT_SCRIPT;
31
33
  const smtpPort = argv.smtpPort || process.env.IMAPKIT_SMTPPORT;
32
34
  const pluginsList = []
33
35
  .concat(argv.plugin || process.env.IMAPKIT_PLUGINS || [])
@@ -51,6 +53,10 @@ if (storageLocation) {
51
53
  config.storage = JSON.parse(fs.readFileSync(storageLocation, 'utf-8'));
52
54
  }
53
55
 
56
+ if (scriptLocation) {
57
+ config.script = JSON.parse(fs.readFileSync(scriptLocation, 'utf-8'));
58
+ }
59
+
54
60
  if (pluginsList.length) {
55
61
  config.plugins = pluginsList;
56
62
  }
@@ -1,6 +1,7 @@
1
1
  import createServer, { TAG_REGEX, IMAPServer, IMAPConnection } from './server.js';
2
2
  export { TAG_REGEX, IMAPServer, IMAPConnection };
3
3
  export type { Attribute, ParsedCommand, IMAPResponse, Notification, Callback, CommandHandler, CommandOptions, Plugin, IMAPError, Message, Mailbox, StorageNamespace, UserData, IMAPServerOptions } from './types.js';
4
+ export type { ScriptRule, ScriptContext, ScriptEvent, ScriptBytes, ScriptHandle } from './script.js';
4
5
  declare const imapkit: typeof createServer & {
5
6
  TAG_REGEX: RegExp;
6
7
  IMAPServer: typeof IMAPServer;
@@ -57,7 +57,7 @@ function authPlainPlugin(server) {
57
57
  authenticate(connection, parsed, data, str);
58
58
  };
59
59
  // Send an empty continuation request to the client
60
- connection.write('+ \r\n');
60
+ connection.sendContinuation('', 'AUTHENTICATE PLAIN');
61
61
  }
62
62
  else if (parsed.attributes.length === 1 &&
63
63
  // second argument must be Base64 string as ATOM
@@ -66,7 +66,7 @@ function idlePlugin(server) {
66
66
  }, 'INVALID IDLE', parsed, data);
67
67
  }
68
68
  };
69
- connection.write('+ idling\r\n');
69
+ connection.sendContinuation('idling', 'IDLE');
70
70
  connection.processNotifications();
71
71
  return callback();
72
72
  }, { states: command_states_js_1.states.AUTHENTICATED, noArguments: true });
@@ -169,7 +169,7 @@ function oauthbearerPlugin(server) {
169
169
  if (!args.length) {
170
170
  // without an initial response the client sends its response after an empty challenge
171
171
  readResponse(connection, parsed, data, decoded => authenticate(connection, parsed, data, decoded));
172
- connection.write('+ \r\n');
172
+ connection.sendContinuation('', 'AUTHENTICATE OAUTHBEARER');
173
173
  return callback();
174
174
  }
175
175
  if (args.length !== 1 || !args[0] || args[0].type !== 'ATOM') {
@@ -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 {};