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.
- package/README.md +187 -67
- package/bin/help.txt +20 -5
- package/bin/imapkit.js +64 -24
- 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/fetch.js +2 -1
- 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 +7 -7
- package/dist/cjs/commands/list.js +17 -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 +425 -0
- package/dist/cjs/control.js +637 -0
- package/dist/cjs/dates.d.ts +9 -1
- package/dist/cjs/dates.js +20 -0
- package/dist/cjs/envelope.d.ts +1 -1
- package/dist/cjs/envelope.js +26 -1
- package/dist/cjs/index.d.ts +9 -1
- package/dist/cjs/index.js +9 -2
- package/dist/cjs/load-plugins.d.ts +3 -1
- package/dist/cjs/load-plugins.js +28 -4
- package/dist/cjs/plugins/acl.js +109 -42
- package/dist/cjs/plugins/binary.js +2 -1
- package/dist/cjs/plugins/condstore.js +7 -0
- package/dist/cjs/plugins/context-search.js +2 -2
- package/dist/cjs/plugins/create-special-use.d.ts +0 -5
- package/dist/cjs/plugins/create-special-use.js +11 -1
- package/dist/cjs/plugins/imap4rev2.js +5 -0
- package/dist/cjs/plugins/index.js +1 -3
- package/dist/cjs/plugins/metadata-server.d.ts +6 -2
- package/dist/cjs/plugins/metadata-server.js +3 -1
- package/dist/cjs/plugins/metadata.d.ts +5 -1
- package/dist/cjs/plugins/metadata.js +143 -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 +125 -0
- package/dist/cjs/random.d.ts +10 -0
- package/dist/cjs/random.js +22 -0
- package/dist/cjs/rest.d.ts +54 -0
- package/dist/cjs/rest.js +567 -0
- package/dist/cjs/script.d.ts +33 -4
- package/dist/cjs/script.js +64 -4
- package/dist/cjs/server.d.ts +199 -9
- package/dist/cjs/server.js +603 -227
- 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 +222 -0
- package/dist/cjs/store-operations.d.ts +171 -0
- package/dist/cjs/store-operations.js +425 -0
- package/dist/cjs/types.d.ts +26 -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/fetch.js +2 -1
- 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 +7 -7
- package/dist/esm/commands/list.js +17 -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 +425 -0
- package/dist/esm/control.js +630 -0
- package/dist/esm/dates.d.ts +9 -1
- package/dist/esm/dates.js +20 -1
- package/dist/esm/envelope.d.ts +1 -1
- package/dist/esm/envelope.js +26 -1
- package/dist/esm/index.d.ts +9 -1
- package/dist/esm/index.js +5 -2
- package/dist/esm/load-plugins.d.ts +3 -1
- package/dist/esm/load-plugins.js +28 -4
- package/dist/esm/plugins/acl.js +109 -42
- package/dist/esm/plugins/binary.js +2 -1
- package/dist/esm/plugins/condstore.js +7 -0
- package/dist/esm/plugins/context-search.js +2 -2
- package/dist/esm/plugins/create-special-use.d.ts +0 -5
- package/dist/esm/plugins/create-special-use.js +8 -1
- package/dist/esm/plugins/imap4rev2.js +5 -0
- package/dist/esm/plugins/index.js +1 -3
- package/dist/esm/plugins/metadata-server.d.ts +6 -2
- package/dist/esm/plugins/metadata-server.js +3 -1
- package/dist/esm/plugins/metadata.d.ts +5 -1
- package/dist/esm/plugins/metadata.js +143 -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 +121 -0
- package/dist/esm/random.d.ts +10 -0
- package/dist/esm/random.js +19 -0
- package/dist/esm/rest.d.ts +54 -0
- package/dist/esm/rest.js +559 -0
- package/dist/esm/script.d.ts +33 -4
- package/dist/esm/script.js +63 -4
- package/dist/esm/server.d.ts +199 -9
- package/dist/esm/server.js +606 -230
- 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 +218 -0
- package/dist/esm/store-operations.d.ts +171 -0
- package/dist/esm/store-operations.js +410 -0
- package/dist/esm/types.d.ts +26 -1
- package/dist/plugin-help.json +3 -16
- 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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.default = metadataPlugin;
|
|
4
4
|
exports.setup = setup;
|
|
5
|
+
const store_operations_js_1 = require("../store-operations.js");
|
|
5
6
|
const command_states_js_1 = require("../command-states.js");
|
|
6
7
|
const numbers_js_1 = require("../numbers.js");
|
|
7
8
|
const arguments_js_1 = require("../arguments.js");
|
|
@@ -14,6 +15,7 @@ const enable_js_1 = require("./enable.js");
|
|
|
14
15
|
* @help default 65536), "metadataMaxEntries" (per mailbox and for the
|
|
15
16
|
* @help server, default 100). "metadataPrivate": false turns /private
|
|
16
17
|
* @help entries off. ENABLE METADATA turns on unsolicited METADATA responses
|
|
18
|
+
* @help (loads the ENABLE plugin, RFC 5464 section 4.1)
|
|
17
19
|
*/
|
|
18
20
|
// Default limits, RFC 5464 section 4.1 requires at least 1024 octets and 10 entries
|
|
19
21
|
const DEFAULT_MAX_SIZE = 64 * 1024;
|
|
@@ -312,6 +314,60 @@ function setup(server, mailboxes) {
|
|
|
312
314
|
return callback();
|
|
313
315
|
}, { states: command_states_js_1.states.AUTHENTICATED });
|
|
314
316
|
// RFC 5464 section 4.3: setmetadata = "SETMETADATA" SP mailbox SP entry-values
|
|
317
|
+
/**
|
|
318
|
+
* Stores the changes of SETMETADATA (or of the control API) when all of them pass the checks, and tells the
|
|
319
|
+
* sessions that used ENABLE METADATA about the changed entries. The session that made the change
|
|
320
|
+
* (`server.activeConnection`) is not told, and without one (the control API) the read-only server entries can
|
|
321
|
+
* be set too
|
|
322
|
+
*
|
|
323
|
+
* @param {Object} holder Mailbox object or state.server
|
|
324
|
+
* @param {Map} changes Lower case entry name to the new value, null removes the entry
|
|
325
|
+
* @return {Object|null} `{ text, code }` when a change fails, nothing is changed then
|
|
326
|
+
*/
|
|
327
|
+
const storeChanges = (holder, changes) => {
|
|
328
|
+
const origin = server.activeConnection;
|
|
329
|
+
const entries = getEntries(holder);
|
|
330
|
+
const computed = getComputedEntries(holder);
|
|
331
|
+
// changes go to a copy that replaces the entries once every check passed
|
|
332
|
+
const next = Object.assign(Object.create(null), entries);
|
|
333
|
+
const changed = [];
|
|
334
|
+
for (const [name, value] of changes) {
|
|
335
|
+
if ((!holder.path && origin && READ_ONLY_SERVER_ENTRIES.has(name)) || name in computed) {
|
|
336
|
+
return { text: 'The ' + name + ' entry is read-only', code: 'CANNOT' };
|
|
337
|
+
}
|
|
338
|
+
if (!state.allowPrivate && name.startsWith('/private/')) {
|
|
339
|
+
return { text: 'Private annotations are not supported', code: ['METADATA', 'NOPRIVATE'] };
|
|
340
|
+
}
|
|
341
|
+
if (value !== null && value.length > state.maxSize) {
|
|
342
|
+
return { text: 'Value of ' + name + ' is too large', code: ['METADATA', 'MAXSIZE', state.maxSize] };
|
|
343
|
+
}
|
|
344
|
+
if ((entries[name] ?? null) !== value) {
|
|
345
|
+
changed.push(name);
|
|
346
|
+
}
|
|
347
|
+
if (value === null) {
|
|
348
|
+
delete next[name];
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
next[name] = value;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// only adding entries can fail, even when the storage already holds more than the limit
|
|
355
|
+
const count = Object.keys(next).length;
|
|
356
|
+
if (count > state.maxEntries && count > Object.keys(entries).length) {
|
|
357
|
+
return { text: 'Too many annotations', code: ['METADATA', 'TOOMANY'] };
|
|
358
|
+
}
|
|
359
|
+
setEntries(holder, next);
|
|
360
|
+
if (changed.length) {
|
|
361
|
+
// RFC 5464 section 4.4.2: unsolicited responses only list the entry names. Section 4.1:
|
|
362
|
+
// only sessions that used ENABLE METADATA (or METADATA-SERVER) get them
|
|
363
|
+
server.notify({
|
|
364
|
+
tag: '*',
|
|
365
|
+
command: 'METADATA',
|
|
366
|
+
attributes: [mailboxAttribute(holder.path)].concat(changed.map(name => ({ type: 'ATOM', value: name })))
|
|
367
|
+
}, false, origin, isMetadataEnabled);
|
|
368
|
+
}
|
|
369
|
+
return null;
|
|
370
|
+
};
|
|
315
371
|
server.setCommandHandler('SETMETADATA', (connection, parsed, data, callback) => {
|
|
316
372
|
const attrs = parsed.attributes || [];
|
|
317
373
|
if (attrs.length !== 2 || !(0, arguments_js_1.isAstring)(attrs[0]) || !Array.isArray(attrs[1])) {
|
|
@@ -347,81 +403,111 @@ function setup(server, mailboxes) {
|
|
|
347
403
|
if (!holder) {
|
|
348
404
|
return callback();
|
|
349
405
|
}
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
const fail = (text, code) => {
|
|
406
|
+
const failure = storeChanges(holder, changes);
|
|
407
|
+
if (failure) {
|
|
353
408
|
// RFC 5464 section 4.3: when one entry fails, no entry is changed
|
|
354
|
-
connection.sendStatus(parsed, data, 'NO', text, code);
|
|
409
|
+
connection.sendStatus(parsed, data, 'NO', failure.text, failure.code);
|
|
355
410
|
return callback();
|
|
356
|
-
};
|
|
357
|
-
// changes go to a copy that replaces the entries once every check passed
|
|
358
|
-
const next = Object.assign(Object.create(null), entries);
|
|
359
|
-
const changed = [];
|
|
360
|
-
for (const [name, value] of changes) {
|
|
361
|
-
if ((!holder.path && READ_ONLY_SERVER_ENTRIES.has(name)) || name in computed) {
|
|
362
|
-
return fail('The ' + name + ' entry is read-only', 'CANNOT');
|
|
363
|
-
}
|
|
364
|
-
if (!state.allowPrivate && name.startsWith('/private/')) {
|
|
365
|
-
return fail('Private annotations are not supported', ['METADATA', 'NOPRIVATE']);
|
|
366
|
-
}
|
|
367
|
-
if (value !== null && value.length > state.maxSize) {
|
|
368
|
-
return fail('Value of ' + name + ' is too large', ['METADATA', 'MAXSIZE', state.maxSize]);
|
|
369
|
-
}
|
|
370
|
-
if ((entries[name] ?? null) !== value) {
|
|
371
|
-
changed.push(name);
|
|
372
|
-
}
|
|
373
|
-
if (value === null) {
|
|
374
|
-
delete next[name];
|
|
375
|
-
}
|
|
376
|
-
else {
|
|
377
|
-
next[name] = value;
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
// only adding entries can fail, even when the storage already holds more than the limit
|
|
381
|
-
const count = Object.keys(next).length;
|
|
382
|
-
if (count > state.maxEntries && count > Object.keys(entries).length) {
|
|
383
|
-
return fail('Too many annotations', ['METADATA', 'TOOMANY']);
|
|
384
|
-
}
|
|
385
|
-
setEntries(holder, next);
|
|
386
|
-
if (changed.length) {
|
|
387
|
-
// RFC 5464 section 4.4.2: unsolicited responses only list the entry names. Section 4.1:
|
|
388
|
-
// only sessions that used ENABLE METADATA (or METADATA-SERVER) get them
|
|
389
|
-
server.notify({
|
|
390
|
-
tag: '*',
|
|
391
|
-
command: 'METADATA',
|
|
392
|
-
attributes: [mailboxAttribute(holder.path)].concat(changed.map(name => ({ type: 'ATOM', value: name })))
|
|
393
|
-
}, false, connection, isMetadataEnabled);
|
|
394
411
|
}
|
|
395
412
|
connection.sendStatus(parsed, data, 'OK', 'SETMETADATA completed');
|
|
396
413
|
return callback();
|
|
397
414
|
},
|
|
398
415
|
// RFC 5464 section 5: values can be literal8, with or without BINARY
|
|
399
416
|
{ states: command_states_js_1.states.AUTHENTICATED, mailboxArguments: [0], literal8: true });
|
|
400
|
-
server.
|
|
401
|
-
if (
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
if (target && target !== extra && extra.metadata) {
|
|
409
|
-
setEntries(target, Object.assign(Object.create(null), getEntries(extra)));
|
|
417
|
+
server.on('mailbox', (event) => {
|
|
418
|
+
if (event.type === 'rename' && event.oldPath === 'INBOX') {
|
|
419
|
+
// RFC 5464 section 4.1: renaming INBOX copies its annotations, INBOX keeps them. Other mailboxes are
|
|
420
|
+
// moved as objects, so their annotations follow without any help
|
|
421
|
+
const inbox = server.getMailbox('INBOX');
|
|
422
|
+
const target = server.getMailbox(event.path);
|
|
423
|
+
if (inbox && target && target !== inbox && inbox.metadata) {
|
|
424
|
+
setEntries(target, Object.assign(Object.create(null), getEntries(inbox)));
|
|
410
425
|
}
|
|
411
426
|
}
|
|
412
|
-
else if (
|
|
413
|
-
// RFC 5464 section 4.1: a mailbox created later with the same name must not inherit the
|
|
414
|
-
//
|
|
415
|
-
const placeholder =
|
|
416
|
-
if (placeholder) {
|
|
427
|
+
else if (event.type === 'delete') {
|
|
428
|
+
// RFC 5464 section 4.1: a mailbox created later with the same name must not inherit the annotations.
|
|
429
|
+
// A mailbox with children stays as a \Noselect placeholder
|
|
430
|
+
const placeholder = server.getMailbox(event.path);
|
|
431
|
+
if (placeholder && placeholder !== event.mailbox) {
|
|
417
432
|
delete placeholder.metadata;
|
|
418
433
|
}
|
|
419
434
|
}
|
|
420
435
|
});
|
|
436
|
+
// Control API (README "Control API"): the stored annotations of a mailbox, or of the server for "". Values are
|
|
437
|
+
// UTF-8 text, the protocol keeps them as octets
|
|
438
|
+
// control.reset() restores the server annotations of the options, the mailboxes come with their storage
|
|
439
|
+
server.on('reset', () => {
|
|
440
|
+
state.server = { path: '', metadata: options.metadata };
|
|
441
|
+
});
|
|
442
|
+
const controlHolder = (path) => {
|
|
443
|
+
if (path === '') {
|
|
444
|
+
return state.server;
|
|
445
|
+
}
|
|
446
|
+
if (!state.mailboxes) {
|
|
447
|
+
throw (0, store_operations_js_1.storeError)('Mailbox annotations need the METADATA plugin, METADATA-SERVER has server annotations only', 'INVALID');
|
|
448
|
+
}
|
|
449
|
+
return server.control.requireMailbox(path);
|
|
450
|
+
};
|
|
451
|
+
const getMetadataOperation = (path) => {
|
|
452
|
+
const entries = getEntries(controlHolder(path));
|
|
453
|
+
const result = {};
|
|
454
|
+
Object.keys(entries).forEach(name => {
|
|
455
|
+
result[name] = Buffer.from(entries[name], 'binary').toString('utf-8');
|
|
456
|
+
});
|
|
457
|
+
return result;
|
|
458
|
+
};
|
|
459
|
+
const setMetadataOperation = (path, values) => {
|
|
460
|
+
const holder = controlHolder(path);
|
|
461
|
+
if (!values || typeof values !== 'object' || Array.isArray(values)) {
|
|
462
|
+
throw (0, store_operations_js_1.storeError)('setMetadata expects an object of entry names and values (null removes an entry)', 'INVALID');
|
|
463
|
+
}
|
|
464
|
+
const changes = new Map();
|
|
465
|
+
Object.keys(values).forEach(name => {
|
|
466
|
+
const value = values[name];
|
|
467
|
+
const error = checkEntryName(name, true);
|
|
468
|
+
if (error) {
|
|
469
|
+
throw (0, store_operations_js_1.storeError)(error, 'INVALID');
|
|
470
|
+
}
|
|
471
|
+
if (value !== null && typeof value !== 'string') {
|
|
472
|
+
throw (0, store_operations_js_1.storeError)('Value of ' + name + ' must be a string or null', 'INVALID');
|
|
473
|
+
}
|
|
474
|
+
changes.set(name.toLowerCase(), value === null ? null : Buffer.from(value, 'utf-8').toString('binary'));
|
|
475
|
+
});
|
|
476
|
+
const failure = storeChanges(holder, changes);
|
|
477
|
+
if (failure) {
|
|
478
|
+
throw (0, store_operations_js_1.storeError)(failure.text, typeof failure.code === 'string' ? failure.code : 'INVALID');
|
|
479
|
+
}
|
|
480
|
+
return getMetadataOperation(path);
|
|
481
|
+
};
|
|
482
|
+
server.control.register('getMetadata', getMetadataOperation, [
|
|
483
|
+
{ method: 'GET', path: '/v1/metadata', summary: 'Server annotations (METADATA plugins)', handler: () => server.control.getMetadata('') },
|
|
484
|
+
{
|
|
485
|
+
method: 'GET',
|
|
486
|
+
path: '/v1/mailboxes/{path}/metadata',
|
|
487
|
+
summary: 'Mailbox annotations (METADATA plugin)',
|
|
488
|
+
handler: ({ params }) => server.control.getMetadata(params.path)
|
|
489
|
+
}
|
|
490
|
+
]);
|
|
491
|
+
server.control.register('setMetadata', setMetadataOperation, [
|
|
492
|
+
{
|
|
493
|
+
method: 'PUT',
|
|
494
|
+
path: '/v1/metadata',
|
|
495
|
+
summary: 'Sets server annotations ({ "/shared/comment": "text" }, null removes one, METADATA plugins)',
|
|
496
|
+
handler: ({ body }) => server.control.setMetadata('', body)
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
method: 'PUT',
|
|
500
|
+
path: '/v1/mailboxes/{path}/metadata',
|
|
501
|
+
summary: 'Sets mailbox annotations (METADATA plugin)',
|
|
502
|
+
handler: ({ params, body }) => server.control.setMetadata(params.path, body)
|
|
503
|
+
}
|
|
504
|
+
]);
|
|
421
505
|
}
|
|
422
506
|
function metadataPlugin(server) {
|
|
423
507
|
setup(server, true);
|
|
424
508
|
}
|
|
509
|
+
// RFC 5464 section 4.1: a server that sends unsolicited METADATA responses "MUST support the ENABLE command"
|
|
510
|
+
metadataPlugin.requires = ['ENABLE'];
|
|
425
511
|
{
|
|
426
512
|
const defaultExport = exports.default;
|
|
427
513
|
if (defaultExport && (typeof defaultExport === 'function' || typeof defaultExport === 'object')) {
|
|
@@ -98,6 +98,16 @@ function objectidPlugin(server) {
|
|
|
98
98
|
}
|
|
99
99
|
getMailboxId(mailbox);
|
|
100
100
|
});
|
|
101
|
+
// Control API (README "Control API"): the object ids in getMailbox() and getMessage()
|
|
102
|
+
server.control.mailboxInfoHandlers.push((mailbox, info) => {
|
|
103
|
+
if (mailbox.flags.indexOf('\\Noselect') < 0) {
|
|
104
|
+
info.mailboxId = getMailboxId(mailbox);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
server.control.messageInfoHandlers.push((message, info) => {
|
|
108
|
+
info.emailId = message.EMAILID;
|
|
109
|
+
info.threadId = message.THREADID;
|
|
110
|
+
});
|
|
101
111
|
server.messageHandlers.push((server, message, mailbox) => {
|
|
102
112
|
const owner = 'a message of mailbox ' + mailbox.path;
|
|
103
113
|
if (message.EMAILID) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.default = quotaPlugin;
|
|
4
|
+
const store_operations_js_1 = require("../store-operations.js");
|
|
4
5
|
const command_states_js_1 = require("../command-states.js");
|
|
5
6
|
const mimeparser_js_1 = require("../mimeparser.js");
|
|
6
7
|
const numbers_js_1 = require("../numbers.js");
|
|
@@ -32,6 +33,8 @@ function quotaPlugin(server) {
|
|
|
32
33
|
soft: !!config.soft,
|
|
33
34
|
limits: {}
|
|
34
35
|
};
|
|
36
|
+
// the limits of the quota option, control.reset() restores them
|
|
37
|
+
const initialLimits = {};
|
|
35
38
|
RESOURCES.forEach(resource => {
|
|
36
39
|
if (config[resource] === undefined || config[resource] === null) {
|
|
37
40
|
return;
|
|
@@ -40,8 +43,13 @@ function quotaPlugin(server) {
|
|
|
40
43
|
if (!Number.isSafeInteger(limit) || limit < 0) {
|
|
41
44
|
throw new Error('Invalid quota limit for ' + resource + ': ' + JSON.stringify(config[resource]));
|
|
42
45
|
}
|
|
43
|
-
|
|
46
|
+
initialLimits[resource] = limit;
|
|
44
47
|
});
|
|
48
|
+
const restoreLimits = () => {
|
|
49
|
+
root.limits = Object.assign({}, initialLimits);
|
|
50
|
+
};
|
|
51
|
+
restoreLimits();
|
|
52
|
+
server.on('reset', restoreLimits);
|
|
45
53
|
// RFC 9208 sections 3.1.1 and 5: every supported resource is advertised, SETQUOTA needs QUOTASET
|
|
46
54
|
server.registerCapability('QUOTA');
|
|
47
55
|
RESOURCES.forEach(resource => server.registerCapability('QUOTA=RES-' + resource));
|
|
@@ -114,7 +122,7 @@ function quotaPlugin(server) {
|
|
|
114
122
|
}
|
|
115
123
|
const added = { MESSAGE: messages.length, bytes: totalSize(messages) };
|
|
116
124
|
// RFC 8508 section 3.4: REPLACE counts only the net usage, the replaced message is removed
|
|
117
|
-
if (options.replaced && connection.selectedMailbox && inRoot(connection.selectedMailbox)) {
|
|
125
|
+
if (options.replaced && connection && connection.selectedMailbox && inRoot(connection.selectedMailbox)) {
|
|
118
126
|
added.MESSAGE--;
|
|
119
127
|
added.bytes -= totalSize([options.replaced]);
|
|
120
128
|
}
|
|
@@ -239,6 +247,46 @@ function quotaPlugin(server) {
|
|
|
239
247
|
}
|
|
240
248
|
});
|
|
241
249
|
server.statusHandlers['DELETED-STORAGE'] = (connection, mailbox) => totalSize(mailbox.messages.filter(message => message.flags.indexOf('\\Deleted') >= 0));
|
|
250
|
+
// Control API (README "Control API"): the quota root with its limits and usage, STORAGE in units of 1024 octets
|
|
251
|
+
const getQuotaOperation = () => {
|
|
252
|
+
const usage = getUsage();
|
|
253
|
+
return {
|
|
254
|
+
root: root.name,
|
|
255
|
+
limits: Object.assign({}, root.limits),
|
|
256
|
+
usage: { STORAGE: Math.ceil(usage.bytes / 1024), MESSAGE: usage.MESSAGE, MAILBOX: usage.MAILBOX }
|
|
257
|
+
};
|
|
258
|
+
};
|
|
259
|
+
// the new limits replace all earlier ones, like SETQUOTA (RFC 9208 section 4.1.3)
|
|
260
|
+
const setQuotaOperation = (limits) => {
|
|
261
|
+
if (!limits || typeof limits !== 'object' || Array.isArray(limits)) {
|
|
262
|
+
throw (0, store_operations_js_1.storeError)('setQuota expects an object of resource limits, e.g. { STORAGE: 1024 }', 'INVALID');
|
|
263
|
+
}
|
|
264
|
+
const next = {};
|
|
265
|
+
Object.keys(limits).forEach(name => {
|
|
266
|
+
const resource = name.toUpperCase();
|
|
267
|
+
const value = limits[name];
|
|
268
|
+
if (RESOURCES.indexOf(resource) < 0) {
|
|
269
|
+
throw (0, store_operations_js_1.storeError)('Unknown quota resource ' + name + ', expected ' + RESOURCES.join(', '), 'INVALID');
|
|
270
|
+
}
|
|
271
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
|
|
272
|
+
throw (0, store_operations_js_1.storeError)('Limit of ' + resource + ' must be a non-negative integer', 'INVALID');
|
|
273
|
+
}
|
|
274
|
+
next[resource] = value;
|
|
275
|
+
});
|
|
276
|
+
root.limits = next;
|
|
277
|
+
return getQuotaOperation();
|
|
278
|
+
};
|
|
279
|
+
server.control.register('getQuota', getQuotaOperation, [
|
|
280
|
+
{ method: 'GET', path: '/v1/quota', summary: 'Quota root, limits and usage (QUOTA plugin)', handler: () => server.control.getQuota() }
|
|
281
|
+
]);
|
|
282
|
+
server.control.register('setQuota', setQuotaOperation, [
|
|
283
|
+
{
|
|
284
|
+
method: 'PUT',
|
|
285
|
+
path: '/v1/quota',
|
|
286
|
+
summary: 'Replaces the quota limits ({ STORAGE, MESSAGE, MAILBOX }, QUOTA plugin)',
|
|
287
|
+
handler: ({ body }) => server.control.setQuota(body)
|
|
288
|
+
}
|
|
289
|
+
]);
|
|
242
290
|
}
|
|
243
291
|
{
|
|
244
292
|
const defaultExport = exports.default;
|
|
@@ -28,7 +28,7 @@ function savedatePlugin(server) {
|
|
|
28
28
|
delete message.SAVEDATE;
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
31
|
-
const savedate = server.normalizeDateTime(message.SAVEDATE ||
|
|
31
|
+
const savedate = server.normalizeDateTime(message.SAVEDATE || server.now());
|
|
32
32
|
if (!server.validateInternalDate(savedate)) {
|
|
33
33
|
throw new Error('Invalid SAVEDATE value ' + JSON.stringify(savedate) + ' in mailbox ' + mailbox.path);
|
|
34
34
|
}
|
|
@@ -1,10 +1,2 @@
|
|
|
1
1
|
import type { IMAPServer } from '../types.js';
|
|
2
|
-
/**
|
|
3
|
-
* @help Enables SPECIAL-USE [RFC6154] capability
|
|
4
|
-
* @help Mailboxes need to have a "special-use"
|
|
5
|
-
* @help property (String or Array) that will be used
|
|
6
|
-
* @help as extra flag for LIST and LSUB responses.
|
|
7
|
-
* @help With LIST-EXTENDED the SPECIAL-USE selection and
|
|
8
|
-
* @help return options work with the other LIST options
|
|
9
|
-
*/
|
|
10
2
|
export default function specialUsePlugin(server: IMAPServer): void;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.default = specialUsePlugin;
|
|
4
4
|
const list_extensions_js_1 = require("../list-extensions.js");
|
|
5
|
+
const store_operations_js_1 = require("../store-operations.js");
|
|
5
6
|
/**
|
|
6
7
|
* @help Enables SPECIAL-USE [RFC6154] capability
|
|
7
8
|
* @help Mailboxes need to have a "special-use"
|
|
@@ -10,6 +11,8 @@ const list_extensions_js_1 = require("../list-extensions.js");
|
|
|
10
11
|
* @help With LIST-EXTENDED the SPECIAL-USE selection and
|
|
11
12
|
* @help return options work with the other LIST options
|
|
12
13
|
*/
|
|
14
|
+
// RFC 6154 section 2 and RFC 8457 (\\Important)
|
|
15
|
+
const SPECIAL_USES = new Set(['\\All', '\\Archive', '\\Drafts', '\\Flagged', '\\Junk', '\\Sent', '\\Trash', '\\Important']);
|
|
13
16
|
function specialUsePlugin(server) {
|
|
14
17
|
// Register capability
|
|
15
18
|
server.registerCapability('SPECIAL-USE');
|
|
@@ -80,6 +83,26 @@ function specialUsePlugin(server) {
|
|
|
80
83
|
}
|
|
81
84
|
}
|
|
82
85
|
});
|
|
86
|
+
// Control API (README "Control API"): the special-use attributes of a mailbox (RFC 6154 section 2)
|
|
87
|
+
server.control.mailboxInfoHandlers.push((mailbox, info) => {
|
|
88
|
+
info.specialUse = getSpecialUse(mailbox);
|
|
89
|
+
});
|
|
90
|
+
const setSpecialUseOperation = (path, uses) => {
|
|
91
|
+
const mailbox = server.control.requireMailbox(path, true);
|
|
92
|
+
if (!Array.isArray(uses) || uses.some(use => typeof use !== 'string' || !SPECIAL_USES.has(use))) {
|
|
93
|
+
throw (0, store_operations_js_1.storeError)('Special-use attributes must be a list of ' + [...SPECIAL_USES].join(', '), 'INVALID');
|
|
94
|
+
}
|
|
95
|
+
mailbox['special-use'] = [...new Set(uses)];
|
|
96
|
+
return server.control.getMailbox(mailbox.path);
|
|
97
|
+
};
|
|
98
|
+
server.control.register('setSpecialUse', setSpecialUseOperation, [
|
|
99
|
+
{
|
|
100
|
+
method: 'PUT',
|
|
101
|
+
path: '/v1/mailboxes/{path}/special-use',
|
|
102
|
+
summary: 'Sets the special-use attributes of a mailbox ({ specialUse: ["\\Sent"] }, SPECIAL-USE plugin)',
|
|
103
|
+
handler: ({ params, body }) => server.control.setSpecialUse(params.path, body.specialUse)
|
|
104
|
+
}
|
|
105
|
+
]);
|
|
83
106
|
}
|
|
84
107
|
{
|
|
85
108
|
const defaultExport = exports.default;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quirk presets (#87): named sets of script rules (and plugins to leave out) that make the server behave like a known
|
|
3
|
+
* real server, so a client test reproduces the bug of that server in every run, without the server itself. The
|
|
4
|
+
* `quirks` option turns them on, the rules come after the rules of the `script` option. The probabilistic ones use
|
|
5
|
+
* the random numbers of the `scriptSeed` option, so a run can be repeated. The presets are exported as data, copy
|
|
6
|
+
* and adjust one as script rules when it does not fit
|
|
7
|
+
*/
|
|
8
|
+
import type { ScriptRule } from './script.js';
|
|
9
|
+
/** A quirk preset */
|
|
10
|
+
export interface Quirk {
|
|
11
|
+
description: string;
|
|
12
|
+
/** script rules, see README "Scripted faults" */
|
|
13
|
+
rules?: ScriptRule[] | undefined;
|
|
14
|
+
/** plugins that are not loaded even when the `plugins` option lists them */
|
|
15
|
+
removePlugins?: string[] | undefined;
|
|
16
|
+
}
|
|
17
|
+
export declare const quirks: Record<string, Quirk>;
|
|
18
|
+
/**
|
|
19
|
+
* Resolves the `quirks` option
|
|
20
|
+
*
|
|
21
|
+
* @param {Array|String} names Quirk names
|
|
22
|
+
* @return {Object} `{ rules, removePlugins }` of all of them, removePlugins maps a plugin name to the quirk that removes it
|
|
23
|
+
* @throws {Error} for an unknown name
|
|
24
|
+
*/
|
|
25
|
+
export declare function resolveQuirks(names: string[] | string | null | undefined): {
|
|
26
|
+
rules: ScriptRule[];
|
|
27
|
+
removePlugins: Map<string, string>;
|
|
28
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Quirk presets (#87): named sets of script rules (and plugins to leave out) that make the server behave like a known
|
|
4
|
+
* real server, so a client test reproduces the bug of that server in every run, without the server itself. The
|
|
5
|
+
* `quirks` option turns them on, the rules come after the rules of the `script` option. The probabilistic ones use
|
|
6
|
+
* the random numbers of the `scriptSeed` option, so a run can be repeated. The presets are exported as data, copy
|
|
7
|
+
* and adjust one as script rules when it does not fit
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.quirks = void 0;
|
|
11
|
+
exports.resolveQuirks = resolveQuirks;
|
|
12
|
+
const FETCH_COMMANDS = ['FETCH', 'UID FETCH'];
|
|
13
|
+
/**
|
|
14
|
+
* The part a body section belongs to: "2.MIME", "2.HEADER" and "2" all belong to part 2, "HEADER" and "TEXT" to the
|
|
15
|
+
* message itself
|
|
16
|
+
*
|
|
17
|
+
* @param {Array} section Section of a BODY item, its first entry is the section spec
|
|
18
|
+
* @return {String} part path, "" for the message
|
|
19
|
+
*/
|
|
20
|
+
function partPath(section) {
|
|
21
|
+
const spec = String((section[0] && section[0].value) || '').toUpperCase();
|
|
22
|
+
return spec.replace(/(^|\.)(MIME|HEADER(\.FIELDS(\.NOT)?)?|TEXT)$/, '');
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Changes the BODY[...] values of an untagged FETCH response
|
|
26
|
+
*
|
|
27
|
+
* @param {Object} response Untagged FETCH response
|
|
28
|
+
* @param {Function} change `(name, value, index)` returns the new value
|
|
29
|
+
* @return {Object|undefined} the changed response, nothing for other responses
|
|
30
|
+
*/
|
|
31
|
+
function changeBodyValues(response, change) {
|
|
32
|
+
const items = response.attributes && response.attributes[2];
|
|
33
|
+
if (!Array.isArray(items) || !response.attributes[1] || response.attributes[1].value !== 'FETCH') {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
const changed = items.slice();
|
|
37
|
+
for (let i = 0; i < changed.length - 1; i += 2) {
|
|
38
|
+
const name = changed[i];
|
|
39
|
+
if (name && name.type === 'ATOM' && String(name.value).toUpperCase() === 'BODY' && Array.isArray(name.section)) {
|
|
40
|
+
changed[i + 1] = change(name, changed[i + 1]);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return Object.assign({}, response, { attributes: [response.attributes[0], response.attributes[1], changed] });
|
|
44
|
+
}
|
|
45
|
+
exports.quirks = {
|
|
46
|
+
'james-fetchgroup': {
|
|
47
|
+
description: 'Apache James FetchGroup: only the first section asked for a part in one FETCH is answered, later ones for the same ' +
|
|
48
|
+
'part come back empty. BODY[2.MIME] BODY[2] gives a zero-length body, the reverse order loses the headers',
|
|
49
|
+
rules: [
|
|
50
|
+
{
|
|
51
|
+
on: 'response',
|
|
52
|
+
command: FETCH_COMMANDS,
|
|
53
|
+
untagged: true,
|
|
54
|
+
mutate: response => {
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
return changeBodyValues(response, (name, value) => {
|
|
57
|
+
const path = partPath(name.section);
|
|
58
|
+
if (seen.has(path)) {
|
|
59
|
+
return { type: 'LITERAL', value: '' };
|
|
60
|
+
}
|
|
61
|
+
seen.add(path);
|
|
62
|
+
return value;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
},
|
|
68
|
+
'james-late-fetch': {
|
|
69
|
+
description: 'Apache James: now and then (1 in 4) a FETCH response comes after the tagged OK of its command',
|
|
70
|
+
rules: [{ on: 'response', command: FETCH_COMMANDS, untagged: true, chance: 0.25, defer: 'tagged' }]
|
|
71
|
+
},
|
|
72
|
+
'yahoo-quoted-sections': {
|
|
73
|
+
description: 'Yahoo: short body sections (up to 100 octets without line breaks) are quoted strings instead of literals',
|
|
74
|
+
rules: [
|
|
75
|
+
{
|
|
76
|
+
on: 'response',
|
|
77
|
+
command: FETCH_COMMANDS,
|
|
78
|
+
untagged: true,
|
|
79
|
+
mutate: response => changeBodyValues(response, (name, value) => value && value.type === 'LITERAL' && typeof value.value === 'string' && value.value.length <= 100 && /^[\x20-\x7e]*$/.test(value.value)
|
|
80
|
+
? { type: 'STRING', value: value.value }
|
|
81
|
+
: value)
|
|
82
|
+
}
|
|
83
|
+
]
|
|
84
|
+
},
|
|
85
|
+
'm365-throttle': {
|
|
86
|
+
description: 'Microsoft 365: 1 in 10 commands (not LOGOUT) is refused with "BAD Request is throttled. Suggested Backoff Time: 1000 milliseconds"',
|
|
87
|
+
rules: [
|
|
88
|
+
{
|
|
89
|
+
on: 'command',
|
|
90
|
+
chance: 0.1,
|
|
91
|
+
when: context => context.command !== 'LOGOUT',
|
|
92
|
+
send: '$TAG BAD Request is throttled. Suggested Backoff Time: 1000 milliseconds\r\n'
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
},
|
|
96
|
+
'no-uidplus': {
|
|
97
|
+
description: 'A server without UIDPLUS: no APPENDUID, COPYUID or UID EXPUNGE, even when the plugins list it',
|
|
98
|
+
removePlugins: ['UIDPLUS']
|
|
99
|
+
},
|
|
100
|
+
'no-move': {
|
|
101
|
+
description: 'A server without MOVE, clients have to COPY, STORE \\Deleted and EXPUNGE',
|
|
102
|
+
removePlugins: ['MOVE']
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* Resolves the `quirks` option
|
|
107
|
+
*
|
|
108
|
+
* @param {Array|String} names Quirk names
|
|
109
|
+
* @return {Object} `{ rules, removePlugins }` of all of them, removePlugins maps a plugin name to the quirk that removes it
|
|
110
|
+
* @throws {Error} for an unknown name
|
|
111
|
+
*/
|
|
112
|
+
function resolveQuirks(names) {
|
|
113
|
+
const rules = [];
|
|
114
|
+
const removePlugins = new Map();
|
|
115
|
+
[].concat(names || []).forEach(name => {
|
|
116
|
+
const key = String(name).toLowerCase();
|
|
117
|
+
const quirk = Object.hasOwn(exports.quirks, key) ? exports.quirks[key] : null;
|
|
118
|
+
if (!quirk) {
|
|
119
|
+
throw new Error('Unknown quirk "' + name + '". Available quirks: ' + Object.keys(exports.quirks).join(', '));
|
|
120
|
+
}
|
|
121
|
+
rules.push(...(quirk.rules || []));
|
|
122
|
+
(quirk.removePlugins || []).forEach(plugin => removePlugins.set(plugin, key));
|
|
123
|
+
});
|
|
124
|
+
return { rules, removePlugins };
|
|
125
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seeded random numbers, so that randomized test behavior (UID shuffles, script rule chances) can be repeated
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* A small seeded random number generator (mulberry32), for repeatable "shuffle" orders
|
|
6
|
+
*
|
|
7
|
+
* @param {Number} seed Seed
|
|
8
|
+
* @return {Function} returns numbers from 0 (inclusive) to 1 (exclusive)
|
|
9
|
+
*/
|
|
10
|
+
export declare function seededRandom(seed: number): () => number;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Seeded random numbers, so that randomized test behavior (UID shuffles, script rule chances) can be repeated
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.seededRandom = seededRandom;
|
|
7
|
+
/**
|
|
8
|
+
* A small seeded random number generator (mulberry32), for repeatable "shuffle" orders
|
|
9
|
+
*
|
|
10
|
+
* @param {Number} seed Seed
|
|
11
|
+
* @return {Function} returns numbers from 0 (inclusive) to 1 (exclusive)
|
|
12
|
+
*/
|
|
13
|
+
function seededRandom(seed) {
|
|
14
|
+
let state = seed >>> 0;
|
|
15
|
+
return () => {
|
|
16
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
17
|
+
let t = state;
|
|
18
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
19
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
20
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
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 type { IMAPServer } from './types.js';
|
|
10
|
+
import type { ControlRoute as Route } from './control.js';
|
|
11
|
+
/** The HTTP server of the REST API, `endStreams()` ends the open event streams */
|
|
12
|
+
type RestServer = http.Server & {
|
|
13
|
+
endStreams?: () => void;
|
|
14
|
+
};
|
|
15
|
+
/** Options of the REST API, the `rest` server option */
|
|
16
|
+
interface RestOptions {
|
|
17
|
+
port?: number | undefined;
|
|
18
|
+
/** address to listen on, 127.0.0.1 by default. Any other than a loopback address needs a token */
|
|
19
|
+
host?: string | undefined;
|
|
20
|
+
/** bearer token that every request must send in `Authorization: Bearer <token>` */
|
|
21
|
+
token?: string | undefined;
|
|
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
|
+
declare function isLoopback(host: string): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Builds the route table of a server
|
|
32
|
+
*
|
|
33
|
+
* @param {Object} server IMAP server
|
|
34
|
+
* @return {Array} routes
|
|
35
|
+
*/
|
|
36
|
+
declare function getRoutes(server: IMAPServer, streams?: Set<http.ServerResponse>): Route[];
|
|
37
|
+
/**
|
|
38
|
+
* Describes the routes as an OpenAPI 3.1 document
|
|
39
|
+
*
|
|
40
|
+
* @param {Array} routes Route table
|
|
41
|
+
* @return {Object} OpenAPI document
|
|
42
|
+
*/
|
|
43
|
+
declare function openApi(routes: Route[]): Record<string, unknown>;
|
|
44
|
+
/**
|
|
45
|
+
* Creates the HTTP server of the REST API, it does not listen yet
|
|
46
|
+
*
|
|
47
|
+
* @param {Object} server IMAP server
|
|
48
|
+
* @param {Object} options `{ host, token }`
|
|
49
|
+
* @return {Object} HTTP server
|
|
50
|
+
* @throws {Error} for a host that is not a loopback address without a token
|
|
51
|
+
*/
|
|
52
|
+
declare function createRestServer(server: IMAPServer, options: RestOptions): RestServer;
|
|
53
|
+
export { createRestServer, isLoopback, getRoutes, openApi };
|
|
54
|
+
export type { RestOptions, RestServer };
|