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.
- package/README.md +167 -51
- package/bin/help.txt +17 -3
- package/bin/imapkit.js +53 -23
- package/dist/cjs/commands/append.js +1 -1
- package/dist/cjs/commands/create.js +2 -2
- package/dist/cjs/commands/delete.js +2 -14
- package/dist/cjs/commands/handlers/flags.d.ts +3 -3
- package/dist/cjs/commands/handlers/flags.js +3 -3
- package/dist/cjs/commands/handlers/store.js +3 -3
- package/dist/cjs/commands/rename.js +6 -46
- package/dist/cjs/commands/select.js +1 -0
- package/dist/cjs/commands/subscribe.js +7 -8
- package/dist/cjs/commands/unsubscribe.js +3 -9
- package/dist/cjs/control.d.ts +430 -0
- package/dist/cjs/control.js +637 -0
- package/dist/cjs/index.d.ts +8 -1
- package/dist/cjs/index.js +9 -2
- package/dist/cjs/load-plugins.d.ts +1 -1
- package/dist/cjs/load-plugins.js +7 -2
- package/dist/cjs/plugins/acl.js +109 -42
- package/dist/cjs/plugins/condstore.js +7 -0
- package/dist/cjs/plugins/index.js +1 -3
- package/dist/cjs/plugins/metadata.js +140 -57
- package/dist/cjs/plugins/objectid.js +10 -0
- package/dist/cjs/plugins/quota.js +50 -2
- package/dist/cjs/plugins/savedate.js +1 -1
- package/dist/cjs/plugins/special-use.d.ts +0 -8
- package/dist/cjs/plugins/special-use.js +23 -0
- package/dist/cjs/quirks.d.ts +28 -0
- package/dist/cjs/quirks.js +124 -0
- package/dist/cjs/random.d.ts +10 -0
- package/dist/cjs/random.js +22 -0
- package/dist/cjs/rest.d.ts +50 -0
- package/dist/cjs/rest.js +561 -0
- package/dist/cjs/script.d.ts +33 -4
- package/dist/cjs/script.js +64 -4
- package/dist/cjs/server.d.ts +149 -8
- package/dist/cjs/server.js +429 -152
- package/dist/cjs/smtp-listener.d.ts +3 -2
- package/dist/cjs/smtp-listener.js +10 -6
- package/dist/cjs/storage-schema.d.ts +139 -0
- package/dist/cjs/storage-schema.js +215 -0
- package/dist/cjs/store-operations.d.ts +168 -0
- package/dist/cjs/store-operations.js +422 -0
- package/dist/cjs/types.d.ts +22 -1
- package/dist/esm/commands/append.js +1 -1
- package/dist/esm/commands/create.js +2 -2
- package/dist/esm/commands/delete.js +2 -14
- package/dist/esm/commands/handlers/flags.d.ts +3 -3
- package/dist/esm/commands/handlers/flags.js +3 -3
- package/dist/esm/commands/handlers/store.js +3 -3
- package/dist/esm/commands/rename.js +6 -46
- package/dist/esm/commands/select.js +1 -0
- package/dist/esm/commands/subscribe.js +7 -8
- package/dist/esm/commands/unsubscribe.js +3 -9
- package/dist/esm/control.d.ts +430 -0
- package/dist/esm/control.js +630 -0
- package/dist/esm/index.d.ts +8 -1
- package/dist/esm/index.js +5 -2
- package/dist/esm/load-plugins.d.ts +1 -1
- package/dist/esm/load-plugins.js +7 -2
- package/dist/esm/plugins/acl.js +109 -42
- package/dist/esm/plugins/condstore.js +7 -0
- package/dist/esm/plugins/index.js +1 -3
- package/dist/esm/plugins/metadata.js +140 -57
- package/dist/esm/plugins/objectid.js +10 -0
- package/dist/esm/plugins/quota.js +50 -2
- package/dist/esm/plugins/savedate.js +1 -1
- package/dist/esm/plugins/special-use.d.ts +0 -8
- package/dist/esm/plugins/special-use.js +23 -0
- package/dist/esm/quirks.d.ts +28 -0
- package/dist/esm/quirks.js +120 -0
- package/dist/esm/random.d.ts +10 -0
- package/dist/esm/random.js +19 -0
- package/dist/esm/rest.d.ts +50 -0
- package/dist/esm/rest.js +553 -0
- package/dist/esm/script.d.ts +33 -4
- package/dist/esm/script.js +63 -4
- package/dist/esm/server.d.ts +149 -8
- package/dist/esm/server.js +429 -152
- package/dist/esm/smtp-listener.d.ts +3 -2
- package/dist/esm/smtp-listener.js +10 -6
- package/dist/esm/storage-schema.d.ts +139 -0
- package/dist/esm/storage-schema.js +211 -0
- package/dist/esm/store-operations.d.ts +168 -0
- package/dist/esm/store-operations.js +407 -0
- package/dist/esm/types.d.ts +22 -1
- package/dist/plugin-help.json +0 -14
- package/package.json +11 -3
- package/dist/cjs/plugins/xtoybird.d.ts +0 -2
- package/dist/cjs/plugins/xtoybird.js +0 -236
- package/dist/esm/plugins/xtoybird.d.ts +0 -2
- package/dist/esm/plugins/xtoybird.js +0 -218
package/dist/cjs/rest.js
ADDED
|
@@ -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
|
+
}
|
package/dist/cjs/script.d.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|