imapkit 4.3.0 → 5.0.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.
Files changed (93) hide show
  1. package/README.md +167 -51
  2. package/bin/help.txt +17 -3
  3. package/bin/imapkit.js +53 -23
  4. package/dist/cjs/commands/append.js +1 -1
  5. package/dist/cjs/commands/create.js +2 -2
  6. package/dist/cjs/commands/delete.js +2 -14
  7. package/dist/cjs/commands/handlers/flags.d.ts +3 -3
  8. package/dist/cjs/commands/handlers/flags.js +3 -3
  9. package/dist/cjs/commands/handlers/store.js +3 -3
  10. package/dist/cjs/commands/rename.js +6 -46
  11. package/dist/cjs/commands/select.js +1 -0
  12. package/dist/cjs/commands/subscribe.js +7 -8
  13. package/dist/cjs/commands/unsubscribe.js +3 -9
  14. package/dist/cjs/control.d.ts +430 -0
  15. package/dist/cjs/control.js +637 -0
  16. package/dist/cjs/index.d.ts +8 -1
  17. package/dist/cjs/index.js +9 -2
  18. package/dist/cjs/load-plugins.d.ts +1 -1
  19. package/dist/cjs/load-plugins.js +7 -2
  20. package/dist/cjs/plugins/acl.js +109 -42
  21. package/dist/cjs/plugins/condstore.js +7 -0
  22. package/dist/cjs/plugins/index.js +1 -3
  23. package/dist/cjs/plugins/metadata.js +140 -57
  24. package/dist/cjs/plugins/objectid.js +10 -0
  25. package/dist/cjs/plugins/quota.js +50 -2
  26. package/dist/cjs/plugins/savedate.js +1 -1
  27. package/dist/cjs/plugins/special-use.d.ts +0 -8
  28. package/dist/cjs/plugins/special-use.js +23 -0
  29. package/dist/cjs/quirks.d.ts +28 -0
  30. package/dist/cjs/quirks.js +124 -0
  31. package/dist/cjs/random.d.ts +10 -0
  32. package/dist/cjs/random.js +22 -0
  33. package/dist/cjs/rest.d.ts +50 -0
  34. package/dist/cjs/rest.js +561 -0
  35. package/dist/cjs/script.d.ts +33 -4
  36. package/dist/cjs/script.js +64 -4
  37. package/dist/cjs/server.d.ts +149 -8
  38. package/dist/cjs/server.js +429 -152
  39. package/dist/cjs/smtp-listener.d.ts +3 -2
  40. package/dist/cjs/smtp-listener.js +10 -6
  41. package/dist/cjs/storage-schema.d.ts +139 -0
  42. package/dist/cjs/storage-schema.js +215 -0
  43. package/dist/cjs/store-operations.d.ts +168 -0
  44. package/dist/cjs/store-operations.js +422 -0
  45. package/dist/cjs/types.d.ts +22 -1
  46. package/dist/esm/commands/append.js +1 -1
  47. package/dist/esm/commands/create.js +2 -2
  48. package/dist/esm/commands/delete.js +2 -14
  49. package/dist/esm/commands/handlers/flags.d.ts +3 -3
  50. package/dist/esm/commands/handlers/flags.js +3 -3
  51. package/dist/esm/commands/handlers/store.js +3 -3
  52. package/dist/esm/commands/rename.js +6 -46
  53. package/dist/esm/commands/select.js +1 -0
  54. package/dist/esm/commands/subscribe.js +7 -8
  55. package/dist/esm/commands/unsubscribe.js +3 -9
  56. package/dist/esm/control.d.ts +430 -0
  57. package/dist/esm/control.js +630 -0
  58. package/dist/esm/index.d.ts +8 -1
  59. package/dist/esm/index.js +5 -2
  60. package/dist/esm/load-plugins.d.ts +1 -1
  61. package/dist/esm/load-plugins.js +7 -2
  62. package/dist/esm/plugins/acl.js +109 -42
  63. package/dist/esm/plugins/condstore.js +7 -0
  64. package/dist/esm/plugins/index.js +1 -3
  65. package/dist/esm/plugins/metadata.js +140 -57
  66. package/dist/esm/plugins/objectid.js +10 -0
  67. package/dist/esm/plugins/quota.js +50 -2
  68. package/dist/esm/plugins/savedate.js +1 -1
  69. package/dist/esm/plugins/special-use.d.ts +0 -8
  70. package/dist/esm/plugins/special-use.js +23 -0
  71. package/dist/esm/quirks.d.ts +28 -0
  72. package/dist/esm/quirks.js +120 -0
  73. package/dist/esm/random.d.ts +10 -0
  74. package/dist/esm/random.js +19 -0
  75. package/dist/esm/rest.d.ts +50 -0
  76. package/dist/esm/rest.js +553 -0
  77. package/dist/esm/script.d.ts +33 -4
  78. package/dist/esm/script.js +63 -4
  79. package/dist/esm/server.d.ts +149 -8
  80. package/dist/esm/server.js +429 -152
  81. package/dist/esm/smtp-listener.d.ts +3 -2
  82. package/dist/esm/smtp-listener.js +10 -6
  83. package/dist/esm/storage-schema.d.ts +139 -0
  84. package/dist/esm/storage-schema.js +211 -0
  85. package/dist/esm/store-operations.d.ts +168 -0
  86. package/dist/esm/store-operations.js +407 -0
  87. package/dist/esm/types.d.ts +22 -1
  88. package/dist/plugin-help.json +0 -14
  89. package/package.json +11 -3
  90. package/dist/cjs/plugins/xtoybird.d.ts +0 -2
  91. package/dist/cjs/plugins/xtoybird.js +0 -236
  92. package/dist/esm/plugins/xtoybird.d.ts +0 -2
  93. package/dist/esm/plugins/xtoybird.js +0 -218
@@ -0,0 +1,561 @@
1
+ "use strict";
2
+ /**
3
+ * The REST API: the control API over HTTP, for tests written in any language. Off unless the `rest` option (or
4
+ * `--rest-port`) turns it on. It is full control over the server, including the mail of every user, so it listens
5
+ * on 127.0.0.1 by default, needs a bearer token on any other address, sends no CORS headers, takes JSON bodies
6
+ * only (a browser can not send them cross-origin without a CORS preflight) and, without a token, only answers
7
+ * requests for a loopback host name (DNS rebinding). Built on node:http only.
8
+ */
9
+ var __importDefault = (this && this.__importDefault) || function (mod) {
10
+ return (mod && mod.__esModule) ? mod : { "default": mod };
11
+ };
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.createRestServer = createRestServer;
14
+ exports.isLoopback = isLoopback;
15
+ exports.getRoutes = getRoutes;
16
+ exports.openApi = openApi;
17
+ const node_http_1 = __importDefault(require("node:http"));
18
+ const node_crypto_1 = __importDefault(require("node:crypto"));
19
+ const store_operations_js_1 = require("./store-operations.js");
20
+ // the largest literal a client can send is 64 MiB as well, see IMAPConnection#getMaxLiteralSize
21
+ const BODY_LIMIT = 64 * 1024 * 1024;
22
+ const STATUS_CODES = {
23
+ NONEXISTENT: 404,
24
+ NOTFOUND: 404,
25
+ ALREADYEXISTS: 409,
26
+ INVALID: 400,
27
+ UNAUTHORIZED: 401,
28
+ METHOD: 405,
29
+ TOOBIG: 413,
30
+ UNSUPPORTED: 415
31
+ };
32
+ /**
33
+ * Checks if a host name or address is a loopback one
34
+ *
35
+ * @param {String} host Host name or address, can be in brackets
36
+ * @return {Boolean} true for localhost, 127.0.0.0/8 and ::1
37
+ */
38
+ function isLoopback(host) {
39
+ const name = host.replace(/^\[|\]$/g, '').toLowerCase();
40
+ return name === 'localhost' || name === '::1' || /^(::ffff:)?127\.\d+\.\d+\.\d+$/.test(name);
41
+ }
42
+ /**
43
+ * Turns a UID path or query value into a number, the control API checks the rest
44
+ *
45
+ * @param {String} value Value from the URL
46
+ * @return {Number} UID, NaN for anything that is not digits
47
+ */
48
+ const toUid = (value) => (/^\d+$/.test(value) ? Number(value) : NaN);
49
+ /**
50
+ * A message for JSON: the source is base64
51
+ *
52
+ * @param {Object} message Message info of the control API
53
+ * @return {Object} message with `raw` in base64
54
+ */
55
+ function messageJson(message) {
56
+ const { raw, ...rest } = message;
57
+ return raw ? Object.assign(rest, { raw: raw.toString('base64'), encoding: 'base64' }) : rest;
58
+ }
59
+ /**
60
+ * Decodes binary data of a request body, text unless the encoding is "base64"
61
+ *
62
+ * @param {*} value Value from the body
63
+ * @param {*} encoding "utf-8" (default) or "base64"
64
+ * @return {Buffer|*} data, values that are not strings are left to the control API to refuse
65
+ */
66
+ function decodeBody(value, encoding) {
67
+ if (encoding !== undefined && encoding !== 'base64' && encoding !== 'utf-8') {
68
+ throw (0, store_operations_js_1.storeError)('encoding must be "utf-8" or "base64"', 'INVALID');
69
+ }
70
+ return encoding === 'base64' && typeof value === 'string' ? Buffer.from(value, 'base64') : value;
71
+ }
72
+ // the server events that GET /v1/events streams
73
+ const EVENT_TYPES = ['session', 'command', 'mailbox', 'expunge', 'flags', 'acl', 'script', 'reset'];
74
+ const sessionNumber = (origin) => (origin ? origin.sessionNumber : null);
75
+ /**
76
+ * Turns the arguments of a server event into JSON data: mailboxes become paths, messages UIDs, sessions numbers
77
+ *
78
+ * @param {String} type Event name
79
+ * @param {Array} args Event arguments
80
+ * @return {Object} event data
81
+ */
82
+ function eventData(type, args) {
83
+ switch (type) {
84
+ case 'mailbox': {
85
+ const { type: change, path, oldPath, origin } = args[0];
86
+ return { type: change, path, oldPath, origin: sessionNumber(origin) };
87
+ }
88
+ case 'expunge':
89
+ return { path: args[0].path, uids: args[1].map(message => message.uid), origin: sessionNumber(args[2]) };
90
+ case 'flags':
91
+ return {
92
+ path: args[0].path,
93
+ messages: args[1].map(message => ({ uid: message.uid, flags: message.flags.slice() })),
94
+ origin: sessionNumber(args[2])
95
+ };
96
+ case 'acl':
97
+ return { path: args[0].path };
98
+ default:
99
+ // session, command and script events are plain data already, reset has none
100
+ return args[0] || {};
101
+ }
102
+ }
103
+ /**
104
+ * Streams server events as Server-Sent Events, `event: <type>` with the JSON data, until the client goes away
105
+ *
106
+ * @param {Object} server IMAP server
107
+ * @param {Array} types Event types to send
108
+ * @return {Function} writes the stream to a response
109
+ */
110
+ function eventStream(server, types) {
111
+ return (res, req) => {
112
+ res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store' });
113
+ res.write(': connected\n\n');
114
+ const listeners = types.map(type => {
115
+ const listener = (...args) => res.write('event: ' + type + '\ndata: ' + JSON.stringify(eventData(type, args)) + '\n\n');
116
+ server.on(type, listener);
117
+ return { type, listener };
118
+ });
119
+ // a comment now and then keeps proxies from closing an idle stream
120
+ const ping = setInterval(() => res.write(': ping\n\n'), 15000);
121
+ ping.unref();
122
+ req.on('close', () => {
123
+ clearInterval(ping);
124
+ listeners.forEach(({ type, listener }) => server.removeListener(type, listener));
125
+ });
126
+ };
127
+ }
128
+ /**
129
+ * Builds the route table of a server
130
+ *
131
+ * @param {Object} server IMAP server
132
+ * @return {Array} routes
133
+ */
134
+ function getRoutes(server) {
135
+ const control = server.control;
136
+ const ruleJson = (handle) => ({ id: handle.id, rule: handle.rule, matched: handle.matched, hits: handle.hits });
137
+ const routes = [
138
+ { method: 'GET', path: '/v1/snapshot', summary: 'The storage in the shape of the storage option', handler: () => control.snapshot() },
139
+ {
140
+ method: 'POST',
141
+ path: '/v1/reset',
142
+ summary: 'Restores the mailboxes and users of the server options, disconnects every session',
143
+ handler: () => {
144
+ control.reset();
145
+ return { reset: true };
146
+ }
147
+ },
148
+ {
149
+ method: 'POST',
150
+ path: '/v1/shutdown',
151
+ summary: 'Stops accepting connections, the server closes once the last client is gone ({ graceful: false } closes them)',
152
+ handler: ({ body }) => {
153
+ // the answer goes out first
154
+ setImmediate(() => control.shutdown({ graceful: body.graceful !== false }).catch(() => false));
155
+ return { status: 202, body: { shutdown: true } };
156
+ }
157
+ },
158
+ { method: 'GET', path: '/v1/sessions', summary: 'Connected sessions', handler: () => control.sessions() },
159
+ {
160
+ method: 'GET',
161
+ path: '/v1/events',
162
+ summary: 'Server events as Server-Sent Events (?types=session,command to choose: ' + EVENT_TYPES.join(', ') + ')',
163
+ handler: ({ query }) => {
164
+ const wanted = query.get('types');
165
+ const types = wanted === null ? EVENT_TYPES : wanted.split(',');
166
+ const unknown = types.filter(type => EVENT_TYPES.indexOf(type) < 0);
167
+ if (unknown.length) {
168
+ throw (0, store_operations_js_1.storeError)('Unknown event type ' + unknown.join(', ') + ', expected ' + EVENT_TYPES.join(', '), 'INVALID');
169
+ }
170
+ return { stream: eventStream(server, types) };
171
+ }
172
+ },
173
+ {
174
+ method: 'DELETE',
175
+ path: '/v1/sessions/{session}',
176
+ summary: 'Disconnects a session with BYE ({ text }) or a TCP reset ({ reset: true })',
177
+ handler: ({ params, body }) => {
178
+ if (!control.disconnect(toUid(params.session), { text: body.text, reset: body.reset })) {
179
+ throw (0, store_operations_js_1.storeError)('Session ' + params.session + ' does not exist', 'NONEXISTENT');
180
+ }
181
+ return { disconnected: true };
182
+ }
183
+ },
184
+ {
185
+ method: 'POST',
186
+ path: '/v1/sessions/{session}/inject',
187
+ summary: 'Writes bytes to a session as they are ({ data, encoding })',
188
+ handler: ({ params, body }) => {
189
+ control.inject(toUid(params.session), body.encoding === 'base64' && typeof body.data === 'string' ? Buffer.from(body.data, 'base64') : body.data);
190
+ return { injected: true };
191
+ }
192
+ },
193
+ { method: 'GET', path: '/v1/users', summary: 'Users, without credentials', handler: () => control.listUsers() },
194
+ {
195
+ method: 'POST',
196
+ path: '/v1/users',
197
+ summary: 'Adds a user ({ name, password, xoauth2 })',
198
+ handler: ({ body }) => {
199
+ control.addUser(body.name, { password: body.password, xoauth2: body.xoauth2 });
200
+ return { status: 201, body: { name: body.name } };
201
+ }
202
+ },
203
+ {
204
+ method: 'PUT',
205
+ path: '/v1/users/{name}',
206
+ summary: 'Changes the password or the XOAUTH2 token of a user',
207
+ handler: ({ params, body }) => {
208
+ control.updateUser(params.name, { password: body.password, xoauth2: body.xoauth2 });
209
+ return { name: params.name };
210
+ }
211
+ },
212
+ {
213
+ method: 'DELETE',
214
+ path: '/v1/users/{name}',
215
+ summary: 'Deletes a user and disconnects its sessions ({ disconnect: false } keeps them)',
216
+ handler: ({ params, body }) => {
217
+ control.deleteUser(params.name, { disconnect: body.disconnect });
218
+ return { deleted: true };
219
+ }
220
+ },
221
+ { method: 'GET', path: '/v1/mailboxes', summary: 'Every mailbox', handler: () => control.listMailboxes() },
222
+ {
223
+ method: 'POST',
224
+ path: '/v1/mailboxes',
225
+ summary: 'Creates a mailbox ({ path, subscribed })',
226
+ handler: ({ body }) => ({ status: 201, body: control.createMailbox(body.path, { subscribed: body.subscribed }) })
227
+ },
228
+ { method: 'GET', path: '/v1/mailboxes/{path}', summary: 'A mailbox', handler: ({ params }) => control.getMailbox(params.path) },
229
+ {
230
+ method: 'DELETE',
231
+ path: '/v1/mailboxes/{path}',
232
+ summary: 'Deletes a mailbox, sessions that have it selected get BYE',
233
+ handler: ({ params }) => {
234
+ control.deleteMailbox(params.path);
235
+ return { deleted: true };
236
+ }
237
+ },
238
+ {
239
+ method: 'POST',
240
+ path: '/v1/mailboxes/{path}/rename',
241
+ summary: 'Renames a mailbox ({ newPath })',
242
+ handler: ({ params, body }) => control.renameMailbox(params.path, body.newPath)
243
+ },
244
+ {
245
+ method: 'PUT',
246
+ path: '/v1/mailboxes/{path}/subscription',
247
+ summary: 'Subscribes a mailbox',
248
+ handler: ({ params }) => ({ changed: control.subscribe(params.path) })
249
+ },
250
+ {
251
+ method: 'DELETE',
252
+ path: '/v1/mailboxes/{path}/subscription',
253
+ summary: 'Unsubscribes a name',
254
+ handler: ({ params }) => ({ changed: control.unsubscribe(params.path) })
255
+ },
256
+ {
257
+ method: 'POST',
258
+ path: '/v1/mailboxes/{path}/uidvalidity',
259
+ summary: 'Gives the mailbox a new UIDVALIDITY ({ uidvalidity, uids, offset, seed })',
260
+ handler: ({ params, body }) => control.resetUidValidity(params.path, body)
261
+ },
262
+ {
263
+ method: 'GET',
264
+ path: '/v1/mailboxes/{path}/messages',
265
+ summary: 'Messages (?uids=1,2 to choose, ?raw=true for the base64 source)',
266
+ handler: ({ params, query }) => {
267
+ const uids = query.get('uids');
268
+ return control
269
+ .listMessages(params.path, { uids: uids === null ? undefined : uids.split(',').map(toUid), raw: query.get('raw') === 'true' })
270
+ .map(messageJson);
271
+ }
272
+ },
273
+ {
274
+ method: 'POST',
275
+ path: '/v1/mailboxes/{path}/messages',
276
+ summary: 'Adds a message ({ raw, encoding, flags, internaldate, checks }), like a delivery, checks: true refuses it like APPEND would (quota)',
277
+ handler: ({ params, body }) => ({
278
+ status: 201,
279
+ body: control.addMessage(params.path, {
280
+ raw: decodeBody(body.raw, body.encoding),
281
+ flags: body.flags,
282
+ internaldate: body.internaldate
283
+ }, { checks: body.checks })
284
+ })
285
+ },
286
+ {
287
+ method: 'POST',
288
+ path: '/v1/mailboxes/{path}/messages/flags',
289
+ summary: 'Changes flags ({ uids, flags, mode }), mode is set, add or remove',
290
+ handler: ({ params, body }) => control.setFlags(params.path, body.uids, body.flags, body.mode || 'set')
291
+ },
292
+ {
293
+ method: 'POST',
294
+ path: '/v1/mailboxes/{path}/messages/expunge',
295
+ summary: 'Removes messages ({ uids })',
296
+ handler: ({ params, body }) => ({ uids: control.expungeMessages(params.path, body.uids) })
297
+ },
298
+ {
299
+ method: 'POST',
300
+ path: '/v1/mailboxes/{path}/messages/copy',
301
+ summary: 'Copies messages ({ uids, target })',
302
+ handler: ({ params, body }) => control.copyMessages(params.path, body.uids, body.target)
303
+ },
304
+ {
305
+ method: 'POST',
306
+ path: '/v1/mailboxes/{path}/messages/move',
307
+ summary: 'Moves messages ({ uids, target })',
308
+ handler: ({ params, body }) => control.moveMessages(params.path, body.uids, body.target)
309
+ },
310
+ {
311
+ method: 'GET',
312
+ path: '/v1/mailboxes/{path}/messages/{uid}',
313
+ summary: 'A message with its base64 source',
314
+ handler: ({ params }) => messageJson(control.getMessage(params.path, toUid(params.uid)))
315
+ },
316
+ {
317
+ method: 'DELETE',
318
+ path: '/v1/mailboxes/{path}/messages/{uid}',
319
+ summary: 'Removes a message',
320
+ handler: ({ params }) => ({ uids: control.expungeMessages(params.path, [toUid(params.uid)]) })
321
+ },
322
+ { method: 'GET', path: '/v1/script/rules', summary: 'Script rules with their hit counts', handler: () => server.script.rules.map(ruleJson) },
323
+ {
324
+ method: 'POST',
325
+ path: '/v1/script/rules',
326
+ summary: 'Adds a script rule or a list of them, in the JSON form of the --script file',
327
+ handler: ({ body }) => {
328
+ let handles;
329
+ try {
330
+ handles = server.script.add(body);
331
+ }
332
+ catch (err) {
333
+ throw (0, store_operations_js_1.storeError)(err.message, 'INVALID');
334
+ }
335
+ return { status: 201, body: Array.isArray(handles) ? handles.map(ruleJson) : ruleJson(handles) };
336
+ }
337
+ },
338
+ {
339
+ method: 'DELETE',
340
+ path: '/v1/script/rules',
341
+ summary: 'Removes every script rule',
342
+ handler: () => {
343
+ server.script.clear();
344
+ return { deleted: true };
345
+ }
346
+ },
347
+ {
348
+ method: 'DELETE',
349
+ path: '/v1/script/rules/{id}',
350
+ summary: 'Removes a script rule',
351
+ handler: ({ params }) => {
352
+ const handle = server.script.rules.find(rule => String(rule.id) === params.id);
353
+ if (!handle) {
354
+ throw (0, store_operations_js_1.storeError)('Script rule ' + params.id + ' does not exist', 'NONEXISTENT');
355
+ }
356
+ handle.remove();
357
+ return { deleted: true };
358
+ }
359
+ }
360
+ ];
361
+ // the operations of plugins (ACL, METADATA, QUOTA)
362
+ routes.push(...control.routes);
363
+ routes.push({ method: 'GET', path: '/v1/openapi.json', summary: 'This API as an OpenAPI document', handler: () => openApi(routes) });
364
+ return routes;
365
+ }
366
+ /**
367
+ * Describes the routes as an OpenAPI 3.1 document
368
+ *
369
+ * @param {Array} routes Route table
370
+ * @return {Object} OpenAPI document
371
+ */
372
+ function openApi(routes) {
373
+ const paths = {};
374
+ routes.forEach(route => {
375
+ const parameters = (route.path.match(/\{(\w+)\}/g) || []).map(param => ({
376
+ name: param.slice(1, -1),
377
+ in: 'path',
378
+ required: true,
379
+ schema: { type: 'string' },
380
+ description: param === '{path}' ? 'Storage name of the mailbox (modified UTF-7), URL encoded, a "/" in it as %2F' : undefined
381
+ }));
382
+ paths[route.path] = paths[route.path] || {};
383
+ paths[route.path][route.method.toLowerCase()] = {
384
+ summary: route.summary,
385
+ parameters,
386
+ responses: { default: { description: 'JSON, errors as { error: { code, message } }' } }
387
+ };
388
+ });
389
+ return {
390
+ openapi: '3.1.0',
391
+ info: { title: 'ImapKit REST API', version: '1' },
392
+ components: { securitySchemes: { bearer: { type: 'http', scheme: 'bearer' } } },
393
+ paths
394
+ };
395
+ }
396
+ /**
397
+ * Finds the route of a request
398
+ *
399
+ * @param {Array} routes Route table
400
+ * @param {String} method HTTP method
401
+ * @param {String} pathname URL path, still URL encoded
402
+ * @return {Object} `{ route, params }`, route is null if the path is known with other methods only
403
+ */
404
+ function matchRoute(routes, method, pathname) {
405
+ const segments = pathname.split('/');
406
+ let pathKnown = false;
407
+ for (const route of routes) {
408
+ const pattern = route.path.split('/');
409
+ if (pattern.length !== segments.length) {
410
+ continue;
411
+ }
412
+ const params = {};
413
+ let matched = true;
414
+ for (let i = 0; i < pattern.length && matched; i++) {
415
+ if (pattern[i].startsWith('{')) {
416
+ try {
417
+ params[pattern[i].slice(1, -1)] = decodeURIComponent(segments[i]);
418
+ }
419
+ catch {
420
+ throw (0, store_operations_js_1.storeError)('Invalid URL encoding', 'INVALID');
421
+ }
422
+ matched = segments[i] !== '';
423
+ }
424
+ else {
425
+ matched = pattern[i] === segments[i];
426
+ }
427
+ }
428
+ if (matched) {
429
+ if (route.method === method) {
430
+ return { route, params };
431
+ }
432
+ pathKnown = true;
433
+ }
434
+ }
435
+ if (pathKnown) {
436
+ throw (0, store_operations_js_1.storeError)('Method ' + method + ' is not allowed here', 'METHOD');
437
+ }
438
+ throw (0, store_operations_js_1.storeError)('No such endpoint', 'NOTFOUND');
439
+ }
440
+ /**
441
+ * Compares a bearer token in constant time
442
+ *
443
+ * @param {String} header Authorization header
444
+ * @param {String} token Expected token
445
+ * @return {Boolean} true if the header carries the token
446
+ */
447
+ function hasToken(header, token) {
448
+ const match = /^Bearer (.+)$/i.exec(header || '');
449
+ if (!match) {
450
+ return false;
451
+ }
452
+ const digest = (value) => node_crypto_1.default.createHash('sha256').update(value).digest();
453
+ return node_crypto_1.default.timingSafeEqual(digest(match[1]), digest(token));
454
+ }
455
+ /**
456
+ * Reads a JSON request body
457
+ *
458
+ * @param {Object} req HTTP request
459
+ * @return {Promise<Object>} the body, an empty object without one
460
+ */
461
+ function readBody(req) {
462
+ return new Promise((resolve, reject) => {
463
+ const chunks = [];
464
+ let size = 0;
465
+ req.on('data', (chunk) => {
466
+ size += chunk.length;
467
+ if (size > BODY_LIMIT) {
468
+ reject((0, store_operations_js_1.storeError)('Request body is larger than 64 MiB', 'TOOBIG'));
469
+ req.destroy();
470
+ return;
471
+ }
472
+ chunks.push(chunk);
473
+ });
474
+ req.on('error', reject);
475
+ req.on('end', () => {
476
+ const text = Buffer.concat(chunks).toString('utf-8');
477
+ if (!text) {
478
+ return resolve({});
479
+ }
480
+ let body;
481
+ try {
482
+ body = JSON.parse(text);
483
+ }
484
+ catch {
485
+ return reject((0, store_operations_js_1.storeError)('Request body is not valid JSON', 'INVALID'));
486
+ }
487
+ if (!body || typeof body !== 'object') {
488
+ return reject((0, store_operations_js_1.storeError)('Request body must be a JSON object or array', 'INVALID'));
489
+ }
490
+ resolve(body);
491
+ });
492
+ });
493
+ }
494
+ /**
495
+ * Creates the HTTP server of the REST API, it does not listen yet
496
+ *
497
+ * @param {Object} server IMAP server
498
+ * @param {Object} options `{ host, token }`
499
+ * @return {Object} HTTP server
500
+ * @throws {Error} for a host that is not a loopback address without a token
501
+ */
502
+ function createRestServer(server, options) {
503
+ const host = options.host || '127.0.0.1';
504
+ const token = options.token;
505
+ if (token !== undefined && (typeof token !== 'string' || !token)) {
506
+ throw new Error('The REST API token must be a non-empty string');
507
+ }
508
+ if (!token && !isLoopback(host)) {
509
+ throw new Error('The REST API controls the whole server, it needs a token (rest.token, --rest-token) to listen on ' + host);
510
+ }
511
+ const routes = getRoutes(server);
512
+ const send = (res, status, body) => {
513
+ const json = JSON.stringify(body);
514
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(json), 'Cache-Control': 'no-store' });
515
+ res.end(json);
516
+ };
517
+ const handle = async (req, res) => {
518
+ if (token) {
519
+ if (!hasToken(req.headers.authorization, token)) {
520
+ res.setHeader('WWW-Authenticate', 'Bearer');
521
+ throw (0, store_operations_js_1.storeError)('Missing or wrong bearer token', 'UNAUTHORIZED');
522
+ }
523
+ }
524
+ else if (!isLoopback((req.headers.host || '').replace(/:\d+$/, ''))) {
525
+ // a page that rebinds its DNS name to 127.0.0.1 sends its own host name
526
+ throw (0, store_operations_js_1.storeError)('Requests must use a loopback host name', 'UNAUTHORIZED');
527
+ }
528
+ const url = new URL(req.url || '/', 'http://localhost');
529
+ const method = (req.method || 'GET').toUpperCase();
530
+ const { route, params } = matchRoute(routes, method, url.pathname);
531
+ let body = {};
532
+ if (method !== 'GET') {
533
+ const hasBody = Number(req.headers['content-length'] || 0) > 0 || !!req.headers['transfer-encoding'];
534
+ // a browser page can POST a form or text/plain cross-origin without asking. A JSON request, and every PUT and
535
+ // DELETE, needs a CORS preflight, which this server never allows
536
+ if (!/^application\/json\b/i.test(req.headers['content-type'] || '') && (hasBody || method === 'POST')) {
537
+ throw (0, store_operations_js_1.storeError)('Requests must be application/json', 'UNSUPPORTED');
538
+ }
539
+ body = await readBody(req);
540
+ }
541
+ const result = route.handler({ params, query: url.searchParams, body });
542
+ if (result && typeof result === 'object' && typeof result.stream === 'function') {
543
+ return result.stream(res, req);
544
+ }
545
+ if (result && typeof result === 'object' && 'status' in result && 'body' in result) {
546
+ const { status, body: responseBody } = result;
547
+ return send(res, status, responseBody);
548
+ }
549
+ send(res, 200, result === undefined ? {} : result);
550
+ };
551
+ return node_http_1.default.createServer((req, res) => {
552
+ handle(req, res).catch((err) => {
553
+ const code = err instanceof store_operations_js_1.ImapKitError ? err.code : 'SERVERERROR';
554
+ // RFC 5530 codes of failed mailbox operations (CANNOT, HASCHILDREN ...) are conflicts with the state
555
+ const status = STATUS_CODES[code] || (err instanceof store_operations_js_1.ImapKitError ? 409 : 500);
556
+ if (!res.headersSent) {
557
+ send(res, status, { error: { code, message: err.message } });
558
+ }
559
+ });
560
+ });
561
+ }
@@ -7,7 +7,7 @@ import type { IMAPResponse, Transport } from './types.js';
7
7
  * core changed the response, so they can send anything at all, also what the strict server never would.
8
8
  */
9
9
  /** The events a rule can watch */
10
- export type ScriptEvent = 'greeting' | 'command' | 'input' | 'response' | 'continuation';
10
+ export type ScriptEvent = 'greeting' | 'command' | 'input' | 'response' | 'continuation' | 'quiet';
11
11
  /** Bytes a rule sends, a function gets the context of the event */
12
12
  export type ScriptBytes = string | Buffer | ((context: ScriptContext) => string | Buffer);
13
13
  /** The event a rule is checked against */
@@ -31,6 +31,8 @@ export interface ScriptContext {
31
31
  description?: string | null | undefined;
32
32
  /** the response object (response and continuation events sent with `connection.send()`) */
33
33
  response?: IMAPResponse | undefined;
34
+ /** milliseconds without input or output (quiet events) */
35
+ quiet?: number | undefined;
34
36
  }
35
37
  /** A rule as it is given to `server.script.add()` or the `script` option */
36
38
  export interface ScriptRule {
@@ -56,6 +58,11 @@ export interface ScriptRule {
56
58
  nth?: number | undefined;
57
59
  /** the rule fires this many times at most (default unlimited) */
58
60
  times?: number | undefined;
61
+ /** the rule fires on a matching event with this probability (0 to 1), from the seeded random numbers of the
62
+ * server (`scriptSeed` option), so a run can be repeated */
63
+ chance?: number | undefined;
64
+ /** milliseconds without input or output before a quiet event fires (quiet events, required) */
65
+ quietFor?: number | undefined;
59
66
  /** sends every string of the response that the grammar allows as a literal (response events) */
60
67
  literals?: boolean | undefined;
61
68
  /**
@@ -79,8 +86,9 @@ export interface ScriptRule {
79
86
  delay?: number | undefined;
80
87
  /** write the bytes in pieces of this many octets */
81
88
  chunk?: number | undefined;
82
- /** milliseconds between the pieces of `chunk` (default 10) */
83
- chunkDelay?: number | undefined;
89
+ /** milliseconds between the pieces of `chunk` (default 10). 0 or "tick" sends each piece on its own event loop
90
+ * turn, so the pieces leave as separate TCP segments without a wall clock delay */
91
+ chunkDelay?: number | 'tick' | undefined;
84
92
  /** send only this many octets of the bytes, then close the connection */
85
93
  truncate?: number | undefined;
86
94
  /** close the connection after the bytes are sent, `"reset"` destroys the socket instead of ending it */
@@ -88,6 +96,8 @@ export interface ScriptRule {
88
96
  }
89
97
  /** A rule that was added, `server.script.add()` returns it */
90
98
  export interface ScriptHandle {
99
+ /** number of the rule, 1 for the first rule added to the server */
100
+ readonly id: number;
91
101
  readonly rule: ScriptRule;
92
102
  /** events that matched the rule, also before `nth` */
93
103
  readonly matched: number;
@@ -100,7 +110,7 @@ export interface OutputOperation {
100
110
  data?: Buffer | undefined;
101
111
  delay?: number | undefined;
102
112
  chunk?: number | undefined;
103
- chunkDelay?: number | undefined;
113
+ chunkDelay?: number | 'tick' | undefined;
104
114
  close?: boolean | 'reset' | undefined;
105
115
  /** the transport layer of the connection when the output was queued */
106
116
  transport?: Transport | null | undefined;
@@ -119,6 +129,10 @@ interface Entry extends ScriptHandle {
119
129
  export declare class ServerScript {
120
130
  server: IMAPServer;
121
131
  entries: Entry[];
132
+ /** rules added so far, the id of a rule is its number */
133
+ ruleCounter: number;
134
+ /** random numbers for `chance`, seeded with the `scriptSeed` option */
135
+ random: () => number;
122
136
  /** the events that rules watch, so that an event nobody watches costs one lookup */
123
137
  watched: Set<ScriptEvent>;
124
138
  constructor(server: IMAPServer, rules?: ScriptRule | ScriptRule[] | undefined);
@@ -157,6 +171,13 @@ export declare class ServerScript {
157
171
  context: ScriptContext;
158
172
  } | null;
159
173
  private setEntries;
174
+ /**
175
+ * The next quiet time a rule waits for, after the quiet time the rules were checked for already
176
+ *
177
+ * @param {Number} checked Milliseconds of quiet the rules were checked for
178
+ * @return {Number|null} the smallest `quietFor` above it, null if there is none
179
+ */
180
+ nextQuiet(checked: number): number | null;
160
181
  private addEntry;
161
182
  /**
162
183
  * Finds the rule that handles an event and counts it
@@ -203,6 +224,14 @@ export declare function releaseDeferred(connection: IMAPConnection, tag: string
203
224
  * @return {Object} a copy of the response with literals, the response is not changed
204
225
  */
205
226
  export declare function literalResponse(response: IMAPResponse): IMAPResponse;
227
+ /**
228
+ * Handles a quiet event with the rule that matched it: sends the bytes of `send` and closes the connection with `close`
229
+ *
230
+ * @param {Object} connection IMAP connection
231
+ * @param {Object} rule Rule that matched
232
+ * @param {Object} context Event context
233
+ */
234
+ export declare function handleQuiet(connection: IMAPConnection, rule: ScriptRule, context: ScriptContext): void;
206
235
  /**
207
236
  * Handles a command or input line with the rule that matched it: sends the bytes of `send`, closes the connection,
208
237
  * and processes the line as usual with `run` (or when the rule only delays it)