imapkit 4.3.1 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +187 -67
  2. package/bin/help.txt +20 -5
  3. package/bin/imapkit.js +64 -24
  4. package/dist/cjs/commands/append.js +1 -1
  5. package/dist/cjs/commands/create.js +2 -2
  6. package/dist/cjs/commands/delete.js +2 -14
  7. package/dist/cjs/commands/handlers/fetch.js +2 -1
  8. package/dist/cjs/commands/handlers/flags.d.ts +3 -3
  9. package/dist/cjs/commands/handlers/flags.js +3 -3
  10. package/dist/cjs/commands/handlers/store.js +7 -7
  11. package/dist/cjs/commands/list.js +17 -3
  12. package/dist/cjs/commands/rename.js +6 -46
  13. package/dist/cjs/commands/select.js +1 -0
  14. package/dist/cjs/commands/subscribe.js +7 -8
  15. package/dist/cjs/commands/unsubscribe.js +3 -9
  16. package/dist/cjs/control.d.ts +425 -0
  17. package/dist/cjs/control.js +637 -0
  18. package/dist/cjs/dates.d.ts +9 -1
  19. package/dist/cjs/dates.js +20 -0
  20. package/dist/cjs/envelope.d.ts +1 -1
  21. package/dist/cjs/envelope.js +26 -1
  22. package/dist/cjs/index.d.ts +9 -1
  23. package/dist/cjs/index.js +9 -2
  24. package/dist/cjs/load-plugins.d.ts +3 -1
  25. package/dist/cjs/load-plugins.js +28 -4
  26. package/dist/cjs/plugins/acl.js +109 -42
  27. package/dist/cjs/plugins/binary.js +2 -1
  28. package/dist/cjs/plugins/condstore.js +7 -0
  29. package/dist/cjs/plugins/context-search.js +2 -2
  30. package/dist/cjs/plugins/create-special-use.d.ts +0 -5
  31. package/dist/cjs/plugins/create-special-use.js +11 -1
  32. package/dist/cjs/plugins/imap4rev2.js +5 -0
  33. package/dist/cjs/plugins/index.js +1 -3
  34. package/dist/cjs/plugins/metadata-server.d.ts +6 -2
  35. package/dist/cjs/plugins/metadata-server.js +3 -1
  36. package/dist/cjs/plugins/metadata.d.ts +5 -1
  37. package/dist/cjs/plugins/metadata.js +143 -57
  38. package/dist/cjs/plugins/objectid.js +10 -0
  39. package/dist/cjs/plugins/quota.js +50 -2
  40. package/dist/cjs/plugins/savedate.js +1 -1
  41. package/dist/cjs/plugins/special-use.d.ts +0 -8
  42. package/dist/cjs/plugins/special-use.js +23 -0
  43. package/dist/cjs/quirks.d.ts +28 -0
  44. package/dist/cjs/quirks.js +125 -0
  45. package/dist/cjs/random.d.ts +10 -0
  46. package/dist/cjs/random.js +22 -0
  47. package/dist/cjs/rest.d.ts +54 -0
  48. package/dist/cjs/rest.js +567 -0
  49. package/dist/cjs/script.d.ts +33 -4
  50. package/dist/cjs/script.js +64 -4
  51. package/dist/cjs/server.d.ts +199 -9
  52. package/dist/cjs/server.js +603 -227
  53. package/dist/cjs/smtp-listener.d.ts +3 -2
  54. package/dist/cjs/smtp-listener.js +10 -6
  55. package/dist/cjs/storage-schema.d.ts +139 -0
  56. package/dist/cjs/storage-schema.js +222 -0
  57. package/dist/cjs/store-operations.d.ts +171 -0
  58. package/dist/cjs/store-operations.js +425 -0
  59. package/dist/cjs/types.d.ts +26 -1
  60. package/dist/esm/commands/append.js +1 -1
  61. package/dist/esm/commands/create.js +2 -2
  62. package/dist/esm/commands/delete.js +2 -14
  63. package/dist/esm/commands/handlers/fetch.js +2 -1
  64. package/dist/esm/commands/handlers/flags.d.ts +3 -3
  65. package/dist/esm/commands/handlers/flags.js +3 -3
  66. package/dist/esm/commands/handlers/store.js +7 -7
  67. package/dist/esm/commands/list.js +17 -3
  68. package/dist/esm/commands/rename.js +6 -46
  69. package/dist/esm/commands/select.js +1 -0
  70. package/dist/esm/commands/subscribe.js +7 -8
  71. package/dist/esm/commands/unsubscribe.js +3 -9
  72. package/dist/esm/control.d.ts +425 -0
  73. package/dist/esm/control.js +630 -0
  74. package/dist/esm/dates.d.ts +9 -1
  75. package/dist/esm/dates.js +20 -1
  76. package/dist/esm/envelope.d.ts +1 -1
  77. package/dist/esm/envelope.js +26 -1
  78. package/dist/esm/index.d.ts +9 -1
  79. package/dist/esm/index.js +5 -2
  80. package/dist/esm/load-plugins.d.ts +3 -1
  81. package/dist/esm/load-plugins.js +28 -4
  82. package/dist/esm/plugins/acl.js +109 -42
  83. package/dist/esm/plugins/binary.js +2 -1
  84. package/dist/esm/plugins/condstore.js +7 -0
  85. package/dist/esm/plugins/context-search.js +2 -2
  86. package/dist/esm/plugins/create-special-use.d.ts +0 -5
  87. package/dist/esm/plugins/create-special-use.js +8 -1
  88. package/dist/esm/plugins/imap4rev2.js +5 -0
  89. package/dist/esm/plugins/index.js +1 -3
  90. package/dist/esm/plugins/metadata-server.d.ts +6 -2
  91. package/dist/esm/plugins/metadata-server.js +3 -1
  92. package/dist/esm/plugins/metadata.d.ts +5 -1
  93. package/dist/esm/plugins/metadata.js +143 -57
  94. package/dist/esm/plugins/objectid.js +10 -0
  95. package/dist/esm/plugins/quota.js +50 -2
  96. package/dist/esm/plugins/savedate.js +1 -1
  97. package/dist/esm/plugins/special-use.d.ts +0 -8
  98. package/dist/esm/plugins/special-use.js +23 -0
  99. package/dist/esm/quirks.d.ts +28 -0
  100. package/dist/esm/quirks.js +121 -0
  101. package/dist/esm/random.d.ts +10 -0
  102. package/dist/esm/random.js +19 -0
  103. package/dist/esm/rest.d.ts +54 -0
  104. package/dist/esm/rest.js +559 -0
  105. package/dist/esm/script.d.ts +33 -4
  106. package/dist/esm/script.js +63 -4
  107. package/dist/esm/server.d.ts +199 -9
  108. package/dist/esm/server.js +606 -230
  109. package/dist/esm/smtp-listener.d.ts +3 -2
  110. package/dist/esm/smtp-listener.js +10 -6
  111. package/dist/esm/storage-schema.d.ts +139 -0
  112. package/dist/esm/storage-schema.js +218 -0
  113. package/dist/esm/store-operations.d.ts +171 -0
  114. package/dist/esm/store-operations.js +410 -0
  115. package/dist/esm/types.d.ts +26 -1
  116. package/dist/plugin-help.json +3 -16
  117. package/package.json +11 -3
  118. package/dist/cjs/plugins/xtoybird.d.ts +0 -2
  119. package/dist/cjs/plugins/xtoybird.js +0 -236
  120. package/dist/esm/plugins/xtoybird.d.ts +0 -2
  121. package/dist/esm/plugins/xtoybird.js +0 -218
@@ -53,6 +53,11 @@ const search_js_1 = require("./commands/handlers/search.js");
53
53
  const numbers_js_1 = require("./numbers.js");
54
54
  const arguments_js_1 = require("./arguments.js");
55
55
  const append_js_1 = require("./commands/append.js");
56
+ const store_operations_js_1 = require("./store-operations.js");
57
+ const control_js_1 = require("./control.js");
58
+ const quirks_js_1 = require("./quirks.js");
59
+ const storage_schema_js_1 = require("./storage-schema.js");
60
+ const rest_js_1 = require("./rest.js");
56
61
  const bundledCert = __importStar(require("./cert.js"));
57
62
  const script_js_1 = require("./script.js");
58
63
  // longest command line (not counting literals) accepted from a client
@@ -101,17 +106,35 @@ function stateError(command, state) {
101
106
  return command + ' is not allowed in the ' + state + ' state';
102
107
  }
103
108
  /**
104
- * Creates an error for a failed mailbox operation, with a RFC 5530 response code
109
+ * Checks if a queued notification changes message sequence numbers by removing messages: an EXPUNGE response, or the
110
+ * EXISTS that follows the EXPUNGE responses of another session (it carries the snapshot of the old message list)
105
111
  *
106
- * @param {String} message Error message
107
- * @param {String} code Response code, e.g. "ALREADYEXISTS"
108
- * @return {Error} Error object
112
+ * @param {Object} notification Queued notification
113
+ * @return {Boolean} true if the notification must wait while EXPUNGE responses are not allowed
109
114
  */
110
- function mailboxError(message, code) {
111
- const err = new Error(message);
112
- err.code = code;
113
- return err;
115
+ function isPendingExpunge(notification) {
116
+ return !!notification.mailboxCopy || (0, arguments_js_1.isAtom)(notification.attributes && notification.attributes[1], 'EXPUNGE');
114
117
  }
118
+ /**
119
+ * Checks if a notification is an untagged EXISTS response
120
+ *
121
+ * @param {Object} [notification] Notification to send
122
+ * @return {Boolean} true for an EXISTS response
123
+ */
124
+ function isExists(notification) {
125
+ return !!notification && !notification.command && (0, arguments_js_1.isAtom)(notification.attributes && notification.attributes[1], 'EXISTS');
126
+ }
127
+ /**
128
+ * Answers AUTHENTICATE with a mechanism no plugin supports: NO, not a syntax error (RFC 3501 section 6.2.2). It runs
129
+ * through the command queue, so the state check comes first (AUTHENTICATE is only valid in the Not Authenticated
130
+ * state, RFC 3501 section 6.2)
131
+ */
132
+ // usesSequenceNumbers() of each running command
133
+ const sequenceNumberUse = new WeakMap();
134
+ const unsupportedMechanism = (connection, parsed, data, callback) => {
135
+ connection.sendStatus(parsed, data, 'NO', 'Unsupported authentication mechanism', false, 'UNKNOWN COMMAND');
136
+ callback();
137
+ };
115
138
  /**
116
139
  * Creates a new IMAP server, call `listen()` on it to start accepting connections
117
140
  *
@@ -200,11 +223,33 @@ class IMAPServer extends node_stream_1.Stream {
200
223
  // the session whose command is running, see IMAPServer#notify
201
224
  this.activeConnection = null;
202
225
  this.sessionCounter = 0;
203
- // rules that make the server misbehave on purpose, from the script option or server.script.add()
226
+ // rules that make the server misbehave on purpose, from the script option or server.script.add(), and the rules
227
+ // of the quirk presets after them
204
228
  this.script = new script_js_1.ServerScript(this, this.options.script);
205
- // users and storage are deep copied, so that runtime changes never leak into
206
- // the caller's objects or into other servers built from the same fixture.
207
- // Without a prototype, user names like "__proto__" or "toString" are plain keys
229
+ const quirks = (0, quirks_js_1.resolveQuirks)(this.options.quirks);
230
+ if (quirks.rules.length) {
231
+ this.script.add(quirks.rules);
232
+ }
233
+ this.loadUsers();
234
+ // plugins register their control operations
235
+ this.control = new control_js_1.Control(this);
236
+ // a quirk preset can leave plugins out (no-move, no-uidplus)
237
+ (0, load_plugins_js_1.default)(this, this.options.plugins, quirks.removePlugins);
238
+ if (this.options.storage) {
239
+ // a typo in a fixture fails here with the path of the problem
240
+ (0, storage_schema_js_1.validateStorage)(this.options.storage);
241
+ }
242
+ this.systemFlags = [].concat(this.options.systemFlags || ['\\Answered', '\\Flagged', '\\Draft', '\\Deleted', '\\Seen']);
243
+ this.loadStorage();
244
+ this.smtpServer = null;
245
+ this.restServer = null;
246
+ }
247
+ /**
248
+ * Sets the users from the `users` option, or the default user. The users are deep copied, so that runtime changes
249
+ * never leak into the caller's objects or into other servers built from the same fixture. Without a prototype,
250
+ * user names like "__proto__" or "toString" are plain keys
251
+ */
252
+ loadUsers() {
208
253
  this.users = Object.assign(Object.create(null), this.options.users
209
254
  ? structuredClone(this.options.users)
210
255
  : {
@@ -212,12 +257,16 @@ class IMAPServer extends node_stream_1.Stream {
212
257
  password: 'testpass',
213
258
  xoauth2: {
214
259
  accessToken: 'testtoken',
215
- sessionTimeout: 3600 * 1000
260
+ sessionTimeout: store_operations_js_1.DEFAULT_SESSION_TIMEOUT
216
261
  }
217
262
  }
218
263
  });
219
- (0, load_plugins_js_1.default)(this, this.options.plugins);
220
- this.systemFlags = [].concat(this.options.systemFlags || ['\\Answered', '\\Flagged', '\\Draft', '\\Deleted', '\\Seen']);
264
+ }
265
+ /**
266
+ * Builds the mailboxes from the `storage` option, a deep copy of it. Message and mailbox handlers of plugins run
267
+ * on every message and mailbox
268
+ */
269
+ loadStorage() {
221
270
  // indexFolders() below turns the storage option into namespaces and mailboxes in place
222
271
  this.storage = (this.options.storage
223
272
  ? structuredClone(this.options.storage)
@@ -225,6 +274,7 @@ class IMAPServer extends node_stream_1.Stream {
225
274
  INBOX: {},
226
275
  '': {}
227
276
  });
277
+ this.referenceNamespace = false;
228
278
  this.uidvalidityCounter = 0; // highest UIDVALIDITY in use, new mailboxes get a higher one
229
279
  // subscribed mailbox names (RFC 3501 section 6.3.6). Names, not mailboxes: a subscription outlives
230
280
  // DELETE and stays with the old name on RENAME (RFC 9051 section 6.3.6), see trackSubscription
@@ -232,10 +282,67 @@ class IMAPServer extends node_stream_1.Stream {
232
282
  this.folderCache = Object.create(null);
233
283
  this.indexFolders(true);
234
284
  }
285
+ /**
286
+ * Starts accepting connections
287
+ *
288
+ * @param {Number} [port] Port to listen on, a free port if not set
289
+ * @param {String} [host] Address to listen on, all addresses if not set
290
+ * @return {Promise<Number>} resolves with the port the server listens on
291
+ */
292
+ async start(port, host) {
293
+ const imapPort = await listenOn(this.server, port, host);
294
+ await Promise.all([this.options.smtp ? this.startSmtp() : null, this.options.rest ? this.startRest() : null]);
295
+ return imapPort;
296
+ }
297
+ /**
298
+ * Starts the REST API of the `rest` option, see src/rest.ts
299
+ *
300
+ * @return {Promise<Number>} resolves with the HTTP port
301
+ */
302
+ async startRest() {
303
+ const { port, host, token } = this.options.rest || {};
304
+ this.restServer = (0, rest_js_1.createRestServer)(this, { host, token });
305
+ return listenOn(this.restServer, port, host || '127.0.0.1');
306
+ }
307
+ /**
308
+ * Starts the SMTP server of the `smtp` option. smtp-server is an optional dependency, loaded only here
309
+ *
310
+ * @return {Promise<Number>} resolves with the SMTP port
311
+ */
312
+ startSmtp() {
313
+ const { port, host } = this.options.smtp || {};
314
+ return this.loadSmtpListener().then(({ startSMTPServer }) => new Promise((resolve, reject) => {
315
+ const smtp = startSMTPServer(port || 0, this, smtpPort => {
316
+ smtp.removeListener('error', reject);
317
+ resolve(smtpPort);
318
+ }, host);
319
+ smtp.once('error', reject);
320
+ this.smtpServer = smtp;
321
+ }));
322
+ }
323
+ /**
324
+ * Loads the SMTP listener, which needs the smtp-server package
325
+ *
326
+ * @return {Promise<Object>} the smtp-listener module
327
+ */
328
+ loadSmtpListener() {
329
+ return Promise.resolve().then(() => __importStar(require('./smtp-listener.js'))).catch((err) => {
330
+ throw new Error('The smtp option needs the smtp-server package, install it with: npm install smtp-server (' + err.message + ')');
331
+ });
332
+ }
333
+ /**
334
+ * Stops the server and closes every connection
335
+ *
336
+ * @return {Promise} resolves when the server is closed
337
+ */
338
+ stop() {
339
+ return this.control.shutdown({ graceful: false });
340
+ }
235
341
  listen(...args) {
236
342
  this.server.listen.apply(this.server, args);
237
343
  }
238
344
  close(callback) {
345
+ this.closeListeners(true);
239
346
  this.server.close(callback);
240
347
  // close() only completes once all connections are gone
241
348
  this.connections.forEach((connection) => {
@@ -244,6 +351,31 @@ class IMAPServer extends node_stream_1.Stream {
244
351
  }
245
352
  });
246
353
  }
354
+ /**
355
+ * Stops the SMTP and REST listeners of `start()`
356
+ *
357
+ * @param {Boolean} force true closes the open REST connections too, otherwise only the idle ones (a request that
358
+ * is answered, like POST /v1/shutdown, finishes first)
359
+ */
360
+ closeListeners(force) {
361
+ if (this.smtpServer) {
362
+ this.smtpServer.close(() => false);
363
+ this.smtpServer = null;
364
+ }
365
+ if (this.restServer) {
366
+ // event streams never end on their own, a graceful shutdown would wait for them forever
367
+ this.restServer.endStreams?.();
368
+ this.restServer.close();
369
+ // keep-alive connections would hold the server open
370
+ if (force) {
371
+ this.restServer.closeAllConnections?.();
372
+ }
373
+ else {
374
+ this.restServer.closeIdleConnections?.();
375
+ }
376
+ this.restServer = null;
377
+ }
378
+ }
247
379
  /**
248
380
  * Returns TLS key and certificate. Without credentials in the options the
249
381
  * bundled self-signed certificate for localhost is used.
@@ -363,13 +495,31 @@ class IMAPServer extends node_stream_1.Stream {
363
495
  *
364
496
  * @param {String} type Kind of change
365
497
  * @param {String} path Storage name of the mailbox
366
- * @param {Object} [details] `{ oldPath, mailbox }`: the earlier name of a renamed mailbox, the mailbox
367
- * object that a DELETE removed
498
+ * @param {Object} [details] `{ oldPath, mailbox, created }`: the earlier name of a renamed mailbox, the mailbox
499
+ * object that a DELETE removed, the mailboxes a CREATE made (superior levels first)
368
500
  */
369
501
  mailboxChanged(type, path, details) {
370
502
  const event = Object.assign({ type, path, oldPath: null, mailbox: null }, details, { origin: this.activeConnection });
371
503
  this.emit('mailbox', event);
372
504
  }
505
+ /**
506
+ * Runs a change with `origin` as the session that caused it, the `origin` of the notifications and events it
507
+ * makes. Commands run with their session, the control API with null
508
+ *
509
+ * @param {Object|null} origin Session, or null for a change from outside
510
+ * @param {Function} fn Change
511
+ * @return {*} the result of fn
512
+ */
513
+ withOrigin(origin, fn) {
514
+ const previous = this.activeConnection;
515
+ this.activeConnection = origin;
516
+ try {
517
+ return fn();
518
+ }
519
+ finally {
520
+ this.activeConnection = previous;
521
+ }
522
+ }
373
523
  /**
374
524
  * Retrieves the handler of an IMAP command
375
525
  *
@@ -393,8 +543,7 @@ class IMAPServer extends node_stream_1.Stream {
393
543
  let seen = 0;
394
544
  let unseen = 0;
395
545
  // flags stay defined in the mailbox once a message had them, see rememberFlags
396
- const permanentFlags = [].concat(mailbox.permanentFlags || []);
397
- (mailbox.knownFlags || []).forEach(flag => this.ensureFlag(permanentFlags, flag));
546
+ const permanentFlags = this.permanentFlagList(mailbox);
398
547
  let recent = 0;
399
548
  // \Recent sets of the sessions that have this mailbox selected
400
549
  const recentSets = [];
@@ -440,20 +589,8 @@ class IMAPServer extends node_stream_1.Stream {
440
589
  * @return {Boolean} Returns true if the date string is in IMAP date-time format
441
590
  */
442
591
  validateInternalDate(date) {
443
- if (!date || typeof date !== 'string') {
444
- return false;
445
- }
446
- // date-time from RFC 3501 section 9, month names are case-insensitive like all ABNF strings
447
- const match = date.match(/^( \d|\d\d)-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{4}) (\d{2}):(\d{2}):(\d{2}) [-+](\d{2})(\d{2})$/i);
448
- if (!match) {
449
- return false;
450
- }
451
- // the values must also make a real date and time
452
- return ((0, dates_js_1.isRealDate)(match[1], (0, dates_js_1.monthIndex)(match[2]), match[3]) &&
453
- Number(match[4]) < 24 &&
454
- Number(match[5]) < 60 &&
455
- Number(match[6]) < 61 &&
456
- Number(match[8]) < 60);
592
+ // date-time from RFC 3501 section 9
593
+ return (0, dates_js_1.isDateTime)(date);
457
594
  }
458
595
  /**
459
596
  * Converts a date object to a valid date-time string format
@@ -502,22 +639,22 @@ class IMAPServer extends node_stream_1.Stream {
502
639
  */
503
640
  createMailbox(path, defaultMailbox) {
504
641
  if (!path) {
505
- throw mailboxError('Invalid mailbox name', 'CANNOT');
642
+ throw (0, store_operations_js_1.storeError)('Invalid mailbox name', 'CANNOT');
506
643
  }
507
644
  // Ensure case insensitive INBOX
508
645
  if (path.toUpperCase() === 'INBOX') {
509
- throw mailboxError('INBOX can not be modified', 'ALREADYEXISTS');
646
+ throw (0, store_operations_js_1.storeError)('INBOX can not be modified', 'ALREADYEXISTS');
510
647
  }
511
648
  const { namespace, storage } = this.getPersonalNamespace(path);
512
649
  path = this.stripSeparator(path, storage.separator);
513
650
  if (this.folderCache[path] && this.folderCache[path].flags.indexOf('\\Noselect') < 0) {
514
- throw mailboxError('Mailbox already exists', 'ALREADYEXISTS');
651
+ throw (0, store_operations_js_1.storeError)('Mailbox already exists', 'ALREADYEXISTS');
515
652
  }
516
653
  const folderPath = path.substr(namespace.length).split(storage.separator);
517
654
  if (folderPath.some(name => !name)) {
518
655
  // an empty hierarchy level ("foo//bar", "/foo", "foo//"). RFC 5530 section 3 has this very case as the
519
656
  // example of CANNOT, Dovecot refuses it too
520
- throw mailboxError('Mailbox names can not have empty hierarchy levels', 'CANNOT');
657
+ throw (0, store_operations_js_1.storeError)('Mailbox names can not have empty hierarchy levels', 'CANNOT');
521
658
  }
522
659
  let parent = storage;
523
660
  let curPath = namespace;
@@ -528,7 +665,7 @@ class IMAPServer extends node_stream_1.Stream {
528
665
  curPath += (curPath.length ? storage.separator : '') + folderName;
529
666
  let folder = this.getMailbox(curPath) || false;
530
667
  if (folder && folder.flags && folder.flags.indexOf('\\Noinferiors') >= 0) {
531
- throw mailboxError('Can not create subfolders for ' + folder.path, 'CANNOT');
668
+ throw (0, store_operations_js_1.storeError)('Can not create subfolders for ' + folder.path, 'CANNOT');
532
669
  }
533
670
  // a \Noselect placeholder that is created again is replaced with a new mailbox that only keeps
534
671
  // the children, nothing else of a deleted mailbox may come back (RFC 3501 section 6.3.3)
@@ -570,17 +707,17 @@ class IMAPServer extends node_stream_1.Stream {
570
707
  deleteMailbox(path, keepContents) {
571
708
  // Ensure case insensitive INBOX
572
709
  if (path.toUpperCase() === 'INBOX') {
573
- throw mailboxError('INBOX can not be modified', 'CANNOT');
710
+ throw (0, store_operations_js_1.storeError)('INBOX can not be modified', 'CANNOT');
574
711
  }
575
712
  const { namespace, storage } = this.getPersonalNamespace(path);
576
713
  const mailbox = this.folderCache[this.stripSeparator(path, storage.separator)];
577
714
  if (!mailbox) {
578
- throw mailboxError('Mailbox does not exist', 'NONEXISTENT');
715
+ throw (0, store_operations_js_1.storeError)('Mailbox does not exist', 'NONEXISTENT');
579
716
  }
580
717
  if (mailbox.flags.indexOf('\\Noselect') >= 0 && Object.keys(mailbox.folders || {}).length) {
581
718
  // RFC 9051 section 6.3.5: deleting a \Noselect name that has inferior names is an error, the RFC 5530
582
719
  // section 3 HASCHILDREN response code tells the client to delete the children first
583
- throw mailboxError('Mailbox has children, delete them first', 'HASCHILDREN');
720
+ throw (0, store_operations_js_1.storeError)('Mailbox has children, delete them first', 'HASCHILDREN');
584
721
  }
585
722
  const levels = mailbox.path.split(storage.separator);
586
723
  const folderName = levels.pop();
@@ -630,7 +767,7 @@ class IMAPServer extends node_stream_1.Stream {
630
767
  const prefix = key.length ? key.substr(0, key.length - this.storage[key].separator.length) : key;
631
768
  if (key.length && (path === prefix || path.substr(0, key.length) === key)) {
632
769
  if (path === prefix) {
633
- throw mailboxError('Used mailbox name is a namespace value', 'CANNOT');
770
+ throw (0, store_operations_js_1.storeError)('Used mailbox name is a namespace value', 'CANNOT');
634
771
  }
635
772
  namespace = key;
636
773
  }
@@ -640,10 +777,10 @@ class IMAPServer extends node_stream_1.Stream {
640
777
  });
641
778
  const storage = this.storage[namespace];
642
779
  if (!storage) {
643
- throw mailboxError('Unknown namespace', 'CANNOT');
780
+ throw (0, store_operations_js_1.storeError)('Unknown namespace', 'CANNOT');
644
781
  }
645
782
  if (storage.type !== 'personal') {
646
- throw mailboxError('Permission denied', 'NOPERM');
783
+ throw (0, store_operations_js_1.storeError)('Permission denied', 'NOPERM');
647
784
  }
648
785
  return { namespace, storage };
649
786
  }
@@ -884,6 +1021,43 @@ class IMAPServer extends node_stream_1.Stream {
884
1021
  const knownFlags = (mailbox.knownFlags = mailbox.knownFlags || []);
885
1022
  flags.forEach(flag => this.ensureFlag(knownFlags, flag));
886
1023
  }
1024
+ /**
1025
+ * Checks if a flag can be stored on messages of a mailbox: any flag when the mailbox allows new keywords
1026
+ * (PERMANENTFLAGS has `\*`), otherwise only a flag in the PERMANENTFLAGS list that getStatus() builds, the
1027
+ * `permanentFlags` of the mailbox and every flag its messages had. RFC 3501 section 7.1 (RFC 9051 section 7.1):
1028
+ * PERMANENTFLAGS "indicates which of the known flags the client can change permanently", STORE ignores the others
1029
+ *
1030
+ * @param {Object} mailbox Mailbox object
1031
+ * @param {String} flag Normalized flag
1032
+ * @return {Boolean} true if the flag is a permanent flag of the mailbox
1033
+ */
1034
+ isPermanentFlag(mailbox, flag) {
1035
+ // the lists of permanentFlagList(), without building it: this runs for every flag STORE and APPEND set
1036
+ return (mailbox.allowPermanentFlags || (mailbox.permanentFlags || []).indexOf(flag) >= 0 || (!!mailbox.knownFlags && mailbox.knownFlags.indexOf(flag) >= 0));
1037
+ }
1038
+ /**
1039
+ * The flags of the PERMANENTFLAGS list (without `\*`): the `permanentFlags` of the mailbox and every flag its
1040
+ * messages have or had (`knownFlags`, see rememberFlags)
1041
+ *
1042
+ * @param {Object} mailbox Mailbox object
1043
+ * @return {Array} flags, a new list
1044
+ */
1045
+ permanentFlagList(mailbox) {
1046
+ const list = [].concat(mailbox.permanentFlags || []);
1047
+ (mailbox.knownFlags || []).forEach(flag => this.ensureFlag(list, flag));
1048
+ return list;
1049
+ }
1050
+ /**
1051
+ * The current time for dates the server sets itself (INTERNALDATE of a message without one, SAVEDATE). The `now`
1052
+ * option fixes it for repeatable tests: a Date, a timestamp, or a function that returns one
1053
+ *
1054
+ * @return {Date} current time
1055
+ */
1056
+ now() {
1057
+ const now = this.options.now;
1058
+ const value = typeof now === 'function' ? now() : now;
1059
+ return value === undefined || value === null ? new Date() : new Date(value);
1060
+ }
887
1061
  normalizeDateTime(value) {
888
1062
  if (value instanceof Date) {
889
1063
  return this.formatInternalDate(value);
@@ -902,7 +1076,7 @@ class IMAPServer extends node_stream_1.Stream {
902
1076
  * @param mailbox Mailbox of the message
903
1077
  */
904
1078
  processMessage(message, mailbox) {
905
- message.internaldate = this.normalizeDateTime(message.internaldate || new Date());
1079
+ message.internaldate = this.normalizeDateTime(message.internaldate || this.now());
906
1080
  message.flags = [].concat(message.flags || []);
907
1081
  if (message.flags.indexOf('\\Recent') >= 0) {
908
1082
  // \Recent is not a stored flag, it belongs to the first session that selects the mailbox
@@ -944,9 +1118,10 @@ class IMAPServer extends node_stream_1.Stream {
944
1118
  */
945
1119
  appendMessage(path, flags, internaldate, raw, ignoreConnection, properties) {
946
1120
  const mailbox = typeof path === 'string' ? this.getMailbox(path) : path;
947
- // processMessage() below sets the UID
1121
+ // processMessage() below sets the UID. Flags the mailbox can not store are left out (APPEND, COPY: the flags
1122
+ // SHOULD be set, RFC 3501 sections 6.3.11 and 6.4.7), as STORE ignores them (RFC 3501 section 7.1, PERMANENTFLAGS)
948
1123
  const message = Object.assign({}, properties, {
949
- flags: flags,
1124
+ flags: flags.filter(flag => this.isPermanentFlag(mailbox, flag)),
950
1125
  internaldate: internaldate,
951
1126
  raw: raw,
952
1127
  recent: true
@@ -1132,49 +1307,48 @@ class IMAPServer extends node_stream_1.Stream {
1132
1307
  return flags;
1133
1308
  }
1134
1309
  matchFolders(referenceName, match, exportName, folders) {
1135
- let includeINBOX = false;
1136
1310
  const source = folders || this.folderCache;
1137
1311
  const toName = exportName || ((name) => name);
1138
- let reference = referenceName || '';
1139
- if (reference === '' && this.referenceNamespace !== false) {
1140
- reference = toName(this.referenceNamespace);
1141
- includeINBOX = true;
1142
- }
1143
- // the reference does not have to be a namespace, use the namespace it belongs to
1144
- let nsKey = false;
1145
- let nsName = '';
1146
- for (const key of Object.keys(this.storage)) {
1147
- const name = toName(key);
1148
- if (key !== 'INBOX' && reference.substr(0, name.length) === name && (nsKey === false || name.length > nsName.length)) {
1149
- nsKey = key;
1150
- nsName = name;
1151
- }
1152
- }
1153
- if (nsKey === false) {
1154
- return [];
1155
- }
1156
- const namespace = this.storage[nsKey];
1157
- const lookup = reference + match;
1158
- const result = [];
1159
- const pattern = '^' +
1312
+ // RFC 3501 section 6.3.8 (RFC 9051 section 6.3.9): "An empty ("" string) reference name argument
1313
+ // indicates that the mailbox name is interpreted as by SELECT", and without break out characters
1314
+ // "the canonical form is normally the reference name appended with the mailbox name". The pattern
1315
+ // matches full mailbox names, also in a personal namespace with a prefix such as "INBOX.". With the
1316
+ // namespace convention "#" is a break out character "and must be treated as such" (RFC 3501 section 6.3.8,
1317
+ // RFC 9051 section 6.3.9): a pattern that starts with it is a name of its own, the reference is ignored
1318
+ const lookup = match.charAt(0) === '#' ? match : (referenceName || '') + match;
1319
+ // "%" does not match the hierarchy delimiter, which is the one of the namespace a name belongs to
1320
+ const toRegExp = (separator, flags = '') => new RegExp('^' +
1160
1321
  lookup
1161
1322
  // escape regex symbols
1162
1323
  .replace(/([\\^$+?!.():=[\]{}|,-])/g, '\\$1')
1163
1324
  .replace(/[*]/g, '.*')
1164
- .replace(/[%]/g, '[^' + namespace.separator.replace(/([\\^$+*?!.():=[\]{}|,-])/g, '\\$1') + ']*') +
1165
- '$';
1166
- const query = new RegExp(pattern, '');
1167
- // INBOX is case-insensitive
1168
- if (includeINBOX && source.INBOX && ((reference ? reference + namespace.separator : '') + 'INBOX').match(new RegExp(pattern, 'i'))) {
1325
+ .replace(/[%]/g, '[^' + separator.replace(/([\\^$+*?!.():=[\]{}|,-])/g, '\\$1') + ']*') +
1326
+ '$', flags);
1327
+ // RFC 3501 section 6.3.8 allows to "hide" otherwise accessible mailboxes from the wildcards: the
1328
+ // mailboxes of namespaces other than the personal one are only matched when the pattern names
1329
+ // the prefix of the namespace before any wildcard (LIST "" "user.%", LIST "#news." "*"). The pattern
1330
+ // of every namespace that is matched, null for a hidden one
1331
+ const fixedPrefix = lookup.replace(/[*%].*$/, '');
1332
+ const queries = new Map();
1333
+ Object.keys(this.storage).forEach(key => {
1334
+ const name = toName(key);
1335
+ const visible = key === this.referenceNamespace || fixedPrefix.substr(0, name.length) === name;
1336
+ queries.set(key, visible ? toRegExp(this.storage[key].separator) : null);
1337
+ });
1338
+ const result = [];
1339
+ // "The special name INBOX is included in the output from LIST, if [...] the uppercase string "INBOX"
1340
+ // matches the interpreted reference and mailbox name arguments", INBOX is case-insensitive
1341
+ if (source.INBOX && toRegExp((this.storage.INBOX && this.storage.INBOX.separator) || '/', 'i').test('INBOX')) {
1169
1342
  result.push(source.INBOX);
1170
1343
  }
1171
1344
  Object.keys(source).forEach(path => {
1172
1345
  const folder = source[path];
1173
- if (folder.namespace !== nsKey) {
1346
+ const query = path !== 'INBOX' && folder.namespace !== false && folder.namespace !== 'INBOX' ? queries.get(folder.namespace) : null;
1347
+ if (!query) {
1174
1348
  return;
1175
1349
  }
1176
1350
  const name = toName(path);
1177
- if (name.match(query) && (folder.flags.indexOf('\\NonExistent') < 0 || name === match)) {
1351
+ if (query.test(name) && (folder.flags.indexOf('\\NonExistent') < 0 || name === lookup)) {
1178
1352
  result.push(folder);
1179
1353
  }
1180
1354
  });
@@ -1293,6 +1467,7 @@ class IMAPConnection {
1293
1467
  this.notificationQueue = [];
1294
1468
  this.server.on('notify', this._notificationCallback);
1295
1469
  this.server.connections.add(this);
1470
+ this.emitSession('open');
1296
1471
  this.scriptOutput('greeting', '* OK ImapKit ready for rumble\r\n', {});
1297
1472
  }
1298
1473
  /**
@@ -1327,7 +1502,7 @@ class IMAPConnection {
1327
1502
  }
1328
1503
  this._outputQueue = this._outputQueue || [];
1329
1504
  this._outputQueue.push(Object.assign({}, operation, { transport: this.transport }));
1330
- if (!this._outputTimer) {
1505
+ if (!this._outputTimer && !this._outputWaiting) {
1331
1506
  this.flushOutput();
1332
1507
  }
1333
1508
  }
@@ -1348,10 +1523,28 @@ class IMAPConnection {
1348
1523
  return;
1349
1524
  }
1350
1525
  if (operation.data && operation.chunk && operation.data.length > operation.chunk) {
1351
- // the rest waits for chunkDelay, like a delay of its own
1352
- this.writeLayer(operation.data.subarray(0, operation.chunk), operation.transport || null);
1526
+ // the rest waits for chunkDelay, like a delay of its own. Without a delay each piece waits for the next
1527
+ // event loop turn, and Nagle's algorithm must not hold it back to merge it with the next one
1528
+ const piece = operation.data.subarray(0, operation.chunk);
1353
1529
  operation.data = operation.data.subarray(operation.chunk);
1530
+ if (!operation.chunkDelay || operation.chunkDelay === 'tick') {
1531
+ // the next piece waits until this one was handed to the system, and one more event loop turn, so
1532
+ // the pieces leave as separate segments without a wall clock delay (#89)
1533
+ this.socket?.setNoDelay?.(true);
1534
+ this._outputWaiting = true;
1535
+ this.writeLayer(piece, operation.transport || null, () => {
1536
+ if (this._outputWaiting && this._outputQueue === queue) {
1537
+ this._outputTimer = nextTurn(() => {
1538
+ this._outputTimer = null;
1539
+ this._outputWaiting = false;
1540
+ this.flushOutput();
1541
+ });
1542
+ }
1543
+ });
1544
+ return;
1545
+ }
1354
1546
  operation.delay = operation.chunkDelay;
1547
+ this.writeLayer(piece, operation.transport || null);
1355
1548
  continue;
1356
1549
  }
1357
1550
  if (operation.data) {
@@ -1371,8 +1564,10 @@ class IMAPConnection {
1371
1564
  clearOutputQueue() {
1372
1565
  if (this._outputTimer) {
1373
1566
  clearTimeout(this._outputTimer);
1567
+ clearImmediate(this._outputTimer);
1374
1568
  this._outputTimer = null;
1375
1569
  }
1570
+ this._outputWaiting = false;
1376
1571
  this._outputQueue = null;
1377
1572
  this._deferredOutput = null;
1378
1573
  }
@@ -1381,13 +1576,17 @@ class IMAPConnection {
1381
1576
  *
1382
1577
  * @param {Buffer} data Output
1383
1578
  * @param {Object|null} transport Transport layer
1579
+ * @param {Function} [callback] Called once the data was handed to the system (a transport: on the next turn)
1384
1580
  */
1385
- writeLayer(data, transport) {
1581
+ writeLayer(data, transport, callback) {
1386
1582
  if (transport) {
1387
1583
  transport.write(data);
1584
+ if (callback) {
1585
+ setImmediate(callback);
1586
+ }
1388
1587
  }
1389
1588
  else {
1390
- this.writeRaw(data);
1589
+ this.writeRaw(data, callback);
1391
1590
  }
1392
1591
  }
1393
1592
  /**
@@ -1441,17 +1640,70 @@ class IMAPConnection {
1441
1640
  *
1442
1641
  * @param {Buffer} data Data to send
1443
1642
  */
1444
- writeRaw(data) {
1643
+ writeRaw(data, callback) {
1445
1644
  if (this.socket && !this.socket.destroyed) {
1446
- this.socket.write(data);
1645
+ this.socket.write(data, callback);
1646
+ this.touch();
1647
+ }
1648
+ }
1649
+ /**
1650
+ * Notes input or output for the quiet events of script rules (#88)
1651
+ */
1652
+ touch() {
1653
+ if (!this.server.script.watches('quiet')) {
1654
+ // nothing waits for quiet sessions, a rule added later counts from then on
1655
+ this._lastActivity = undefined;
1656
+ return;
1657
+ }
1658
+ this._lastActivity = Date.now();
1659
+ if (!this._quietTimer) {
1660
+ this.watchQuiet();
1447
1661
  }
1448
1662
  }
1663
+ /**
1664
+ * Starts waiting for the next quiet event, if a rule waits for one and the session is not waiting already. When
1665
+ * the time is up, the first rule that matches handles the event (`send`, `close`), the output it sends starts
1666
+ * the next quiet time
1667
+ *
1668
+ * @param {Number} [checked] Quiet time the rules were checked for already, only longer ones are waited for
1669
+ */
1670
+ watchQuiet(checked = 0) {
1671
+ if (this._quietTimer || !this.socket || this._closing) {
1672
+ return;
1673
+ }
1674
+ const last = this._lastActivity || Date.now();
1675
+ this._lastActivity = last;
1676
+ // the time is measured from the last activity, a rule whose time is up already fires right away
1677
+ const next = this.server.script.nextQuiet(checked);
1678
+ if (next === null) {
1679
+ return;
1680
+ }
1681
+ this._quietTimer = setTimeout(() => {
1682
+ this._quietTimer = null;
1683
+ if (!this.isOpen() || this._lastActivity !== last) {
1684
+ // there was activity in between, it started a new wait
1685
+ return this.watchQuiet();
1686
+ }
1687
+ // a timer can fire a millisecond before Date.now() says the time is up
1688
+ const quiet = Math.max(Date.now() - last, next);
1689
+ const found = this.server.script.check(this, 'quiet', { data: '', quiet });
1690
+ if (found) {
1691
+ (0, script_js_1.handleQuiet)(this, found.rule, found.context);
1692
+ }
1693
+ if (this._lastActivity === last) {
1694
+ // nothing was sent, wait for the rules with a longer quiet time
1695
+ this.watchQuiet(quiet);
1696
+ }
1697
+ }, Math.max(0, next - (Date.now() - last)));
1698
+ this._quietTimer.unref();
1699
+ }
1449
1700
  /**
1450
1701
  * Handles data from the socket, through the transport layer if there is one
1451
1702
  *
1452
1703
  * @param {Buffer} chunk Received data
1453
1704
  */
1454
1705
  receive(chunk) {
1706
+ this.touch();
1455
1707
  if (this.transport) {
1456
1708
  this.transport.receive(chunk);
1457
1709
  }
@@ -1584,11 +1836,15 @@ class IMAPConnection {
1584
1836
  * notifications that were not sent yet are dropped. Sends nothing, the caller answers the command
1585
1837
  */
1586
1838
  closeMailbox() {
1839
+ const wasSelected = !!this.selectedMailbox;
1587
1840
  this.state = 'Authenticated';
1588
1841
  this.selectedMailbox = false;
1589
1842
  this.readOnly = false;
1590
1843
  this.recent = null;
1591
1844
  this.notificationQueue = [];
1845
+ if (wasSelected) {
1846
+ this.emitSession('unselect');
1847
+ }
1592
1848
  }
1593
1849
  onClose() {
1594
1850
  if (this.socket) {
@@ -1600,8 +1856,14 @@ class IMAPConnection {
1600
1856
  this.transport = null;
1601
1857
  }
1602
1858
  this.clearOutputQueue();
1859
+ if (this._quietTimer) {
1860
+ clearTimeout(this._quietTimer);
1861
+ this._quietTimer = null;
1862
+ }
1603
1863
  this.server.removeListener('notify', this._notificationCallback);
1604
- this.server.connections.delete(this);
1864
+ if (this.server.connections.delete(this)) {
1865
+ this.emitSession('close');
1866
+ }
1605
1867
  }
1606
1868
  onError(err) {
1607
1869
  if (this.options.debug) {
@@ -1944,7 +2206,7 @@ class IMAPConnection {
1944
2206
  * @return {Boolean} true if an EXPUNGE response is pending
1945
2207
  */
1946
2208
  hasPendingExpunge() {
1947
- return this.notificationQueue.some(notification => notification.attributes && (notification.attributes[1] || {}).value === 'EXPUNGE');
2209
+ return this.notificationQueue.some(isPendingExpunge);
1948
2210
  }
1949
2211
  /**
1950
2212
  * Tells the other sessions that have the selected mailbox open about changed flags, they get
@@ -1953,8 +2215,8 @@ class IMAPConnection {
1953
2215
  * @param {Array} messages Messages with changed flags
1954
2216
  */
1955
2217
  notifyFlagChanges(messages) {
1956
- if (messages.length && this.selectedMailbox) {
1957
- this.server.notify({ tag: '*', flagUpdate: messages }, this.selectedMailbox, this);
2218
+ if (this.selectedMailbox) {
2219
+ (0, store_operations_js_1.notifyFlagChanges)(this.server, this.selectedMailbox, messages, this);
1958
2220
  }
1959
2221
  }
1960
2222
  /**
@@ -2151,22 +2413,52 @@ class IMAPConnection {
2151
2413
  prepareNotifications(queue) {
2152
2414
  return queue;
2153
2415
  }
2154
- processNotifications(data) {
2155
- const options = data && this.server.getCommandOptions(data.command);
2156
- if (options && (options.noExpunge || (options.searchCriteria !== false && this.usesSequenceNumbers(data)))) {
2157
- // EXPUNGE responses are not allowed during FETCH, STORE and SEARCH (RFC 3501 section 7.4.1), during
2158
- // the commands that extensions add to this list (see the noExpunge command option), nor during UID
2159
- // SEARCH with message numbers in the search criteria (RFC 7162 section 3.2.10.2 for VANISHED, EXPUNGE
2160
- // may wait as well, RFC 3501 only allows it during UID commands)
2161
- return;
2162
- }
2416
+ /**
2417
+ * Checks if EXPUNGE responses must wait while a command runs: during FETCH, STORE and SEARCH (RFC 3501 section
2418
+ * 7.4.1), during the commands that extensions add to this list (see the noExpunge command option), and during UID
2419
+ * SEARCH with message numbers in the search criteria (RFC 7162 section 3.2.10.2 for VANISHED, EXPUNGE may wait as
2420
+ * well, RFC 3501 only allows it during UID commands)
2421
+ *
2422
+ * @param {Object} data Parsed command
2423
+ * @return {Boolean} true if the command holds back EXPUNGE responses
2424
+ */
2425
+ holdsExpunge(data) {
2426
+ const options = this.server.getCommandOptions(data.command);
2427
+ return options.noExpunge || (options.searchCriteria !== false && this.usesSequenceNumbers(data));
2428
+ }
2429
+ /**
2430
+ * Sends the queued notifications. During a command that holds back EXPUNGE responses (see holdsExpunge), or with
2431
+ * `beforeCommand` before a command that refers to messages by sequence number, only the notifications queued before
2432
+ * the first pending EXPUNGE go out: new messages (EXISTS) and flag changes. RFC 3501 section 5.2: "A server MUST send
2433
+ * mailbox size updates automatically if a mailbox size change is observed during the processing of a command",
2434
+ * section 7.4.1 forbids only EXPUNGE during FETCH, STORE and SEARCH. What was queued after the EXPUNGE waits with it,
2435
+ * an EXISTS sent before it would describe a list that the client can not know yet
2436
+ *
2437
+ * @param {Object} [data] Parsed command that runs, or null between commands
2438
+ * @param {Boolean} [beforeCommand] true when the command has not run yet, then a command with message sequence
2439
+ * numbers (COPY, MOVE) also waits with the EXPUNGE responses, its numbers refer to the messages before them
2440
+ */
2441
+ processNotifications(data, beforeCommand) {
2163
2442
  if (!this.notificationQueue.length) {
2164
2443
  return;
2165
2444
  }
2166
- const queue = this.prepareNotifications(this.notificationQueue);
2167
- this.notificationQueue = [];
2445
+ let queue = this.notificationQueue;
2168
2446
  // Flag updates use the sequence numbers this session knows: before the EXPUNGE responses of
2169
2447
  // the snapshot are sent, the snapshot, afterwards the current message list
2448
+ const snapshot = queue.find(notification => notification.mailboxCopy);
2449
+ let held = [];
2450
+ if (data && (this.holdsExpunge(data) || (beforeCommand && this.usesSequenceNumbers(data)))) {
2451
+ const first = queue.findIndex(isPendingExpunge);
2452
+ if (first >= 0) {
2453
+ held = queue.slice(first);
2454
+ queue = queue.slice(0, first);
2455
+ }
2456
+ if (!queue.length) {
2457
+ return;
2458
+ }
2459
+ }
2460
+ this.notificationQueue = held;
2461
+ queue = this.prepareNotifications(queue);
2170
2462
  const snapshotIndex = queue.findIndex(notification => notification.mailboxCopy);
2171
2463
  const sequenceMaps = new Map();
2172
2464
  const getSequence = (messages) => {
@@ -2180,15 +2472,57 @@ class IMAPConnection {
2180
2472
  const current = this.selectedMailbox ? this.selectedMailbox.messages : [];
2181
2473
  // a message changed several times is reported once, its FETCH response carries the current flags
2182
2474
  const reported = new Set();
2475
+ // before the snapshot (or with all of it still held back, then the split at the first pending expunge leaves
2476
+ // no snapshot in the queue) the session knows the old list
2477
+ const sessionList = (i) => (snapshot && (snapshotIndex < 0 || i < snapshotIndex) ? snapshot.mailboxCopy : current);
2478
+ // the last EXISTS response of a run of EXISTS responses, and if the run announces new messages
2479
+ let lastExists = -1;
2480
+ let newMessages = false;
2183
2481
  queue.forEach((notification, i) => {
2184
2482
  if (notification.flagUpdate) {
2185
- // i < snapshotIndex only when there is a snapshot
2186
- this.sendFlagUpdate(notification.flagUpdate, getSequence(i < snapshotIndex ? queue[snapshotIndex].mailboxCopy : current), reported);
2483
+ this.sendFlagUpdate(notification.flagUpdate, getSequence(sessionList(i)), reported);
2187
2484
  }
2188
2485
  else {
2189
2486
  this.send(notification);
2190
2487
  }
2488
+ if (isExists(notification)) {
2489
+ lastExists = i;
2490
+ // the EXISTS after the EXPUNGE responses of another session carries the snapshot instead of a message
2491
+ newMessages = newMessages || !!notification.message;
2492
+ }
2493
+ const next = queue[i + 1];
2494
+ if (lastExists < 0 || isExists(next) || (next && next.fetchedMessage)) {
2495
+ // not the end of a run of EXISTS responses (and the FETCH responses NOTIFY sends after them)
2496
+ return;
2497
+ }
2498
+ // RFC 3501 section 7.3.2: the RECENT response "occurs as a result of a SELECT or EXAMINE command, and if the
2499
+ // size of the mailbox changes (e.g., new messages)". One for consecutive EXISTS responses that announce new
2500
+ // messages, with the number of \Recent messages among those the client was told about. After expunges the
2501
+ // EXPUNGE responses report the change (RFC 3501 section 7.4.1). It goes after the FETCH responses that NOTIFY
2502
+ // sends for new messages, RFC 5465 section 5.2: "an unsolicited EXISTS response, followed by an unsolicited
2503
+ // FETCH response [...] The server MAY also send a RECENT response"
2504
+ if (newMessages) {
2505
+ this.sendRecent(Number(queue[lastExists].attributes[0]), getSequence(sessionList(lastExists)));
2506
+ }
2507
+ lastExists = -1;
2508
+ newMessages = false;
2509
+ });
2510
+ }
2511
+ /**
2512
+ * Sends an untagged RECENT response with the number of \Recent messages among the first `count` messages
2513
+ *
2514
+ * @param {Number} count Number of messages the client was told about
2515
+ * @param {Map} sequence Message to the sequence number this session knows it by
2516
+ */
2517
+ sendRecent(count, sequence) {
2518
+ let recent = 0;
2519
+ (this.recent || new Set()).forEach(message => {
2520
+ const seq = sequence.get(message);
2521
+ if (seq && seq <= count) {
2522
+ recent++;
2523
+ }
2191
2524
  });
2525
+ this.send({ tag: '*', notification: true, attributes: [recent, { type: 'ATOM', value: 'RECENT' }] }, 'RECENT NOTIFICATION');
2192
2526
  }
2193
2527
  /**
2194
2528
  * Compile a command object to a response string and write it to socket.
@@ -2210,7 +2544,7 @@ class IMAPConnection {
2210
2544
  */
2211
2545
  send(response, description, parsed, data, ...extra) {
2212
2546
  // nothing goes out once the connection is closing (RFC 3501 section 7.1.5)
2213
- if (!this.socket || this.socket.destroyed || this._closing) {
2547
+ if (!this.isOpen()) {
2214
2548
  return;
2215
2549
  }
2216
2550
  if (!response.notification && response.tag !== '*') {
@@ -2220,6 +2554,9 @@ class IMAPConnection {
2220
2554
  this.server.outputHandlers.forEach(handler => {
2221
2555
  handler(this, response, description, parsed, data, ...extra);
2222
2556
  });
2557
+ if (this._commandStart && parsed && response.tag === this._commandStart.tag && parsed.tag === response.tag) {
2558
+ this.commandCompleted(String(response.command || '').toUpperCase());
2559
+ }
2223
2560
  // No need to display this response to user
2224
2561
  if (response.skipResponse) {
2225
2562
  return;
@@ -2229,10 +2566,11 @@ class IMAPConnection {
2229
2566
  response.tag === parsed.tag &&
2230
2567
  response.command === 'OK' &&
2231
2568
  this.hasPendingExpunge() &&
2232
- this.server.getCommandOptions(parsed.command).noExpunge &&
2569
+ this.holdsExpunge(parsed) &&
2233
2570
  !(Array.isArray(response.attributes) && response.attributes.some(attr => attr && attr.type === 'SECTION'))) {
2234
2571
  // After the output handlers, they might add a response code of their own (MODIFIED of CONDSTORE).
2235
- // FETCH, STORE, SEARCH and the like can not report the EXPUNGE of another session (RFC 3501 section 7.4.1),
2572
+ // FETCH, STORE, SEARCH and the like can not report the EXPUNGE of another session (RFC 3501 section 7.4.1), nor
2573
+ // can UID SEARCH with message numbers in the criteria (see holdsExpunge),
2236
2574
  // EXPUNGEISSUED tells the client to issue NOOP soon (RFC 5530 section 3, RFC 9051 section 7.1)
2237
2575
  response.attributes = [{ type: 'SECTION', section: [{ type: 'ATOM', value: 'EXPUNGEISSUED' }] }].concat(response.attributes || []);
2238
2576
  }
@@ -2376,6 +2714,15 @@ class IMAPConnection {
2376
2714
  * @return {Boolean} true if the command uses message sequence numbers
2377
2715
  */
2378
2716
  usesSequenceNumbers(parsed) {
2717
+ // the notification checks ask several times per command, the answer does not change
2718
+ let cached = sequenceNumberUse.get(parsed);
2719
+ if (cached === undefined) {
2720
+ cached = this.findSequenceNumbers(parsed);
2721
+ sequenceNumberUse.set(parsed, cached);
2722
+ }
2723
+ return cached;
2724
+ }
2725
+ findSequenceNumbers(parsed) {
2379
2726
  const { sequenceSet, searchCriteria } = this.server.getCommandOptions(parsed.command);
2380
2727
  if (sequenceSet !== false) {
2381
2728
  // other forms of sequence sets, like "$" of SEARCHRES (RFC 5182 section 2.3), do not use numbers
@@ -2554,12 +2901,14 @@ class IMAPConnection {
2554
2901
  this.refusePipelined(parsed, data);
2555
2902
  return;
2556
2903
  }
2557
- if (this.server.getCommandHandler(parsed.command)) {
2904
+ // an unknown SASL mechanism is answered by its own handler, after the central checks (state, script rules)
2905
+ const fallback = /^AUTHENTICATE /i.test(parsed.command) && !this.server.getCommandHandler(parsed.command) ? unsupportedMechanism : undefined;
2906
+ if (fallback || this.server.getCommandHandler(parsed.command)) {
2558
2907
  if (this.isAmbiguous(parsed)) {
2559
2908
  this.sendStatus(parsed, data, 'BAD', 'Commands with message sequence numbers must wait for the completion of earlier commands');
2560
2909
  return;
2561
2910
  }
2562
- const element = { parsed, data };
2911
+ const element = { parsed, data, handler: fallback };
2563
2912
  if (scripted) {
2564
2913
  // processQueue runs it once the script rule released the queue
2565
2914
  this._commandQueue.unshift(element);
@@ -2569,19 +2918,6 @@ class IMAPConnection {
2569
2918
  }
2570
2919
  this.processQueue();
2571
2920
  }
2572
- else if (/^AUTHENTICATE /i.test(parsed.command)) {
2573
- // an unsupported mechanism is a NO, not a syntax error (RFC 3501 section 6.2.2)
2574
- this.send({
2575
- tag: parsed.tag,
2576
- command: 'NO',
2577
- attributes: [
2578
- {
2579
- type: 'TEXT',
2580
- value: 'Unsupported authentication mechanism'
2581
- }
2582
- ]
2583
- }, 'UNKNOWN COMMAND', parsed, data);
2584
- }
2585
2921
  else {
2586
2922
  this.send({
2587
2923
  tag: parsed.tag,
@@ -2595,6 +2931,64 @@ class IMAPConnection {
2595
2931
  }, 'UNKNOWN COMMAND', parsed, data);
2596
2932
  }
2597
2933
  }
2934
+ /**
2935
+ * Emits the `command` event when the tagged response of a command goes out, and the `session` events "login"
2936
+ * (the command authenticated the session) and "logout" (back to Not Authenticated, UNAUTHENTICATE). Every command
2937
+ * that changes these does so before its tagged response. "select" and "unselect" come from SELECT and
2938
+ * closeMailbox(). Tests wait for these instead of polling
2939
+ *
2940
+ * @param {String} status OK, NO or BAD
2941
+ */
2942
+ commandCompleted(status) {
2943
+ const start = this._commandStart;
2944
+ this._commandStart = null;
2945
+ const server = this.server;
2946
+ if (!server.listenerCount('command') && !server.listenerCount('session')) {
2947
+ return;
2948
+ }
2949
+ server.emit('command', { session: this.sessionNumber, tag: start.tag, command: start.command, status, user: this.username || null });
2950
+ const authenticated = (state) => state === 'Authenticated' || state === 'Selected';
2951
+ if (authenticated(start.state) !== authenticated(this.state) && this.state !== 'Logout') {
2952
+ this.emitSession(authenticated(this.state) ? 'login' : 'logout');
2953
+ }
2954
+ }
2955
+ /**
2956
+ * Emits a `session` event, `{ type, session, ...fields }`
2957
+ *
2958
+ * @param {String} type Event type
2959
+ * @param {Object} [fields] More fields of the event
2960
+ */
2961
+ emitSession(type, fields) {
2962
+ if (this.server.listenerCount('session')) {
2963
+ this.server.emit('session', Object.assign({ type, session: this.describe() }, fields));
2964
+ }
2965
+ }
2966
+ /**
2967
+ * Describes the session without credentials or sockets, for the control API and the `session` events
2968
+ *
2969
+ * @return {Object} `{ session, user, state, mailbox, readOnly, enabled, secure, compressed, remoteAddress }`
2970
+ */
2971
+ describe() {
2972
+ return {
2973
+ session: this.sessionNumber,
2974
+ user: this.username || null,
2975
+ state: this.state,
2976
+ mailbox: this.selectedMailbox ? this.selectedMailbox.path : null,
2977
+ readOnly: !!this.selectedMailbox && !!this.readOnly,
2978
+ enabled: Array.isArray(this.enabled) ? this.enabled.slice() : [],
2979
+ secure: !!this.secureConnection,
2980
+ compressed: !!this.transport,
2981
+ remoteAddress: (this.socket && this.socket.remoteAddress) || null
2982
+ };
2983
+ }
2984
+ /**
2985
+ * Checks if output can still be sent: the socket is open and the connection is not closing (BYE, LOGOUT)
2986
+ *
2987
+ * @return {Boolean} true for a live session
2988
+ */
2989
+ isOpen() {
2990
+ return !!this.socket && !this.socket.destroyed && !this._closing;
2991
+ }
2598
2992
  /**
2599
2993
  * Handles a command line with the script rule that matched it, after the rule's delay. With `run` the line
2600
2994
  * goes through the parser and the command handler as usual afterwards
@@ -2635,6 +3029,8 @@ class IMAPConnection {
2635
3029
  const command = element.parsed.command.toUpperCase();
2636
3030
  this._runningCommand = element;
2637
3031
  const options = this.server.getCommandOptions(command);
3032
+ // the session events of the command are worked out when its tagged response goes out, see commandCompleted
3033
+ this._commandStart = { tag: element.parsed.tag, command, state: this.state };
2638
3034
  let done = false;
2639
3035
  const next = () => {
2640
3036
  if (done) {
@@ -2689,61 +3085,66 @@ class IMAPConnection {
2689
3085
  return next();
2690
3086
  }
2691
3087
  }
2692
- if (command.substr(0, 4) === 'UID ' && this.hasPendingExpunge()) {
2693
- // EXPUNGE responses may be sent during UID commands (RFC 3501 section 7.4.1). The expunges of other sessions
2694
- // are reported first, then the command runs on the current mailbox, where the UIDs of the expunged messages
2695
- // do not exist and are ignored (RFC 3501 section 6.4.8), so the ghost handling of STORE, COPY and MOVE (RFC 2180
2696
- // section 4) only applies to their sequence number forms. Not for UID SEARCH with message numbers in the
2697
- // criteria, processNotifications knows when EXPUNGE must wait
2698
- this.processNotifications(element.parsed);
2699
- }
2700
- try {
2701
- // changes made while the handler runs are attributed to this session (the `origin` of notifications)
2702
- this.server.activeConnection = this;
2703
- const inputHandler = this.inputHandler;
2704
- this.server.getCommandHandler(element.parsed.command)(this, element.parsed, element.data, next);
2705
- if (this.inputHandler && this.inputHandler !== inputHandler) {
2706
- // the command reads the lines that follow (IDLE, AUTHENTICATE), script rules match them with it
2707
- this.inputCommand = { tag: element.parsed.tag, command: element.parsed.command };
3088
+ if (this.state === 'Selected' &&
3089
+ (command.startsWith('UID ') || options.noExpunge || options.sequenceSet !== false || options.searchCriteria !== false)) {
3090
+ // A command that refers to messages runs on the message list the client was told about. Only these: CLOSE
3091
+ // must not send pending EXPUNGE responses (RFC 3501 section 6.4.2) and SELECT leaves the old mailbox. RFC 3501 section 5.2:
3092
+ // "A server MUST send mailbox size updates automatically if a mailbox size change is observed during the
3093
+ // processing of a command", so the new messages and flag changes of other sessions are reported first,
3094
+ // before the command resolves its sequence set or search criteria.
3095
+ // EXPUNGE responses may be sent during UID commands (RFC 3501 section 7.4.1), so these report the expunges of
3096
+ // other sessions first as well, then the command runs on the current mailbox, where the UIDs of the expunged
3097
+ // messages do not exist and are ignored (RFC 3501 section 6.4.8). The ghost handling of STORE, COPY and MOVE
3098
+ // (RFC 2180 section 4) only applies to their sequence number forms, these and UID SEARCH with message numbers
3099
+ // in the criteria keep the EXPUNGE responses for later, see processNotifications
3100
+ this.processNotifications(element.parsed, true);
3101
+ }
3102
+ // changes made while the handler runs are attributed to this session (the `origin` of notifications)
3103
+ this.server.withOrigin(this, () => {
3104
+ try {
3105
+ const inputHandler = this.inputHandler;
3106
+ (element.handler || this.server.getCommandHandler(element.parsed.command))(this, element.parsed, element.data, next);
3107
+ if (this.inputHandler && this.inputHandler !== inputHandler) {
3108
+ // the command reads the lines that follow (IDLE, AUTHENTICATE), script rules match them with it
3109
+ this.inputCommand = { tag: element.parsed.tag, command: element.parsed.command };
3110
+ this.emitSession('waiting', { command });
3111
+ }
2708
3112
  }
2709
- }
2710
- catch (E) {
2711
- const ex = E;
2712
- const badInput = ex.imapResponse === 'BAD';
2713
- if (!badInput && this.options.debug) {
2714
- console.error('Error processing command:', ex, '\n', ex.stack);
3113
+ catch (E) {
3114
+ const ex = E;
3115
+ const badInput = ex.imapResponse === 'BAD';
3116
+ if (!badInput && this.options.debug) {
3117
+ console.error('Error processing command:', ex, '\n', ex.stack);
3118
+ }
3119
+ this.send({
3120
+ tag: element.parsed.tag,
3121
+ command: badInput ? 'BAD' : 'NO',
3122
+ attributes: [].concat(badInput
3123
+ ? []
3124
+ : {
3125
+ type: 'SECTION',
3126
+ section: [
3127
+ {
3128
+ type: 'ATOM',
3129
+ value: 'SERVERBUG'
3130
+ }
3131
+ ]
3132
+ }, {
3133
+ type: 'TEXT',
3134
+ value: badInput ? ex.message : 'Server error: ' + ex.message
3135
+ })
3136
+ }, badInput ? 'INVALID COMMAND' : 'SERVER ERROR', element.parsed, element.data);
3137
+ // keep the connection usable, otherwise every later command would hang
3138
+ next();
2715
3139
  }
2716
- this.send({
2717
- tag: element.parsed.tag,
2718
- command: badInput ? 'BAD' : 'NO',
2719
- attributes: [].concat(badInput
2720
- ? []
2721
- : {
2722
- type: 'SECTION',
2723
- section: [
2724
- {
2725
- type: 'ATOM',
2726
- value: 'SERVERBUG'
2727
- }
2728
- ]
2729
- }, {
2730
- type: 'TEXT',
2731
- value: badInput ? ex.message : 'Server error: ' + ex.message
2732
- })
2733
- }, badInput ? 'INVALID COMMAND' : 'SERVER ERROR', element.parsed, element.data);
2734
- // keep the connection usable, otherwise every later command would hang
2735
- next();
2736
- }
2737
- finally {
2738
- this.server.activeConnection = null;
2739
- }
3140
+ });
2740
3141
  }
2741
3142
  /**
2742
3143
  * Removes messages with \Deleted flag
2743
3144
  *
2744
3145
  * @param {Object} mailbox Mailbox to check for
2745
3146
  * @param {Boolean} [ignoreSelf] If set to true, does not send any notices to itself
2746
- * @param {Boolean} [ignoreSelf] If set to true, does not send EXISTS notice to itself
3147
+ * @param {Boolean} [ignoreExists] If set to true, does not send EXISTS notice to itself
2747
3148
  */
2748
3149
  expungeDeleted(mailbox, ignoreSelf, ignoreExists) {
2749
3150
  this.expungeSpecificMessages(mailbox, (message) => {
@@ -2752,81 +3153,56 @@ class IMAPConnection {
2752
3153
  }
2753
3154
  /**
2754
3155
  * Given a set of messages in a mailbox (possibly via getMessageRange), remove
2755
- * them from the mailbox and generate EXPUNGE notifications.
3156
+ * them from the mailbox and generate EXPUNGE notifications, see expungeMessages() in store-operations.ts
2756
3157
  *
2757
3158
  * @param {Object} mailbox Mailbox to check for
2758
3159
  * @param {Function|Array} messagesOrFilterFunc An Array of messages in the
2759
3160
  * folder that should be removed or a filtering function that indicates
2760
3161
  * messages to be removed by returning true.
2761
3162
  * @param {Boolean} [ignoreSelf] If set to true, does not send any notices to itself
2762
- * @param {Boolean} [ignoreSelf] If set to true, does not send EXISTS notice to itself
3163
+ * @param {Boolean} [ignoreExists] If set to true, does not send EXISTS notice to itself
2763
3164
  * @param {Boolean} [highestFirst] If set to true, the EXPUNGE responses go from the highest UID to the lowest
2764
3165
  * (MESSAGELIMIT, RFC 9738 section 3.1), otherwise from the lowest
2765
3166
  */
2766
3167
  expungeSpecificMessages(mailbox, messagesOrFilterFunc, ignoreSelf, ignoreExists, highestFirst) {
2767
- let filterFunc;
2768
- if (Array.isArray(messagesOrFilterFunc)) {
2769
- const messageSet = new Set(messagesOrFilterFunc);
2770
- filterFunc = (message) => messageSet.has(message);
2771
- }
2772
- else {
2773
- filterFunc = messagesOrFilterFunc;
2774
- }
2775
- // sequence numbers of the removed messages, each one as it is after the earlier EXPUNGE responses. From the
2776
- // highest message down, the earlier responses do not change the sequence numbers of the later ones
2777
- const expunged = [];
2778
- const kept = [];
2779
- mailbox.messages.forEach((message, i) => {
2780
- if (filterFunc(message)) {
2781
- message.ghost = true;
2782
- expunged.push({ seq: highestFirst ? i + 1 : kept.length + 1, message });
2783
- }
2784
- else {
2785
- kept.push(message);
2786
- }
2787
- });
2788
- if (!expunged.length) {
2789
- return;
2790
- }
2791
- // old copy is required for those sessions that run FETCH before
2792
- // displaying the EXPUNGE notice
2793
- const mailboxCopy = mailbox.messages.slice();
2794
- // update the list in place, other code might hold a reference to it
2795
- kept.forEach((message, i) => {
2796
- mailbox.messages[i] = message;
2797
- });
2798
- mailbox.messages.length = kept.length;
2799
- // lets plugins track the removal (e.g. mod-sequences of CONDSTORE and QRESYNC) before any notification
2800
- this.server.emit('expunge', mailbox, expunged.map(entry => entry.message), this);
2801
- (highestFirst ? expunged.slice().reverse() : expunged).forEach(entry => {
2802
- this.server.notify({
2803
- tag: '*',
2804
- attributes: [
2805
- entry.seq,
2806
- {
2807
- type: 'ATOM',
2808
- value: 'EXPUNGE'
2809
- }
2810
- ],
2811
- // the removed message, for plugins that report it differently (e.g. VANISHED of QRESYNC)
2812
- message: entry.message
2813
- }, mailbox, ignoreSelf ? this : false);
3168
+ (0, store_operations_js_1.expungeMessages)(this.server, mailbox, messagesOrFilterFunc, {
3169
+ origin: this,
3170
+ skip: ignoreSelf ? this : null,
3171
+ skipExists: ignoreExists ? this : null,
3172
+ highestFirst: !!highestFirst
2814
3173
  });
2815
- this.server.notify({
2816
- tag: '*',
2817
- attributes: [
2818
- mailbox.messages.length,
2819
- {
2820
- type: 'ATOM',
2821
- value: 'EXISTS'
2822
- }
2823
- ],
2824
- // distribute the old mailbox data with the notification
2825
- mailboxCopy: mailboxCopy
2826
- }, mailbox, ignoreSelf || ignoreExists ? this : false);
2827
3174
  }
2828
3175
  }
2829
3176
  exports.IMAPConnection = IMAPConnection;
3177
+ // setImmediate does not let Deno send what sockets were given, see nextTurn
3178
+ const IS_DENO = 'Deno' in globalThis;
3179
+ /**
3180
+ * Runs a function on the next event loop turn that also handles network I/O: setImmediate on Node and Bun, a zero
3181
+ * timer on Deno, where setImmediate runs before the sockets send what was written (#89)
3182
+ *
3183
+ * @param {Function} fn Function to run
3184
+ * @return {Object} the timer, clearOutputQueue clears it
3185
+ */
3186
+ function nextTurn(fn) {
3187
+ return IS_DENO ? setTimeout(fn, 0) : setImmediate(fn);
3188
+ }
3189
+ /**
3190
+ * Starts listening, resolves with the port or rejects with the listen error (EADDRINUSE)
3191
+ *
3192
+ * @param {Object} server net, tls or http server
3193
+ * @param {Number} [port] Port, a free one if not set
3194
+ * @param {String} [host] Address, all addresses if not set
3195
+ * @return {Promise<Number>} the port
3196
+ */
3197
+ function listenOn(server, port, host) {
3198
+ return new Promise((resolve, reject) => {
3199
+ server.once('error', reject);
3200
+ server.listen({ port: port || 0, host }, () => {
3201
+ server.removeListener('error', reject);
3202
+ resolve(server.address().port);
3203
+ });
3204
+ });
3205
+ }
2830
3206
  /**
2831
3207
  * Destroys a socket with a TCP RST (`close: 'reset'` of a script rule). resetAndDestroy (Node 16.17) needs a socket with
2832
3208
  * a TCP handle, a TLS socket throws ERR_INVALID_HANDLE_TYPE (#84), so the RST goes to the TCP socket under the TLS