imapkit 4.3.1 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +167 -51
  2. package/bin/help.txt +17 -3
  3. package/bin/imapkit.js +53 -23
  4. package/dist/cjs/commands/append.js +1 -1
  5. package/dist/cjs/commands/create.js +2 -2
  6. package/dist/cjs/commands/delete.js +2 -14
  7. package/dist/cjs/commands/handlers/flags.d.ts +3 -3
  8. package/dist/cjs/commands/handlers/flags.js +3 -3
  9. package/dist/cjs/commands/handlers/store.js +3 -3
  10. package/dist/cjs/commands/rename.js +6 -46
  11. package/dist/cjs/commands/select.js +1 -0
  12. package/dist/cjs/commands/subscribe.js +7 -8
  13. package/dist/cjs/commands/unsubscribe.js +3 -9
  14. package/dist/cjs/control.d.ts +430 -0
  15. package/dist/cjs/control.js +637 -0
  16. package/dist/cjs/index.d.ts +8 -1
  17. package/dist/cjs/index.js +9 -2
  18. package/dist/cjs/load-plugins.d.ts +1 -1
  19. package/dist/cjs/load-plugins.js +7 -2
  20. package/dist/cjs/plugins/acl.js +109 -42
  21. package/dist/cjs/plugins/condstore.js +7 -0
  22. package/dist/cjs/plugins/index.js +1 -3
  23. package/dist/cjs/plugins/metadata.js +140 -57
  24. package/dist/cjs/plugins/objectid.js +10 -0
  25. package/dist/cjs/plugins/quota.js +50 -2
  26. package/dist/cjs/plugins/savedate.js +1 -1
  27. package/dist/cjs/plugins/special-use.d.ts +0 -8
  28. package/dist/cjs/plugins/special-use.js +23 -0
  29. package/dist/cjs/quirks.d.ts +28 -0
  30. package/dist/cjs/quirks.js +124 -0
  31. package/dist/cjs/random.d.ts +10 -0
  32. package/dist/cjs/random.js +22 -0
  33. package/dist/cjs/rest.d.ts +50 -0
  34. package/dist/cjs/rest.js +561 -0
  35. package/dist/cjs/script.d.ts +33 -4
  36. package/dist/cjs/script.js +64 -4
  37. package/dist/cjs/server.d.ts +147 -8
  38. package/dist/cjs/server.js +398 -144
  39. package/dist/cjs/smtp-listener.d.ts +3 -2
  40. package/dist/cjs/smtp-listener.js +10 -6
  41. package/dist/cjs/storage-schema.d.ts +139 -0
  42. package/dist/cjs/storage-schema.js +215 -0
  43. package/dist/cjs/store-operations.d.ts +168 -0
  44. package/dist/cjs/store-operations.js +422 -0
  45. package/dist/cjs/types.d.ts +22 -1
  46. package/dist/esm/commands/append.js +1 -1
  47. package/dist/esm/commands/create.js +2 -2
  48. package/dist/esm/commands/delete.js +2 -14
  49. package/dist/esm/commands/handlers/flags.d.ts +3 -3
  50. package/dist/esm/commands/handlers/flags.js +3 -3
  51. package/dist/esm/commands/handlers/store.js +3 -3
  52. package/dist/esm/commands/rename.js +6 -46
  53. package/dist/esm/commands/select.js +1 -0
  54. package/dist/esm/commands/subscribe.js +7 -8
  55. package/dist/esm/commands/unsubscribe.js +3 -9
  56. package/dist/esm/control.d.ts +430 -0
  57. package/dist/esm/control.js +630 -0
  58. package/dist/esm/index.d.ts +8 -1
  59. package/dist/esm/index.js +5 -2
  60. package/dist/esm/load-plugins.d.ts +1 -1
  61. package/dist/esm/load-plugins.js +7 -2
  62. package/dist/esm/plugins/acl.js +109 -42
  63. package/dist/esm/plugins/condstore.js +7 -0
  64. package/dist/esm/plugins/index.js +1 -3
  65. package/dist/esm/plugins/metadata.js +140 -57
  66. package/dist/esm/plugins/objectid.js +10 -0
  67. package/dist/esm/plugins/quota.js +50 -2
  68. package/dist/esm/plugins/savedate.js +1 -1
  69. package/dist/esm/plugins/special-use.d.ts +0 -8
  70. package/dist/esm/plugins/special-use.js +23 -0
  71. package/dist/esm/quirks.d.ts +28 -0
  72. package/dist/esm/quirks.js +120 -0
  73. package/dist/esm/random.d.ts +10 -0
  74. package/dist/esm/random.js +19 -0
  75. package/dist/esm/rest.d.ts +50 -0
  76. package/dist/esm/rest.js +553 -0
  77. package/dist/esm/script.d.ts +33 -4
  78. package/dist/esm/script.js +63 -4
  79. package/dist/esm/server.d.ts +147 -8
  80. package/dist/esm/server.js +399 -145
  81. package/dist/esm/smtp-listener.d.ts +3 -2
  82. package/dist/esm/smtp-listener.js +10 -6
  83. package/dist/esm/storage-schema.d.ts +139 -0
  84. package/dist/esm/storage-schema.js +211 -0
  85. package/dist/esm/store-operations.d.ts +168 -0
  86. package/dist/esm/store-operations.js +407 -0
  87. package/dist/esm/types.d.ts +22 -1
  88. package/dist/plugin-help.json +0 -14
  89. package/package.json +11 -3
  90. package/dist/cjs/plugins/xtoybird.d.ts +0 -2
  91. package/dist/cjs/plugins/xtoybird.js +0 -236
  92. package/dist/esm/plugins/xtoybird.d.ts +0 -2
  93. package/dist/esm/plugins/xtoybird.js +0 -218
@@ -47,12 +47,17 @@ function resolvePlugin(name) {
47
47
  * @param {Object} server IMAPServer instance
48
48
  * @param {Array|String|Function} plugins List of plugins to load
49
49
  */
50
- function loadPlugins(server, plugins) {
50
+ function loadPlugins(server, plugins, exclude) {
51
51
  const loaded = new Set();
52
+ // plugins a quirk preset leaves out (no-move), they are not loaded even when listed
53
+ const excluded = new Set((exclude || []).map(name => resolvePlugin(name)).flatMap(name => (name ? [builtinPlugins[name]] : [])));
52
54
  const load = (entry) => {
53
55
  let plugin = entry;
54
56
  if (typeof plugin === 'string') {
55
57
  const name = resolvePlugin(plugin);
58
+ if (!name && plugin.toUpperCase() === 'XTOYBIRD') {
59
+ throw new Error('XTOYBIRD was removed in 5.0.0, use the control API (server.control.snapshot(), server.control.addUser() ...) instead, see the README');
60
+ }
56
61
  if (!name) {
57
62
  throw new Error('Unknown plugin "' + plugin + '". Available plugins: ' + listPlugins().join(', '));
58
63
  }
@@ -61,7 +66,7 @@ function loadPlugins(server, plugins) {
61
66
  if (typeof plugin !== 'function') {
62
67
  throw new TypeError('Invalid plugin, expecting a plugin name or a function');
63
68
  }
64
- if (loaded.has(plugin)) {
69
+ if (loaded.has(plugin) || excluded.has(plugin)) {
65
70
  return;
66
71
  }
67
72
  loaded.add(plugin);
@@ -5,6 +5,7 @@ import { getListExtensions } from '../list-extensions.js';
5
5
  import { beginSelect } from '../commands/select.js';
6
6
  import { getPendingTarget } from '../commands/append.js';
7
7
  import { isAstring } from '../arguments.js';
8
+ import { storeError } from '../store-operations.js';
8
9
  /**
9
10
  * @help Adds ACL [RFC4314] capability with RIGHTS=texk
10
11
  * @help The owner (server option "aclOwner", default "testuser")
@@ -199,6 +200,33 @@ export default function aclPlugin(server) {
199
200
  change(acl);
200
201
  server.emit('acl', mailbox, previous);
201
202
  };
203
+ /**
204
+ * Changes the rights of an identifier like SETACL (RFC 4314 section 3.1): "+" adds, "-" removes, no mode
205
+ * replaces. An identifier without rights is removed from the ACL
206
+ *
207
+ * @param {Object} mailbox Mailbox
208
+ * @param {String} identifier Identifier
209
+ * @param {String} mode "+", "-" or ""
210
+ * @param {Set} rights Parsed rights
211
+ */
212
+ const applyRights = (mailbox, identifier, mode, rights) => {
213
+ changeAcl(mailbox, acl => {
214
+ const current = new Set(acl.get(identifier) || []);
215
+ if (mode === '+') {
216
+ rights.forEach(right => current.add(right));
217
+ }
218
+ else if (mode === '-') {
219
+ rights.forEach(right => current.delete(right));
220
+ }
221
+ const updated = mode ? current : rights;
222
+ if (updated.size) {
223
+ acl.set(identifier, updated);
224
+ }
225
+ else {
226
+ acl.delete(identifier);
227
+ }
228
+ });
229
+ };
202
230
  // CATENATE (RFC 4469 section 5): reading a message through an IMAP URL needs the "r" right, a
203
231
  // mailbox without "l" is reported like one that does not exist (RFC 4314 section 6)
204
232
  server.urlAccessChecks.push((connection, mailbox) => {
@@ -354,22 +382,7 @@ export default function aclPlugin(server) {
354
382
  if (!checkAclCommand(connection, parsed, data, mailbox, hasAdmin) || refuseOwnerIdentifier(connection, parsed, data)) {
355
383
  return;
356
384
  }
357
- changeAcl(mailbox, acl => {
358
- const current = new Set(acl.get(identifier) || []);
359
- if (mode === '+') {
360
- rights.forEach(right => current.add(right));
361
- }
362
- else if (mode === '-') {
363
- rights.forEach(right => current.delete(right));
364
- }
365
- const updated = mode ? current : rights;
366
- if (updated.size) {
367
- acl.set(identifier, updated);
368
- }
369
- else {
370
- acl.delete(identifier);
371
- }
372
- });
385
+ applyRights(mailbox, identifier, mode, rights);
373
386
  connection.sendStatus(parsed, data, 'OK', 'Setacl complete');
374
387
  }), Object.assign({ astringArguments: [1, 2] }, aclOptions));
375
388
  // RFC 4314 section 3.2
@@ -467,7 +480,7 @@ export default function aclPlugin(server) {
467
480
  }
468
481
  flags = [].concat(flags);
469
482
  // invalid flags are still refused with BAD, even if the user could not set them anyway
470
- flags.forEach((flag) => checkSystemFlags(connection, normalizeSystemFlag(flagValue(flag))));
483
+ flags.forEach((flag) => checkSystemFlags(connection.server, normalizeSystemFlag(flagValue(flag))));
471
484
  let allowed = flags.filter((flag) => canSetFlag(rights, flag));
472
485
  if (item.charAt(0) !== '+' && item.charAt(0) !== '-') {
473
486
  // replacing the flags keeps the ones the user can not change
@@ -562,7 +575,7 @@ export default function aclPlugin(server) {
562
575
  const flags = parsed.attributes[1];
563
576
  if (Array.isArray(flags) && flags.every((flag) => flag && flag.type === 'ATOM')) {
564
577
  try {
565
- flags.forEach((flag) => checkSystemFlags(connection, normalizeSystemFlag(flag.value)));
578
+ flags.forEach((flag) => checkSystemFlags(connection.server, normalizeSystemFlag(flag.value)));
566
579
  // the server MUST NOT fail APPEND for flags the user can not set
567
580
  parsed.attributes[1] = flags.filter((flag) => canSetFlag(rights, flag));
568
581
  }
@@ -734,7 +747,7 @@ export default function aclPlugin(server) {
734
747
  }
735
748
  const flags = [].concat(args[args.length - 1] || []);
736
749
  try {
737
- flags.forEach(flag => checkSystemFlags(connection, normalizeSystemFlag(flagValue(flag))));
750
+ flags.forEach(flag => checkSystemFlags(connection.server, normalizeSystemFlag(flagValue(flag))));
738
751
  }
739
752
  catch {
740
753
  // invalid flags are refused with BAD by the STORE handler
@@ -833,38 +846,92 @@ export default function aclPlugin(server) {
833
846
  });
834
847
  }
835
848
  });
836
- // The DELETE command MUST delete the ACL of the mailbox (RFC 4314 section 4), this matters when a
837
- // \Noselect placeholder stays for the children of the mailbox
838
- // DELETE and CREATE are core commands, so the handlers exist
839
- const prevDelete = server.getCommandHandler('DELETE');
840
- server.setCommandHandler('DELETE', (connection, parsed, data, callback) => {
841
- const mailbox = argumentMailbox(parsed, 0);
842
- prevDelete(connection, parsed, data, () => {
843
- const placeholder = argumentMailbox(parsed, 0);
844
- if (placeholder && placeholder !== mailbox) {
849
+ server.on('mailbox', (event) => {
850
+ if (event.type === 'delete') {
851
+ // DELETE MUST delete the ACL of the mailbox (RFC 4314 section 4), this matters when a \Noselect
852
+ // placeholder stays for the children of the mailbox
853
+ const placeholder = server.getMailbox(event.path);
854
+ if (placeholder && placeholder !== event.mailbox) {
845
855
  delete placeholder.acl;
846
856
  }
847
- callback();
848
- });
849
- });
850
- // A new mailbox inherits the ACL of its parent (RFC 4314 section 4, CREATE SHOULD)
851
- const prevCreate = server.getCommandHandler('CREATE');
852
- server.setCommandHandler('CREATE', (connection, parsed, data, callback) => {
853
- const existing = new Set(Object.keys(server.folderCache));
854
- prevCreate(connection, parsed, data, () => {
855
- Object.keys(server.folderCache)
856
- .filter(path => !existing.has(path))
857
- .sort((a, b) => a.length - b.length)
858
- .forEach(path => {
857
+ }
858
+ else if (event.type === 'create') {
859
+ // A new mailbox inherits the ACL of its parent (RFC 4314 section 4, CREATE SHOULD)
860
+ (event.created || []).forEach((path) => {
859
861
  const parent = getParent(path);
860
862
  const mailbox = server.folderCache[path];
861
- if (parent && parent !== mailbox) {
863
+ if (parent && mailbox && parent !== mailbox) {
862
864
  const acl = new Map();
863
865
  getAcl(parent).forEach((rights, identifier) => acl.set(identifier, new Set(rights)));
864
866
  mailbox.acl = acl;
865
867
  }
866
868
  });
867
- callback();
869
+ }
870
+ });
871
+ // Control API (README "Control API"): ACLs as objects of identifier to rights, like the storage option
872
+ const aclObject = (mailbox) => {
873
+ const result = {};
874
+ getAcl(mailbox).forEach((rights, identifier) => {
875
+ result[identifier] = formatRights(rights);
868
876
  });
877
+ return result;
878
+ };
879
+ const controlMailbox = (path) => server.control.requireMailbox(path);
880
+ // the snapshot keeps ACLs in the form of the storage option, the parsed ACL is a Map
881
+ server.control.snapshotHandlers.push((mailbox, copy) => {
882
+ if (mailbox.acl instanceof Map) {
883
+ copy.acl = aclObject(mailbox);
884
+ }
869
885
  });
886
+ const getAclOperation = (path) => aclObject(controlMailbox(path));
887
+ // rights replace the rights of the identifier, "+rights" adds and "-rights" removes like SETACL (RFC 4314
888
+ // section 3.1), empty rights remove the identifier
889
+ const setAclOperation = (path, identifier, rights) => {
890
+ const mailbox = controlMailbox(path);
891
+ if (typeof identifier !== 'string' || !identifier || typeof rights !== 'string') {
892
+ throw storeError('setAcl expects an identifier and a rights string', 'INVALID');
893
+ }
894
+ const mode = /^[+-]/.test(rights) ? rights.charAt(0) : '';
895
+ let parsed;
896
+ try {
897
+ parsed = parseRights(rights.substr(mode.length));
898
+ }
899
+ catch (err) {
900
+ throw storeError(err.message, 'INVALID');
901
+ }
902
+ applyRights(mailbox, identifier, mode, parsed);
903
+ return aclObject(mailbox);
904
+ };
905
+ const deleteAclOperation = (path, identifier) => {
906
+ const mailbox = controlMailbox(path);
907
+ if (typeof identifier !== 'string' || !getAcl(mailbox).has(identifier)) {
908
+ throw storeError('No ACL entry for ' + JSON.stringify(identifier), 'NONEXISTENT');
909
+ }
910
+ changeAcl(mailbox, acl => acl.delete(identifier));
911
+ return aclObject(mailbox);
912
+ };
913
+ server.control.register('getAcl', getAclOperation, [
914
+ {
915
+ method: 'GET',
916
+ path: '/v1/mailboxes/{path}/acl',
917
+ summary: 'The ACL of a mailbox (ACL plugin)',
918
+ handler: ({ params }) => server.control.getAcl(params.path)
919
+ }
920
+ ]);
921
+ server.control.register('setAcl', setAclOperation, [
922
+ {
923
+ method: 'PUT',
924
+ path: '/v1/mailboxes/{path}/acl/{identifier}',
925
+ summary: 'Sets the rights of an identifier ({ rights }, "+" or "-" in front adds or removes, ACL plugin)',
926
+ handler: ({ params, body }) => server.control.setAcl(params.path, params.identifier, body.rights)
927
+ }
928
+ ]);
929
+ server.control.register('deleteAcl', deleteAclOperation, [
930
+ {
931
+ method: 'DELETE',
932
+ path: '/v1/mailboxes/{path}/acl/{identifier}',
933
+ summary: 'Removes an identifier from the ACL (ACL plugin)',
934
+ handler: ({ params }) => server.control.deleteAcl(params.path, params.identifier)
935
+ }
936
+ ]);
870
937
  }
@@ -75,6 +75,13 @@ export default function condstorePlugin(server) {
75
75
  server.on('expunge', mailbox => {
76
76
  bumpModseq(mailbox);
77
77
  });
78
+ // Flags changed outside of STORE (the control API, see changeFlags() in store-operations.ts): every changed
79
+ // message gets a new mod-sequence (RFC 7162 section 3.1.1), before the sessions are told
80
+ server.on('flags', (mailbox, messages) => {
81
+ messages.forEach(message => {
82
+ message.MODSEQ = bumpModseq(mailbox);
83
+ });
84
+ });
78
85
  // RFC 7162 section 3.2.11: SELECT or EXAMINE closes the selected mailbox, a CONDSTORE server marks
79
86
  // where the responses for the new mailbox start
80
87
  server.closedChecks.push(() => true);
@@ -53,7 +53,6 @@ import unselectPlugin from './unselect.js';
53
53
  import utf8AcceptPlugin from './utf8-accept.js';
54
54
  import xGmExt1Plugin from './x-gm-ext-1.js';
55
55
  import xoauth2Plugin from './xoauth2.js';
56
- import xtoybirdPlugin from './xtoybird.js';
57
56
  export const plugins = {
58
57
  acl: aclPlugin,
59
58
  appendlimit: appendlimitPlugin,
@@ -108,6 +107,5 @@ export const plugins = {
108
107
  unselect: unselectPlugin,
109
108
  'utf8-accept': utf8AcceptPlugin,
110
109
  'x-gm-ext-1': xGmExt1Plugin,
111
- xoauth2: xoauth2Plugin,
112
- xtoybird: xtoybirdPlugin
110
+ xoauth2: xoauth2Plugin
113
111
  };
@@ -1,3 +1,4 @@
1
+ import { storeError } from '../store-operations.js';
1
2
  import { states } from '../command-states.js';
2
3
  import { isNumber } from '../numbers.js';
3
4
  import { isAstring } from '../arguments.js';
@@ -308,6 +309,60 @@ function setup(server, mailboxes) {
308
309
  return callback();
309
310
  }, { states: states.AUTHENTICATED });
310
311
  // RFC 5464 section 4.3: setmetadata = "SETMETADATA" SP mailbox SP entry-values
312
+ /**
313
+ * Stores the changes of SETMETADATA (or of the control API) when all of them pass the checks, and tells the
314
+ * sessions that used ENABLE METADATA about the changed entries. The session that made the change
315
+ * (`server.activeConnection`) is not told, and without one (the control API) the read-only server entries can
316
+ * be set too
317
+ *
318
+ * @param {Object} holder Mailbox object or state.server
319
+ * @param {Map} changes Lower case entry name to the new value, null removes the entry
320
+ * @return {Object|null} `{ text, code }` when a change fails, nothing is changed then
321
+ */
322
+ const storeChanges = (holder, changes) => {
323
+ const origin = server.activeConnection;
324
+ const entries = getEntries(holder);
325
+ const computed = getComputedEntries(holder);
326
+ // changes go to a copy that replaces the entries once every check passed
327
+ const next = Object.assign(Object.create(null), entries);
328
+ const changed = [];
329
+ for (const [name, value] of changes) {
330
+ if ((!holder.path && origin && READ_ONLY_SERVER_ENTRIES.has(name)) || name in computed) {
331
+ return { text: 'The ' + name + ' entry is read-only', code: 'CANNOT' };
332
+ }
333
+ if (!state.allowPrivate && name.startsWith('/private/')) {
334
+ return { text: 'Private annotations are not supported', code: ['METADATA', 'NOPRIVATE'] };
335
+ }
336
+ if (value !== null && value.length > state.maxSize) {
337
+ return { text: 'Value of ' + name + ' is too large', code: ['METADATA', 'MAXSIZE', state.maxSize] };
338
+ }
339
+ if ((entries[name] ?? null) !== value) {
340
+ changed.push(name);
341
+ }
342
+ if (value === null) {
343
+ delete next[name];
344
+ }
345
+ else {
346
+ next[name] = value;
347
+ }
348
+ }
349
+ // only adding entries can fail, even when the storage already holds more than the limit
350
+ const count = Object.keys(next).length;
351
+ if (count > state.maxEntries && count > Object.keys(entries).length) {
352
+ return { text: 'Too many annotations', code: ['METADATA', 'TOOMANY'] };
353
+ }
354
+ setEntries(holder, next);
355
+ if (changed.length) {
356
+ // RFC 5464 section 4.4.2: unsolicited responses only list the entry names. Section 4.1:
357
+ // only sessions that used ENABLE METADATA (or METADATA-SERVER) get them
358
+ server.notify({
359
+ tag: '*',
360
+ command: 'METADATA',
361
+ attributes: [mailboxAttribute(holder.path)].concat(changed.map(name => ({ type: 'ATOM', value: name })))
362
+ }, false, origin, isMetadataEnabled);
363
+ }
364
+ return null;
365
+ };
311
366
  server.setCommandHandler('SETMETADATA', (connection, parsed, data, callback) => {
312
367
  const attrs = parsed.attributes || [];
313
368
  if (attrs.length !== 2 || !isAstring(attrs[0]) || !Array.isArray(attrs[1])) {
@@ -343,77 +398,105 @@ function setup(server, mailboxes) {
343
398
  if (!holder) {
344
399
  return callback();
345
400
  }
346
- const entries = getEntries(holder);
347
- const computed = getComputedEntries(holder);
348
- const fail = (text, code) => {
401
+ const failure = storeChanges(holder, changes);
402
+ if (failure) {
349
403
  // RFC 5464 section 4.3: when one entry fails, no entry is changed
350
- connection.sendStatus(parsed, data, 'NO', text, code);
404
+ connection.sendStatus(parsed, data, 'NO', failure.text, failure.code);
351
405
  return callback();
352
- };
353
- // changes go to a copy that replaces the entries once every check passed
354
- const next = Object.assign(Object.create(null), entries);
355
- const changed = [];
356
- for (const [name, value] of changes) {
357
- if ((!holder.path && READ_ONLY_SERVER_ENTRIES.has(name)) || name in computed) {
358
- return fail('The ' + name + ' entry is read-only', 'CANNOT');
359
- }
360
- if (!state.allowPrivate && name.startsWith('/private/')) {
361
- return fail('Private annotations are not supported', ['METADATA', 'NOPRIVATE']);
362
- }
363
- if (value !== null && value.length > state.maxSize) {
364
- return fail('Value of ' + name + ' is too large', ['METADATA', 'MAXSIZE', state.maxSize]);
365
- }
366
- if ((entries[name] ?? null) !== value) {
367
- changed.push(name);
368
- }
369
- if (value === null) {
370
- delete next[name];
371
- }
372
- else {
373
- next[name] = value;
374
- }
375
- }
376
- // only adding entries can fail, even when the storage already holds more than the limit
377
- const count = Object.keys(next).length;
378
- if (count > state.maxEntries && count > Object.keys(entries).length) {
379
- return fail('Too many annotations', ['METADATA', 'TOOMANY']);
380
- }
381
- setEntries(holder, next);
382
- if (changed.length) {
383
- // RFC 5464 section 4.4.2: unsolicited responses only list the entry names. Section 4.1:
384
- // only sessions that used ENABLE METADATA (or METADATA-SERVER) get them
385
- server.notify({
386
- tag: '*',
387
- command: 'METADATA',
388
- attributes: [mailboxAttribute(holder.path)].concat(changed.map(name => ({ type: 'ATOM', value: name })))
389
- }, false, connection, isMetadataEnabled);
390
406
  }
391
407
  connection.sendStatus(parsed, data, 'OK', 'SETMETADATA completed');
392
408
  return callback();
393
409
  },
394
410
  // RFC 5464 section 5: values can be literal8, with or without BINARY
395
411
  { states: states.AUTHENTICATED, mailboxArguments: [0], literal8: true });
396
- server.outputHandlers.push((connection, response, description, parsed, data, extra) => {
397
- if (response.command !== 'OK' || !parsed || !parsed.attributes) {
398
- return;
399
- }
400
- if (description === 'RENAME' && extra && extra.path === 'INBOX') {
401
- // RFC 5464 section 4.1: renaming INBOX copies its annotations, INBOX keeps them. Other
402
- // mailboxes are moved as objects, so their annotations follow without any help
403
- const target = connection.server.getMailbox(parsed.attributes[1].value);
404
- if (target && target !== extra && extra.metadata) {
405
- setEntries(target, Object.assign(Object.create(null), getEntries(extra)));
412
+ server.on('mailbox', (event) => {
413
+ if (event.type === 'rename' && event.oldPath === 'INBOX') {
414
+ // RFC 5464 section 4.1: renaming INBOX copies its annotations, INBOX keeps them. Other mailboxes are
415
+ // moved as objects, so their annotations follow without any help
416
+ const inbox = server.getMailbox('INBOX');
417
+ const target = server.getMailbox(event.path);
418
+ if (inbox && target && target !== inbox && inbox.metadata) {
419
+ setEntries(target, Object.assign(Object.create(null), getEntries(inbox)));
406
420
  }
407
421
  }
408
- else if (description === 'DELETE') {
409
- // RFC 5464 section 4.1: a mailbox created later with the same name must not inherit the
410
- // annotations. A mailbox with children stays as a \Noselect placeholder
411
- const placeholder = connection.server.getMailbox(parsed.attributes[0].value);
412
- if (placeholder) {
422
+ else if (event.type === 'delete') {
423
+ // RFC 5464 section 4.1: a mailbox created later with the same name must not inherit the annotations.
424
+ // A mailbox with children stays as a \Noselect placeholder
425
+ const placeholder = server.getMailbox(event.path);
426
+ if (placeholder && placeholder !== event.mailbox) {
413
427
  delete placeholder.metadata;
414
428
  }
415
429
  }
416
430
  });
431
+ // Control API (README "Control API"): the stored annotations of a mailbox, or of the server for "". Values are
432
+ // UTF-8 text, the protocol keeps them as octets
433
+ // control.reset() restores the server annotations of the options, the mailboxes come with their storage
434
+ server.on('reset', () => {
435
+ state.server = { path: '', metadata: options.metadata };
436
+ });
437
+ const controlHolder = (path) => {
438
+ if (path === '') {
439
+ return state.server;
440
+ }
441
+ if (!state.mailboxes) {
442
+ throw storeError('Mailbox annotations need the METADATA plugin, METADATA-SERVER has server annotations only', 'INVALID');
443
+ }
444
+ return server.control.requireMailbox(path);
445
+ };
446
+ const getMetadataOperation = (path) => {
447
+ const entries = getEntries(controlHolder(path));
448
+ const result = {};
449
+ Object.keys(entries).forEach(name => {
450
+ result[name] = Buffer.from(entries[name], 'binary').toString('utf-8');
451
+ });
452
+ return result;
453
+ };
454
+ const setMetadataOperation = (path, values) => {
455
+ const holder = controlHolder(path);
456
+ if (!values || typeof values !== 'object' || Array.isArray(values)) {
457
+ throw storeError('setMetadata expects an object of entry names and values (null removes an entry)', 'INVALID');
458
+ }
459
+ const changes = new Map();
460
+ Object.keys(values).forEach(name => {
461
+ const value = values[name];
462
+ const error = checkEntryName(name, true);
463
+ if (error) {
464
+ throw storeError(error, 'INVALID');
465
+ }
466
+ if (value !== null && typeof value !== 'string') {
467
+ throw storeError('Value of ' + name + ' must be a string or null', 'INVALID');
468
+ }
469
+ changes.set(name.toLowerCase(), value === null ? null : Buffer.from(value, 'utf-8').toString('binary'));
470
+ });
471
+ const failure = storeChanges(holder, changes);
472
+ if (failure) {
473
+ throw storeError(failure.text, typeof failure.code === 'string' ? failure.code : 'INVALID');
474
+ }
475
+ return getMetadataOperation(path);
476
+ };
477
+ server.control.register('getMetadata', getMetadataOperation, [
478
+ { method: 'GET', path: '/v1/metadata', summary: 'Server annotations (METADATA plugins)', handler: () => server.control.getMetadata('') },
479
+ {
480
+ method: 'GET',
481
+ path: '/v1/mailboxes/{path}/metadata',
482
+ summary: 'Mailbox annotations (METADATA plugin)',
483
+ handler: ({ params }) => server.control.getMetadata(params.path)
484
+ }
485
+ ]);
486
+ server.control.register('setMetadata', setMetadataOperation, [
487
+ {
488
+ method: 'PUT',
489
+ path: '/v1/metadata',
490
+ summary: 'Sets server annotations ({ "/shared/comment": "text" }, null removes one, METADATA plugins)',
491
+ handler: ({ body }) => server.control.setMetadata('', body)
492
+ },
493
+ {
494
+ method: 'PUT',
495
+ path: '/v1/mailboxes/{path}/metadata',
496
+ summary: 'Sets mailbox annotations (METADATA plugin)',
497
+ handler: ({ params, body }) => server.control.setMetadata(params.path, body)
498
+ }
499
+ ]);
417
500
  }
418
501
  export default function metadataPlugin(server) {
419
502
  setup(server, true);
@@ -95,6 +95,16 @@ export default function objectidPlugin(server) {
95
95
  }
96
96
  getMailboxId(mailbox);
97
97
  });
98
+ // Control API (README "Control API"): the object ids in getMailbox() and getMessage()
99
+ server.control.mailboxInfoHandlers.push((mailbox, info) => {
100
+ if (mailbox.flags.indexOf('\\Noselect') < 0) {
101
+ info.mailboxId = getMailboxId(mailbox);
102
+ }
103
+ });
104
+ server.control.messageInfoHandlers.push((message, info) => {
105
+ info.emailId = message.EMAILID;
106
+ info.threadId = message.THREADID;
107
+ });
98
108
  server.messageHandlers.push((server, message, mailbox) => {
99
109
  const owner = 'a message of mailbox ' + mailbox.path;
100
110
  if (message.EMAILID) {
@@ -1,3 +1,4 @@
1
+ import { storeError } from '../store-operations.js';
1
2
  import { states } from '../command-states.js';
2
3
  import { normalizeLineBreaks } from '../mimeparser.js';
3
4
  import { MAX_NUMBER64, isNumber } from '../numbers.js';
@@ -29,6 +30,8 @@ export default function quotaPlugin(server) {
29
30
  soft: !!config.soft,
30
31
  limits: {}
31
32
  };
33
+ // the limits of the quota option, control.reset() restores them
34
+ const initialLimits = {};
32
35
  RESOURCES.forEach(resource => {
33
36
  if (config[resource] === undefined || config[resource] === null) {
34
37
  return;
@@ -37,8 +40,13 @@ export default function quotaPlugin(server) {
37
40
  if (!Number.isSafeInteger(limit) || limit < 0) {
38
41
  throw new Error('Invalid quota limit for ' + resource + ': ' + JSON.stringify(config[resource]));
39
42
  }
40
- root.limits[resource] = limit;
43
+ initialLimits[resource] = limit;
41
44
  });
45
+ const restoreLimits = () => {
46
+ root.limits = Object.assign({}, initialLimits);
47
+ };
48
+ restoreLimits();
49
+ server.on('reset', restoreLimits);
42
50
  // RFC 9208 sections 3.1.1 and 5: every supported resource is advertised, SETQUOTA needs QUOTASET
43
51
  server.registerCapability('QUOTA');
44
52
  RESOURCES.forEach(resource => server.registerCapability('QUOTA=RES-' + resource));
@@ -111,7 +119,7 @@ export default function quotaPlugin(server) {
111
119
  }
112
120
  const added = { MESSAGE: messages.length, bytes: totalSize(messages) };
113
121
  // RFC 8508 section 3.4: REPLACE counts only the net usage, the replaced message is removed
114
- if (options.replaced && connection.selectedMailbox && inRoot(connection.selectedMailbox)) {
122
+ if (options.replaced && connection && connection.selectedMailbox && inRoot(connection.selectedMailbox)) {
115
123
  added.MESSAGE--;
116
124
  added.bytes -= totalSize([options.replaced]);
117
125
  }
@@ -236,4 +244,44 @@ export default function quotaPlugin(server) {
236
244
  }
237
245
  });
238
246
  server.statusHandlers['DELETED-STORAGE'] = (connection, mailbox) => totalSize(mailbox.messages.filter(message => message.flags.indexOf('\\Deleted') >= 0));
247
+ // Control API (README "Control API"): the quota root with its limits and usage, STORAGE in units of 1024 octets
248
+ const getQuotaOperation = () => {
249
+ const usage = getUsage();
250
+ return {
251
+ root: root.name,
252
+ limits: Object.assign({}, root.limits),
253
+ usage: { STORAGE: Math.ceil(usage.bytes / 1024), MESSAGE: usage.MESSAGE, MAILBOX: usage.MAILBOX }
254
+ };
255
+ };
256
+ // the new limits replace all earlier ones, like SETQUOTA (RFC 9208 section 4.1.3)
257
+ const setQuotaOperation = (limits) => {
258
+ if (!limits || typeof limits !== 'object' || Array.isArray(limits)) {
259
+ throw storeError('setQuota expects an object of resource limits, e.g. { STORAGE: 1024 }', 'INVALID');
260
+ }
261
+ const next = {};
262
+ Object.keys(limits).forEach(name => {
263
+ const resource = name.toUpperCase();
264
+ const value = limits[name];
265
+ if (RESOURCES.indexOf(resource) < 0) {
266
+ throw storeError('Unknown quota resource ' + name + ', expected ' + RESOURCES.join(', '), 'INVALID');
267
+ }
268
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
269
+ throw storeError('Limit of ' + resource + ' must be a non-negative integer', 'INVALID');
270
+ }
271
+ next[resource] = value;
272
+ });
273
+ root.limits = next;
274
+ return getQuotaOperation();
275
+ };
276
+ server.control.register('getQuota', getQuotaOperation, [
277
+ { method: 'GET', path: '/v1/quota', summary: 'Quota root, limits and usage (QUOTA plugin)', handler: () => server.control.getQuota() }
278
+ ]);
279
+ server.control.register('setQuota', setQuotaOperation, [
280
+ {
281
+ method: 'PUT',
282
+ path: '/v1/quota',
283
+ summary: 'Replaces the quota limits ({ STORAGE, MESSAGE, MAILBOX }, QUOTA plugin)',
284
+ handler: ({ body }) => server.control.setQuota(body)
285
+ }
286
+ ]);
239
287
  }
@@ -25,7 +25,7 @@ export default function savedatePlugin(server) {
25
25
  delete message.SAVEDATE;
26
26
  return;
27
27
  }
28
- const savedate = server.normalizeDateTime(message.SAVEDATE || new Date());
28
+ const savedate = server.normalizeDateTime(message.SAVEDATE || server.now());
29
29
  if (!server.validateInternalDate(savedate)) {
30
30
  throw new Error('Invalid SAVEDATE value ' + JSON.stringify(savedate) + ' in mailbox ' + mailbox.path);
31
31
  }
@@ -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;
@@ -1,4 +1,5 @@
1
1
  import { getListExtensions } from '../list-extensions.js';
2
+ import { storeError } from '../store-operations.js';
2
3
  /**
3
4
  * @help Enables SPECIAL-USE [RFC6154] capability
4
5
  * @help Mailboxes need to have a "special-use"
@@ -7,6 +8,8 @@ import { getListExtensions } from '../list-extensions.js';
7
8
  * @help With LIST-EXTENDED the SPECIAL-USE selection and
8
9
  * @help return options work with the other LIST options
9
10
  */
11
+ // RFC 6154 section 2 and RFC 8457 (\\Important)
12
+ const SPECIAL_USES = new Set(['\\All', '\\Archive', '\\Drafts', '\\Flagged', '\\Junk', '\\Sent', '\\Trash', '\\Important']);
10
13
  export default function specialUsePlugin(server) {
11
14
  // Register capability
12
15
  server.registerCapability('SPECIAL-USE');
@@ -77,4 +80,24 @@ export default function specialUsePlugin(server) {
77
80
  }
78
81
  }
79
82
  });
83
+ // Control API (README "Control API"): the special-use attributes of a mailbox (RFC 6154 section 2)
84
+ server.control.mailboxInfoHandlers.push((mailbox, info) => {
85
+ info.specialUse = getSpecialUse(mailbox);
86
+ });
87
+ const setSpecialUseOperation = (path, uses) => {
88
+ const mailbox = server.control.requireMailbox(path, true);
89
+ if (!Array.isArray(uses) || uses.some(use => typeof use !== 'string' || !SPECIAL_USES.has(use))) {
90
+ throw storeError('Special-use attributes must be a list of ' + [...SPECIAL_USES].join(', '), 'INVALID');
91
+ }
92
+ mailbox['special-use'] = [...new Set(uses)];
93
+ return server.control.getMailbox(mailbox.path);
94
+ };
95
+ server.control.register('setSpecialUse', setSpecialUseOperation, [
96
+ {
97
+ method: 'PUT',
98
+ path: '/v1/mailboxes/{path}/special-use',
99
+ summary: 'Sets the special-use attributes of a mailbox ({ specialUse: ["\\Sent"] }, SPECIAL-USE plugin)',
100
+ handler: ({ params, body }) => server.control.setSpecialUse(params.path, body.specialUse)
101
+ }
102
+ ]);
80
103
  }