imapkit 4.3.1 → 5.0.1

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