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
@@ -0,0 +1,630 @@
1
+ /**
2
+ * The control API, `server.control`: inspects and changes the server state from a test, without an IMAP session.
3
+ * Every change reaches the connected sessions as if another session had made it (EXISTS, EXPUNGE or VANISHED,
4
+ * unsolicited FETCH, BYE, NOTIFY events, CONDSTORE mod-sequences), with no session as its origin. The API is the
5
+ * operator: ACL does not apply, but every argument is checked. Mailboxes are addressed by their storage name
6
+ * (modified UTF-7), messages by mailbox and UID. Methods return plain data and throw an ImapKitError with a `code`:
7
+ * NONEXISTENT, ALREADYEXISTS, INVALID, or a RFC 5530 code of a failed mailbox operation (CANNOT, HASCHILDREN ...)
8
+ */
9
+ import validateMailboxName from './mailbox-name.js';
10
+ import { DEFAULT_SESSION_TIMEOUT, ImapKitError, expungeMessages, checkFlags, changeFlags, createMailbox, deleteMailbox, renameMailbox, resetUidValidity, subscribeMailbox, unsubscribeMailbox } from './store-operations.js';
11
+ /**
12
+ * Copies a value of a plugin into the snapshot as JSON data, values that JSON can not hold are left out
13
+ *
14
+ * @param {*} value Any value
15
+ * @return {*} a JSON copy, or undefined
16
+ */
17
+ function jsonCopy(value) {
18
+ if (value === undefined || typeof value === 'function') {
19
+ return undefined;
20
+ }
21
+ // strings, numbers and booleans are immutable, the message source does not need a round trip
22
+ return value === null || typeof value !== 'object' ? value : JSON.parse(JSON.stringify(value));
23
+ }
24
+ /**
25
+ * Copies the properties of an object as JSON data, except the listed keys and values JSON can not hold
26
+ *
27
+ * @param {Object} source Object to copy
28
+ * @param {Set} skip Keys to leave out
29
+ * @return {Object} copy
30
+ */
31
+ function copyProperties(source, skip) {
32
+ const result = {};
33
+ Object.keys(source).forEach(key => {
34
+ const value = skip.has(key) ? undefined : jsonCopy(source[key]);
35
+ if (value !== undefined) {
36
+ result[key] = value;
37
+ }
38
+ });
39
+ return result;
40
+ }
41
+ const MESSAGE_SKIP = new Set(['ghost']);
42
+ const NAMESPACE_SKIP = new Set(['folders']);
43
+ // properties the server derives when it loads a mailbox, a snapshot leaves them out
44
+ const DERIVED_MAILBOX_KEYS = new Set(['path', 'namespace', 'messages', 'folders', 'subscribed', 'flags']);
45
+ const DERIVED_FLAGS = new Set(['\\HasChildren', '\\HasNoChildren']);
46
+ class Control {
47
+ constructor(server) {
48
+ this.server = server;
49
+ this.routes = [];
50
+ this.snapshotHandlers = [];
51
+ this.mailboxInfoHandlers = [];
52
+ this.messageInfoHandlers = [];
53
+ }
54
+ /**
55
+ * Adds an operation of a plugin, e.g. `server.control.setAcl()` of ACL, so that a plugin that is not loaded
56
+ * leaves no trace. The operation runs with no session as its origin, like the built-in ones, and throws
57
+ * ImapKitErrors (see storeError() in store-operations.ts)
58
+ *
59
+ * @param {String} name Method name on server.control
60
+ * @param {Function} fn Operation
61
+ * @param {Array} [routes] REST routes of the operation
62
+ */
63
+ register(name, fn, routes) {
64
+ if (name in this) {
65
+ throw new Error('Control operation ' + name + ' exists already');
66
+ }
67
+ this[name] = (...args) => this.run(() => fn(...args));
68
+ this.routes.push(...(routes || []));
69
+ }
70
+ /**
71
+ * Runs a change with no session as its origin, also when a listener of a command event calls the API
72
+ *
73
+ * @param {Function} fn Change
74
+ * @return {*} the result of fn
75
+ */
76
+ run(fn) {
77
+ return this.server.withOrigin(null, fn);
78
+ }
79
+ /**
80
+ * Finds a mailbox by its storage name
81
+ *
82
+ * @param {String} path Storage name, INBOX in any case
83
+ * @param {Boolean} [selectable] true if the mailbox must hold messages (not \Noselect)
84
+ * @return {Object} mailbox
85
+ * @throws {ImapKitError} NONEXISTENT
86
+ */
87
+ requireMailbox(path, selectable) {
88
+ const mailbox = typeof path === 'string' && path ? this.server.getMailbox(path) : undefined;
89
+ if (!mailbox || (selectable && mailbox.flags.indexOf('\\Noselect') >= 0)) {
90
+ throw new ImapKitError('Mailbox ' + JSON.stringify(path) + ' does not exist', 'NONEXISTENT');
91
+ }
92
+ return mailbox;
93
+ }
94
+ /**
95
+ * Checks a new mailbox name
96
+ *
97
+ * @param {String} path Storage name
98
+ * @throws {ImapKitError} INVALID for a name that is not valid modified UTF-7
99
+ */
100
+ checkName(path) {
101
+ this.checkPath(path);
102
+ const problem = validateMailboxName(path);
103
+ if (problem) {
104
+ throw new ImapKitError(problem, 'INVALID');
105
+ }
106
+ }
107
+ /**
108
+ * Checks that a mailbox argument is a name, the store operations check the rest
109
+ *
110
+ * @param {String} path Storage name
111
+ * @throws {ImapKitError} INVALID
112
+ */
113
+ checkPath(path) {
114
+ if (typeof path !== 'string' || !path) {
115
+ throw new ImapKitError('Mailbox name must be a non-empty string', 'INVALID');
116
+ }
117
+ }
118
+ /**
119
+ * Finds messages by UID
120
+ *
121
+ * @param {Object} mailbox Mailbox
122
+ * @param {Array} uids UIDs of the messages
123
+ * @return {Array} messages in the order of the UIDs, without duplicates
124
+ * @throws {ImapKitError} INVALID for a list that is not UIDs, NONEXISTENT for a UID that is not in the mailbox
125
+ */
126
+ requireMessages(mailbox, uids) {
127
+ if (!Array.isArray(uids) || uids.some(uid => !Number.isSafeInteger(uid) || uid < 1)) {
128
+ throw new ImapKitError('UIDs must be an array of positive integers', 'INVALID');
129
+ }
130
+ const byUid = new Map(mailbox.messages.map(message => [message.uid, message]));
131
+ const missing = uids.filter(uid => !byUid.has(uid));
132
+ if (missing.length) {
133
+ throw new ImapKitError('No message with UID ' + missing.join(', ') + ' in ' + mailbox.path, 'NONEXISTENT');
134
+ }
135
+ return [...new Set(uids)].map(uid => byUid.get(uid));
136
+ }
137
+ /**
138
+ * Finds the sessions a filter selects
139
+ *
140
+ * @param {Number|Object} filter A session number, or `{ session, user }`
141
+ * @return {Array} connections
142
+ */
143
+ findSessions(filter) {
144
+ const query = typeof filter === 'number' ? { session: filter } : filter || {};
145
+ if (query.session === undefined && query.user === undefined) {
146
+ throw new ImapKitError('Select sessions by number or user', 'INVALID');
147
+ }
148
+ // a session that is closing already (BYE) is left out
149
+ return [...this.server.connections].filter(connection => connection.isOpen() &&
150
+ (query.session === undefined || connection.sessionNumber === query.session) &&
151
+ (query.user === undefined || connection.username === query.user));
152
+ }
153
+ // Inspection
154
+ /**
155
+ * Returns the storage as JSON data in the shape of the `storage` option, so that `imapkit({ storage })` with it
156
+ * starts from the same mailboxes, messages, UIDs, flags and subscriptions. Plugin data on mailboxes and messages
157
+ * is included as far as it is JSON data. Subscriptions of names that are not mailboxes are not part of it
158
+ *
159
+ * @return {Object} storage
160
+ */
161
+ snapshot() {
162
+ const server = this.server;
163
+ const copyMailbox = (mailbox) => {
164
+ const result = copyProperties(mailbox, DERIVED_MAILBOX_KEYS);
165
+ this.snapshotHandlers.forEach(handler => handler(mailbox, result));
166
+ result.flags = mailbox.flags.filter(flag => !DERIVED_FLAGS.has(flag));
167
+ result.subscribed = !!mailbox.subscribed;
168
+ result.messages = mailbox.messages.map(message => copyProperties(message, MESSAGE_SKIP));
169
+ if (mailbox.folders) {
170
+ result.folders = copyFolders(mailbox.folders);
171
+ }
172
+ return result;
173
+ };
174
+ const copyFolders = (folders) => {
175
+ const result = {};
176
+ Object.keys(folders).forEach(name => {
177
+ result[name] = copyMailbox(folders[name]);
178
+ });
179
+ return result;
180
+ };
181
+ const storage = {};
182
+ Object.keys(server.storage).forEach(key => {
183
+ if (key === 'INBOX') {
184
+ storage.INBOX = copyMailbox(server.storage.INBOX);
185
+ return;
186
+ }
187
+ const namespace = server.storage[key];
188
+ storage[key] = Object.assign(copyProperties(namespace, NAMESPACE_SKIP), { folders: copyFolders(namespace.folders || {}) });
189
+ });
190
+ return storage;
191
+ }
192
+ /**
193
+ * Describes every mailbox, including \Noselect hierarchy levels
194
+ *
195
+ * @return {Array} mailboxes ordered by name
196
+ */
197
+ listMailboxes() {
198
+ return Object.keys(this.server.folderCache)
199
+ .sort()
200
+ .map(path => this.describeMailbox(this.server.folderCache[path]));
201
+ }
202
+ /**
203
+ * Describes a mailbox
204
+ *
205
+ * @param {String} path Storage name
206
+ * @return {Object} mailbox info
207
+ */
208
+ getMailbox(path) {
209
+ return this.describeMailbox(this.requireMailbox(path));
210
+ }
211
+ describeMailbox(mailbox) {
212
+ const status = this.server.getStatus(mailbox);
213
+ const info = {
214
+ path: mailbox.path,
215
+ delimiter: this.server.getSeparator(mailbox),
216
+ flags: mailbox.flags.slice(),
217
+ selectable: mailbox.flags.indexOf('\\Noselect') < 0,
218
+ subscribed: !!mailbox.subscribed,
219
+ messages: mailbox.messages.length,
220
+ unseen: status ? status.unseen : 0,
221
+ uidnext: mailbox.uidnext,
222
+ uidvalidity: mailbox.uidvalidity,
223
+ permanentFlags: status ? status.permanentFlags.slice() : []
224
+ };
225
+ if (this.server.condstore && typeof mailbox.HIGHESTMODSEQ === 'number') {
226
+ info.highestModseq = mailbox.HIGHESTMODSEQ;
227
+ }
228
+ this.mailboxInfoHandlers.forEach(handler => handler(mailbox, info));
229
+ return info;
230
+ }
231
+ /**
232
+ * Describes the messages of a mailbox
233
+ *
234
+ * @param {String} path Storage name
235
+ * @param {Object} [options] `{ uids, raw }`: only these UIDs, include the message source
236
+ * @return {Array} messages ordered by UID
237
+ */
238
+ listMessages(path, options = {}) {
239
+ const mailbox = this.requireMailbox(path, true);
240
+ const messages = options.uids ? this.requireMessages(mailbox, options.uids).sort((a, b) => a.uid - b.uid) : mailbox.messages;
241
+ return messages.map(message => this.describeMessage(message, !!options.raw));
242
+ }
243
+ /**
244
+ * Describes a message
245
+ *
246
+ * @param {String} path Storage name
247
+ * @param {Number} uid UID of the message
248
+ * @param {Object} [options] `{ raw }`: include the message source, true by default
249
+ * @return {Object} message info
250
+ */
251
+ getMessage(path, uid, options = {}) {
252
+ const mailbox = this.requireMailbox(path, true);
253
+ return this.describeMessage(this.requireMessages(mailbox, [uid])[0], options.raw !== false);
254
+ }
255
+ describeMessage(message, raw) {
256
+ const info = {
257
+ uid: message.uid,
258
+ flags: message.flags.slice(),
259
+ internaldate: message.internaldate,
260
+ size: message.raw.length
261
+ };
262
+ if (this.server.condstore && typeof message.MODSEQ === 'number') {
263
+ info.modseq = message.MODSEQ;
264
+ }
265
+ if (raw) {
266
+ info.raw = Buffer.from(message.raw, 'binary');
267
+ }
268
+ this.messageInfoHandlers.forEach(handler => handler(message, info));
269
+ return info;
270
+ }
271
+ /**
272
+ * Describes the connected sessions
273
+ *
274
+ * @return {Array} sessions ordered by number
275
+ */
276
+ sessions() {
277
+ return [...this.server.connections].map(connection => connection.describe()).sort((a, b) => a.session - b.session);
278
+ }
279
+ // Messages
280
+ /**
281
+ * Adds a message to a mailbox, like a delivery from outside: sessions that have the mailbox selected get EXISTS,
282
+ * the first read-write one sees it as \Recent
283
+ *
284
+ * @param {String} path Storage name
285
+ * @param {Object} message `{ raw, flags, internaldate }`
286
+ * @param {Object} [options] `{ checks }`: true runs the checks APPEND runs (QUOTA, APPENDLIMIT ...), a failed
287
+ * one throws an ImapKitError with its response code, e.g. OVERQUOTA
288
+ * @return {Object} `{ uid, uidvalidity }`
289
+ */
290
+ addMessage(path, message, options = {}) {
291
+ const mailbox = this.requireMailbox(path, true);
292
+ const { raw, flags, internaldate } = message || {};
293
+ let source;
294
+ if (typeof raw === 'string') {
295
+ source = Buffer.from(raw, 'utf-8').toString('binary');
296
+ }
297
+ else if (raw instanceof Uint8Array) {
298
+ source = Buffer.from(raw).toString('binary');
299
+ }
300
+ else {
301
+ throw new ImapKitError('Message source (raw) must be a string or a Buffer', 'INVALID');
302
+ }
303
+ if (!source.length) {
304
+ throw new ImapKitError('Message source (raw) can not be empty', 'INVALID');
305
+ }
306
+ const list = checkFlags(this.server, mailbox, flags || [], true);
307
+ if (internaldate !== undefined &&
308
+ !(internaldate instanceof Date && !isNaN(internaldate.getTime())) &&
309
+ !this.server.validateInternalDate(internaldate)) {
310
+ throw new ImapKitError('Internal date must be a Date or a date-time string like "17-Jul-1996 02:44:25 -0700"', 'INVALID');
311
+ }
312
+ if (options.checks) {
313
+ const failure = this.server.appendChecks
314
+ .map(check => check(null, mailbox, [{ raw: source, flags: list, internaldate }], { command: 'APPEND' }))
315
+ .find(result => result && !result.soft);
316
+ if (failure) {
317
+ const code = Array.isArray(failure.code) ? failure.code[0] : failure.code;
318
+ throw new ImapKitError(failure.text, typeof code === 'string' ? code : 'CANNOT');
319
+ }
320
+ }
321
+ const added = this.run(() => this.server.appendMessage(mailbox, list, internaldate || null, source));
322
+ return { uid: added.message.uid, uidvalidity: mailbox.uidvalidity };
323
+ }
324
+ /**
325
+ * Changes the flags of messages. Sessions that have the mailbox selected get unsolicited FETCH responses, with
326
+ * CONDSTORE the messages get new mod-sequences
327
+ *
328
+ * @param {String} path Storage name
329
+ * @param {Array} uids UIDs of the messages
330
+ * @param {Array} flags Flags
331
+ * @param {String} [mode] "set" (default), "add" or "remove"
332
+ * @return {Array} `{ uid, flags }` of every message
333
+ */
334
+ setFlags(path, uids, flags, mode = 'set') {
335
+ const mailbox = this.requireMailbox(path, true);
336
+ const messages = this.requireMessages(mailbox, uids);
337
+ this.run(() => changeFlags(this.server, mailbox, messages, flags, mode));
338
+ return messages.map(message => ({ uid: message.uid, flags: message.flags.slice() }));
339
+ }
340
+ /**
341
+ * Removes messages. Sessions that have the mailbox selected get EXPUNGE (VANISHED with QRESYNC), following the
342
+ * RFC 2180 rules a session's EXPUNGE would
343
+ *
344
+ * @param {String} path Storage name
345
+ * @param {Array} uids UIDs of the messages
346
+ * @return {Array} the removed UIDs
347
+ */
348
+ expungeMessages(path, uids) {
349
+ const mailbox = this.requireMailbox(path, true);
350
+ const messages = this.requireMessages(mailbox, uids);
351
+ return this.run(() => expungeMessages(this.server, mailbox, messages)).map(message => message.uid);
352
+ }
353
+ /**
354
+ * Copies messages to another mailbox, the copies keep flags and internal date and get new UIDs
355
+ *
356
+ * @param {String} path Storage name of the source
357
+ * @param {Array} uids UIDs of the messages
358
+ * @param {String} target Storage name of the target
359
+ * @return {Object} `{ uidvalidity, uids }`: UIDVALIDITY of the target, `{ uid, targetUid }` for every message
360
+ */
361
+ copyMessages(path, uids, target) {
362
+ return this.transfer(path, uids, target, false);
363
+ }
364
+ /**
365
+ * Moves messages to another mailbox: copies them and expunges them from the source
366
+ *
367
+ * @param {String} path Storage name of the source
368
+ * @param {Array} uids UIDs of the messages
369
+ * @param {String} target Storage name of the target
370
+ * @return {Object} `{ uidvalidity, uids }`: UIDVALIDITY of the target, `{ uid, targetUid }` for every message
371
+ */
372
+ moveMessages(path, uids, target) {
373
+ return this.transfer(path, uids, target, true);
374
+ }
375
+ transfer(path, uids, target, move) {
376
+ const mailbox = this.requireMailbox(path, true);
377
+ const targetMailbox = this.requireMailbox(target, true);
378
+ // in UID order, like COPY and MOVE
379
+ const messages = this.requireMessages(mailbox, uids).sort((a, b) => a.uid - b.uid);
380
+ return this.run(() => {
381
+ const result = messages.map(message => ({ uid: message.uid, targetUid: this.server.copyMessage(targetMailbox, message).message.uid }));
382
+ if (move) {
383
+ expungeMessages(this.server, mailbox, messages);
384
+ }
385
+ return { uidvalidity: targetMailbox.uidvalidity, uids: result };
386
+ });
387
+ }
388
+ /**
389
+ * Replaces a message. The content of a UID never changes (RFC 9051 section 2.3.1.1), so this adds the new
390
+ * message and expunges the old one, the new message gets a new UID. Flags and internal date of the old
391
+ * message are kept unless given
392
+ *
393
+ * @param {String} path Storage name
394
+ * @param {Number} uid UID of the message to replace
395
+ * @param {Object} message `{ raw, flags, internaldate }`
396
+ * @return {Object} `{ uid, uidvalidity }` of the new message
397
+ */
398
+ replaceMessage(path, uid, message) {
399
+ const mailbox = this.requireMailbox(path, true);
400
+ const old = this.requireMessages(mailbox, [uid])[0];
401
+ const added = this.addMessage(path, {
402
+ raw: message && message.raw,
403
+ flags: message && message.flags ? message.flags : old.flags.slice(),
404
+ internaldate: message && message.internaldate ? message.internaldate : old.internaldate
405
+ });
406
+ this.run(() => expungeMessages(this.server, mailbox, [old]));
407
+ return added;
408
+ }
409
+ // Mailboxes
410
+ /**
411
+ * Creates a mailbox, with any missing superior levels
412
+ *
413
+ * @param {String} path Storage name
414
+ * @param {Object} [options] `{ subscribed }`
415
+ * @return {Object} mailbox info
416
+ */
417
+ createMailbox(path, options = {}) {
418
+ this.checkName(path);
419
+ const mailbox = this.run(() => {
420
+ const created = createMailbox(this.server, path);
421
+ if (options.subscribed) {
422
+ subscribeMailbox(this.server, created.path);
423
+ }
424
+ return created;
425
+ });
426
+ return this.describeMailbox(mailbox);
427
+ }
428
+ /**
429
+ * Deletes a mailbox. Sessions that have it selected are disconnected with BYE, like after DELETE in another session
430
+ *
431
+ * @param {String} path Storage name
432
+ */
433
+ deleteMailbox(path) {
434
+ this.checkPath(path);
435
+ this.run(() => deleteMailbox(this.server, path));
436
+ }
437
+ /**
438
+ * Renames a mailbox, with the RENAME rules (renaming INBOX moves its messages)
439
+ *
440
+ * @param {String} path Storage name
441
+ * @param {String} newPath New storage name
442
+ * @return {Object} info of the renamed mailbox
443
+ */
444
+ renameMailbox(path, newPath) {
445
+ this.checkPath(path);
446
+ this.checkName(newPath);
447
+ const result = this.run(() => renameMailbox(this.server, path, newPath));
448
+ return this.getMailbox(result.path);
449
+ }
450
+ /**
451
+ * Gives a mailbox a new UIDVALIDITY, optionally with new UIDs ("keep", "renumber", "shuffle" or "offset"). Sessions
452
+ * that have the mailbox selected are disconnected with BYE, as a UID must not change during a session
453
+ *
454
+ * @param {String} path Storage name
455
+ * @param {Object} [options] `{ uidvalidity, uids, offset, seed }`
456
+ * @return {Object} `{ uidvalidity, uidnext, uids }`, `uids` lists `{ uid, newUid }` for every message
457
+ */
458
+ resetUidValidity(path, options = {}) {
459
+ const mailbox = this.requireMailbox(path, true);
460
+ return this.run(() => resetUidValidity(this.server, mailbox, options || {}));
461
+ }
462
+ /**
463
+ * Subscribes a mailbox
464
+ *
465
+ * @param {String} path Storage name
466
+ * @return {Boolean} true if the subscription changed
467
+ */
468
+ subscribe(path) {
469
+ this.checkPath(path);
470
+ return this.run(() => subscribeMailbox(this.server, path));
471
+ }
472
+ /**
473
+ * Unsubscribes a name, it does not have to be a mailbox
474
+ *
475
+ * @param {String} path Storage name
476
+ * @return {Boolean} true if the subscription changed
477
+ */
478
+ unsubscribe(path) {
479
+ this.checkPath(path);
480
+ return this.run(() => unsubscribeMailbox(this.server, path));
481
+ }
482
+ // Users
483
+ /**
484
+ * Lists the users, without credentials
485
+ *
486
+ * @return {Array} `{ name, xoauth2 }`, xoauth2 is true for a user with an access token
487
+ */
488
+ listUsers() {
489
+ return Object.keys(this.server.users)
490
+ .sort()
491
+ .map(name => ({ name, xoauth2: !!(this.server.users[name].xoauth2 && this.server.users[name].xoauth2.accessToken) }));
492
+ }
493
+ /**
494
+ * Adds a user
495
+ *
496
+ * @param {String} name User name
497
+ * @param {Object} options `{ password, xoauth2: { accessToken, sessionTimeout } }`
498
+ */
499
+ addUser(name, options) {
500
+ if (typeof name !== 'string' || !name) {
501
+ throw new ImapKitError('User name must be a non-empty string', 'INVALID');
502
+ }
503
+ if (Object.hasOwn(this.server.users, name)) {
504
+ throw new ImapKitError('User ' + JSON.stringify(name) + ' already exists', 'ALREADYEXISTS');
505
+ }
506
+ this.server.users[name] = this.userData({}, options);
507
+ }
508
+ /**
509
+ * Changes the password or the XOAUTH2 token of a user
510
+ *
511
+ * @param {String} name User name
512
+ * @param {Object} options `{ password, xoauth2 }`, null xoauth2 removes the token
513
+ */
514
+ updateUser(name, options) {
515
+ this.server.users[name] = this.userData(this.requireUser(name), options);
516
+ }
517
+ /**
518
+ * Deletes a user, its sessions are disconnected with BYE unless `disconnect` is false
519
+ *
520
+ * @param {String} name User name
521
+ * @param {Object} [options] `{ disconnect }`
522
+ */
523
+ deleteUser(name, options = {}) {
524
+ this.requireUser(name);
525
+ delete this.server.users[name];
526
+ if (options.disconnect !== false) {
527
+ this.findSessions({ user: name }).forEach(connection => connection.bye('User was deleted', 'USER DELETED'));
528
+ }
529
+ }
530
+ requireUser(name) {
531
+ if (typeof name !== 'string' || !Object.hasOwn(this.server.users, name)) {
532
+ throw new ImapKitError('User ' + JSON.stringify(name) + ' does not exist', 'NONEXISTENT');
533
+ }
534
+ return this.server.users[name];
535
+ }
536
+ userData(user, options) {
537
+ const { password, xoauth2 } = options || {};
538
+ const result = Object.assign({}, user);
539
+ if (password !== undefined) {
540
+ if (typeof password !== 'string') {
541
+ throw new ImapKitError('Password must be a string', 'INVALID');
542
+ }
543
+ result.password = password;
544
+ }
545
+ if (xoauth2 === null) {
546
+ delete result.xoauth2;
547
+ }
548
+ else if (xoauth2 !== undefined) {
549
+ if (typeof xoauth2 !== 'object' || typeof xoauth2.accessToken !== 'string') {
550
+ throw new ImapKitError('xoauth2 must be { accessToken, sessionTimeout }', 'INVALID');
551
+ }
552
+ result.xoauth2 = { accessToken: xoauth2.accessToken, sessionTimeout: Number(xoauth2.sessionTimeout) || DEFAULT_SESSION_TIMEOUT };
553
+ }
554
+ return result;
555
+ }
556
+ // Server
557
+ /**
558
+ * Disconnects sessions
559
+ *
560
+ * @param {Number|Object} filter A session number, or `{ session, user }`
561
+ * @param {Object} [options] `{ text, reset }`: the text of the untagged BYE, or reset the TCP connection without BYE
562
+ * @return {Number} how many sessions were disconnected
563
+ */
564
+ disconnect(filter, options = {}) {
565
+ const sessions = this.findSessions(filter);
566
+ sessions.forEach(connection => {
567
+ if (options.reset) {
568
+ connection.closeNow('reset');
569
+ }
570
+ else {
571
+ connection.bye(options.text || 'Disconnected by the server', 'DISCONNECT');
572
+ }
573
+ });
574
+ return sessions.length;
575
+ }
576
+ /**
577
+ * Writes bytes to a session as they are, e.g. an untagged response between commands (ALERT, BYE, EXISTS).
578
+ * Nothing checks that they are valid IMAP
579
+ *
580
+ * @param {Number} session Session number
581
+ * @param {String|Buffer} data Bytes to send, a string is encoded as UTF-8
582
+ */
583
+ inject(session, data) {
584
+ const connection = this.findSessions({ session })[0];
585
+ if (!connection) {
586
+ throw new ImapKitError('Session ' + session + ' does not exist', 'NONEXISTENT');
587
+ }
588
+ if (typeof data !== 'string' && !(data instanceof Uint8Array)) {
589
+ throw new ImapKitError('Data must be a string or a Buffer', 'INVALID');
590
+ }
591
+ connection.write(Buffer.from(data));
592
+ }
593
+ /**
594
+ * Restores the mailboxes and users of the server options and disconnects every session with BYE, so that a
595
+ * long running server (the imapkit command) can be reused between tests. Plugins restore their own state on
596
+ * the `reset` event (QUOTA limits, server annotations). Script rules stay, `server.script.clear()` removes them
597
+ */
598
+ reset() {
599
+ this.server.connections.forEach(connection => {
600
+ if (connection.isOpen()) {
601
+ connection.bye('Server reset', 'RESET');
602
+ }
603
+ });
604
+ this.run(() => {
605
+ this.server.loadUsers();
606
+ this.server.loadStorage();
607
+ this.server.emit('reset');
608
+ });
609
+ }
610
+ /**
611
+ * Stops the server. A graceful shutdown stops accepting connections and waits until the clients disconnect,
612
+ * otherwise the sessions are closed right away
613
+ *
614
+ * @param {Object} [options] `{ graceful }`, true by default
615
+ * @return {Promise} resolves when the server is closed
616
+ */
617
+ shutdown(options = {}) {
618
+ return new Promise((resolve, reject) => {
619
+ const done = (err) => (err && err.code !== 'ERR_SERVER_NOT_RUNNING' ? reject(err) : resolve());
620
+ if (options.graceful === false) {
621
+ this.server.close(done);
622
+ }
623
+ else {
624
+ this.server.closeListeners(false);
625
+ this.server.server.close(done);
626
+ }
627
+ });
628
+ }
629
+ }
630
+ export { Control, ImapKitError };
@@ -1,10 +1,17 @@
1
1
  import createServer, { TAG_REGEX, IMAPServer, IMAPConnection } from './server.js';
2
- export { TAG_REGEX, IMAPServer, IMAPConnection };
2
+ import { ImapKitError } from './control.js';
3
+ import { quirks } from './quirks.js';
4
+ import { storageSchema, validateStorage } from './storage-schema.js';
5
+ export { TAG_REGEX, IMAPServer, IMAPConnection, ImapKitError, quirks, storageSchema, validateStorage };
3
6
  export type { Attribute, ParsedCommand, IMAPResponse, Notification, Callback, CommandHandler, CommandOptions, Plugin, IMAPError, Message, Mailbox, StorageNamespace, UserData, IMAPServerOptions } from './types.js';
4
7
  export type { ScriptRule, ScriptContext, ScriptEvent, ScriptBytes, ScriptHandle } from './script.js';
8
+ export type { Quirk } from './quirks.js';
9
+ export type { Control, MailboxInfo, MessageInfo, SessionInfo, NewMessage, UserOptions, SessionFilter } from './control.js';
5
10
  declare const imapkit: typeof createServer & {
6
11
  TAG_REGEX: RegExp;
7
12
  IMAPServer: typeof IMAPServer;
8
13
  IMAPConnection: typeof IMAPConnection;
14
+ ImapKitError: typeof ImapKitError;
15
+ quirks: Record<string, import("./quirks.js").Quirk>;
9
16
  };
10
17
  export default imapkit;
package/dist/esm/index.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import createServer, { TAG_REGEX, IMAPServer, IMAPConnection } from './server.js';
2
- export { TAG_REGEX, IMAPServer, IMAPConnection };
2
+ import { ImapKitError } from './control.js';
3
+ import { quirks } from './quirks.js';
4
+ import { storageSchema, validateStorage } from './storage-schema.js';
5
+ export { TAG_REGEX, IMAPServer, IMAPConnection, ImapKitError, quirks, storageSchema, validateStorage };
3
6
  // `imapkit(options)` creates a server. TAG_REGEX was a property of the CommonJS export, it and the
4
7
  // classes are properties of the factory with both module formats
5
- const imapkit = Object.assign(createServer, { TAG_REGEX, IMAPServer, IMAPConnection });
8
+ const imapkit = Object.assign(createServer, { TAG_REGEX, IMAPServer, IMAPConnection, ImapKitError, quirks });
6
9
  export default imapkit;
@@ -16,6 +16,6 @@ declare function listPlugins(): string[];
16
16
  * @param {Object} server IMAPServer instance
17
17
  * @param {Array|String|Function} plugins List of plugins to load
18
18
  */
19
- declare function loadPlugins(server: IMAPServer, plugins: (string | Plugin)[] | string | Plugin | null | undefined): void;
19
+ declare function loadPlugins(server: IMAPServer, plugins: (string | Plugin)[] | string | Plugin | null | undefined, exclude?: string[]): void;
20
20
  export default loadPlugins;
21
21
  export { listPlugins };