imapkit 4.3.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +149 -8
  38. package/dist/cjs/server.js +429 -152
  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 +149 -8
  80. package/dist/esm/server.js +429 -152
  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
@@ -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
@@ -100,18 +105,6 @@ function getResponseTag(line) {
100
105
  function stateError(command, state) {
101
106
  return command + ' is not allowed in the ' + state + ' state';
102
107
  }
103
- /**
104
- * Creates an error for a failed mailbox operation, with a RFC 5530 response code
105
- *
106
- * @param {String} message Error message
107
- * @param {String} code Response code, e.g. "ALREADYEXISTS"
108
- * @return {Error} Error object
109
- */
110
- function mailboxError(message, code) {
111
- const err = new Error(message);
112
- err.code = code;
113
- return err;
114
- }
115
108
  /**
116
109
  * Creates a new IMAP server, call `listen()` on it to start accepting connections
117
110
  *
@@ -200,11 +193,33 @@ class IMAPServer extends node_stream_1.Stream {
200
193
  // the session whose command is running, see IMAPServer#notify
201
194
  this.activeConnection = null;
202
195
  this.sessionCounter = 0;
203
- // rules that make the server misbehave on purpose, from the script option or server.script.add()
196
+ // rules that make the server misbehave on purpose, from the script option or server.script.add(), and the rules
197
+ // of the quirk presets after them
204
198
  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
199
+ const quirks = (0, quirks_js_1.resolveQuirks)(this.options.quirks);
200
+ if (quirks.rules.length) {
201
+ this.script.add(quirks.rules);
202
+ }
203
+ this.loadUsers();
204
+ // plugins register their control operations
205
+ this.control = new control_js_1.Control(this);
206
+ // a quirk preset can leave plugins out (no-move, no-uidplus)
207
+ (0, load_plugins_js_1.default)(this, this.options.plugins, [...quirks.removePlugins]);
208
+ if (this.options.storage) {
209
+ // a typo in a fixture fails here with the path of the problem
210
+ (0, storage_schema_js_1.validateStorage)(this.options.storage);
211
+ }
212
+ this.systemFlags = [].concat(this.options.systemFlags || ['\\Answered', '\\Flagged', '\\Draft', '\\Deleted', '\\Seen']);
213
+ this.loadStorage();
214
+ this.smtpServer = null;
215
+ this.restServer = null;
216
+ }
217
+ /**
218
+ * Sets the users from the `users` option, or the default user. The users are deep copied, so that runtime changes
219
+ * never leak into the caller's objects or into other servers built from the same fixture. Without a prototype,
220
+ * user names like "__proto__" or "toString" are plain keys
221
+ */
222
+ loadUsers() {
208
223
  this.users = Object.assign(Object.create(null), this.options.users
209
224
  ? structuredClone(this.options.users)
210
225
  : {
@@ -212,12 +227,16 @@ class IMAPServer extends node_stream_1.Stream {
212
227
  password: 'testpass',
213
228
  xoauth2: {
214
229
  accessToken: 'testtoken',
215
- sessionTimeout: 3600 * 1000
230
+ sessionTimeout: store_operations_js_1.DEFAULT_SESSION_TIMEOUT
216
231
  }
217
232
  }
218
233
  });
219
- (0, load_plugins_js_1.default)(this, this.options.plugins);
220
- this.systemFlags = [].concat(this.options.systemFlags || ['\\Answered', '\\Flagged', '\\Draft', '\\Deleted', '\\Seen']);
234
+ }
235
+ /**
236
+ * Builds the mailboxes from the `storage` option, a deep copy of it. Message and mailbox handlers of plugins run
237
+ * on every message and mailbox
238
+ */
239
+ loadStorage() {
221
240
  // indexFolders() below turns the storage option into namespaces and mailboxes in place
222
241
  this.storage = (this.options.storage
223
242
  ? structuredClone(this.options.storage)
@@ -225,6 +244,7 @@ class IMAPServer extends node_stream_1.Stream {
225
244
  INBOX: {},
226
245
  '': {}
227
246
  });
247
+ this.referenceNamespace = false;
228
248
  this.uidvalidityCounter = 0; // highest UIDVALIDITY in use, new mailboxes get a higher one
229
249
  // subscribed mailbox names (RFC 3501 section 6.3.6). Names, not mailboxes: a subscription outlives
230
250
  // DELETE and stays with the old name on RENAME (RFC 9051 section 6.3.6), see trackSubscription
@@ -232,10 +252,67 @@ class IMAPServer extends node_stream_1.Stream {
232
252
  this.folderCache = Object.create(null);
233
253
  this.indexFolders(true);
234
254
  }
255
+ /**
256
+ * Starts accepting connections
257
+ *
258
+ * @param {Number} [port] Port to listen on, a free port if not set
259
+ * @param {String} [host] Address to listen on, all addresses if not set
260
+ * @return {Promise<Number>} resolves with the port the server listens on
261
+ */
262
+ async start(port, host) {
263
+ const imapPort = await listenOn(this.server, port, host);
264
+ await Promise.all([this.options.smtp ? this.startSmtp() : null, this.options.rest ? this.startRest() : null]);
265
+ return imapPort;
266
+ }
267
+ /**
268
+ * Starts the REST API of the `rest` option, see src/rest.ts
269
+ *
270
+ * @return {Promise<Number>} resolves with the HTTP port
271
+ */
272
+ async startRest() {
273
+ const { port, host, token } = this.options.rest || {};
274
+ this.restServer = (0, rest_js_1.createRestServer)(this, { host, token });
275
+ return listenOn(this.restServer, port, host || '127.0.0.1');
276
+ }
277
+ /**
278
+ * Starts the SMTP server of the `smtp` option. smtp-server is an optional dependency, loaded only here
279
+ *
280
+ * @return {Promise<Number>} resolves with the SMTP port
281
+ */
282
+ startSmtp() {
283
+ const { port, host } = this.options.smtp || {};
284
+ return this.loadSmtpListener().then(({ startSMTPServer }) => new Promise((resolve, reject) => {
285
+ const smtp = startSMTPServer(port || 0, this, smtpPort => {
286
+ smtp.removeListener('error', reject);
287
+ resolve(smtpPort);
288
+ }, host);
289
+ smtp.once('error', reject);
290
+ this.smtpServer = smtp;
291
+ }));
292
+ }
293
+ /**
294
+ * Loads the SMTP listener, which needs the smtp-server package
295
+ *
296
+ * @return {Promise<Object>} the smtp-listener module
297
+ */
298
+ loadSmtpListener() {
299
+ return Promise.resolve().then(() => __importStar(require('./smtp-listener.js'))).catch((err) => {
300
+ throw new Error('The smtp option needs the smtp-server package, install it with: npm install smtp-server (' + err.message + ')');
301
+ });
302
+ }
303
+ /**
304
+ * Stops the server and closes every connection
305
+ *
306
+ * @return {Promise} resolves when the server is closed
307
+ */
308
+ stop() {
309
+ return this.control.shutdown({ graceful: false });
310
+ }
235
311
  listen(...args) {
236
312
  this.server.listen.apply(this.server, args);
237
313
  }
238
314
  close(callback) {
315
+ this.closeListeners(true);
239
316
  this.server.close(callback);
240
317
  // close() only completes once all connections are gone
241
318
  this.connections.forEach((connection) => {
@@ -244,6 +321,29 @@ class IMAPServer extends node_stream_1.Stream {
244
321
  }
245
322
  });
246
323
  }
324
+ /**
325
+ * Stops the SMTP and REST listeners of `start()`
326
+ *
327
+ * @param {Boolean} force true closes the open REST connections too, otherwise only the idle ones (a request that
328
+ * is answered, like POST /v1/shutdown, finishes first)
329
+ */
330
+ closeListeners(force) {
331
+ if (this.smtpServer) {
332
+ this.smtpServer.close(() => false);
333
+ this.smtpServer = null;
334
+ }
335
+ if (this.restServer) {
336
+ this.restServer.close();
337
+ // keep-alive connections would hold the server open
338
+ if (force) {
339
+ this.restServer.closeAllConnections?.();
340
+ }
341
+ else {
342
+ this.restServer.closeIdleConnections?.();
343
+ }
344
+ this.restServer = null;
345
+ }
346
+ }
247
347
  /**
248
348
  * Returns TLS key and certificate. Without credentials in the options the
249
349
  * bundled self-signed certificate for localhost is used.
@@ -363,13 +463,31 @@ class IMAPServer extends node_stream_1.Stream {
363
463
  *
364
464
  * @param {String} type Kind of change
365
465
  * @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
466
+ * @param {Object} [details] `{ oldPath, mailbox, created }`: the earlier name of a renamed mailbox, the mailbox
467
+ * object that a DELETE removed, the mailboxes a CREATE made (superior levels first)
368
468
  */
369
469
  mailboxChanged(type, path, details) {
370
470
  const event = Object.assign({ type, path, oldPath: null, mailbox: null }, details, { origin: this.activeConnection });
371
471
  this.emit('mailbox', event);
372
472
  }
473
+ /**
474
+ * Runs a change with `origin` as the session that caused it, the `origin` of the notifications and events it
475
+ * makes. Commands run with their session, the control API with null
476
+ *
477
+ * @param {Object|null} origin Session, or null for a change from outside
478
+ * @param {Function} fn Change
479
+ * @return {*} the result of fn
480
+ */
481
+ withOrigin(origin, fn) {
482
+ const previous = this.activeConnection;
483
+ this.activeConnection = origin;
484
+ try {
485
+ return fn();
486
+ }
487
+ finally {
488
+ this.activeConnection = previous;
489
+ }
490
+ }
373
491
  /**
374
492
  * Retrieves the handler of an IMAP command
375
493
  *
@@ -502,22 +620,22 @@ class IMAPServer extends node_stream_1.Stream {
502
620
  */
503
621
  createMailbox(path, defaultMailbox) {
504
622
  if (!path) {
505
- throw mailboxError('Invalid mailbox name', 'CANNOT');
623
+ throw (0, store_operations_js_1.storeError)('Invalid mailbox name', 'CANNOT');
506
624
  }
507
625
  // Ensure case insensitive INBOX
508
626
  if (path.toUpperCase() === 'INBOX') {
509
- throw mailboxError('INBOX can not be modified', 'ALREADYEXISTS');
627
+ throw (0, store_operations_js_1.storeError)('INBOX can not be modified', 'ALREADYEXISTS');
510
628
  }
511
629
  const { namespace, storage } = this.getPersonalNamespace(path);
512
630
  path = this.stripSeparator(path, storage.separator);
513
631
  if (this.folderCache[path] && this.folderCache[path].flags.indexOf('\\Noselect') < 0) {
514
- throw mailboxError('Mailbox already exists', 'ALREADYEXISTS');
632
+ throw (0, store_operations_js_1.storeError)('Mailbox already exists', 'ALREADYEXISTS');
515
633
  }
516
634
  const folderPath = path.substr(namespace.length).split(storage.separator);
517
635
  if (folderPath.some(name => !name)) {
518
636
  // an empty hierarchy level ("foo//bar", "/foo", "foo//"). RFC 5530 section 3 has this very case as the
519
637
  // example of CANNOT, Dovecot refuses it too
520
- throw mailboxError('Mailbox names can not have empty hierarchy levels', 'CANNOT');
638
+ throw (0, store_operations_js_1.storeError)('Mailbox names can not have empty hierarchy levels', 'CANNOT');
521
639
  }
522
640
  let parent = storage;
523
641
  let curPath = namespace;
@@ -528,7 +646,7 @@ class IMAPServer extends node_stream_1.Stream {
528
646
  curPath += (curPath.length ? storage.separator : '') + folderName;
529
647
  let folder = this.getMailbox(curPath) || false;
530
648
  if (folder && folder.flags && folder.flags.indexOf('\\Noinferiors') >= 0) {
531
- throw mailboxError('Can not create subfolders for ' + folder.path, 'CANNOT');
649
+ throw (0, store_operations_js_1.storeError)('Can not create subfolders for ' + folder.path, 'CANNOT');
532
650
  }
533
651
  // a \Noselect placeholder that is created again is replaced with a new mailbox that only keeps
534
652
  // the children, nothing else of a deleted mailbox may come back (RFC 3501 section 6.3.3)
@@ -570,17 +688,17 @@ class IMAPServer extends node_stream_1.Stream {
570
688
  deleteMailbox(path, keepContents) {
571
689
  // Ensure case insensitive INBOX
572
690
  if (path.toUpperCase() === 'INBOX') {
573
- throw mailboxError('INBOX can not be modified', 'CANNOT');
691
+ throw (0, store_operations_js_1.storeError)('INBOX can not be modified', 'CANNOT');
574
692
  }
575
693
  const { namespace, storage } = this.getPersonalNamespace(path);
576
694
  const mailbox = this.folderCache[this.stripSeparator(path, storage.separator)];
577
695
  if (!mailbox) {
578
- throw mailboxError('Mailbox does not exist', 'NONEXISTENT');
696
+ throw (0, store_operations_js_1.storeError)('Mailbox does not exist', 'NONEXISTENT');
579
697
  }
580
698
  if (mailbox.flags.indexOf('\\Noselect') >= 0 && Object.keys(mailbox.folders || {}).length) {
581
699
  // RFC 9051 section 6.3.5: deleting a \Noselect name that has inferior names is an error, the RFC 5530
582
700
  // section 3 HASCHILDREN response code tells the client to delete the children first
583
- throw mailboxError('Mailbox has children, delete them first', 'HASCHILDREN');
701
+ throw (0, store_operations_js_1.storeError)('Mailbox has children, delete them first', 'HASCHILDREN');
584
702
  }
585
703
  const levels = mailbox.path.split(storage.separator);
586
704
  const folderName = levels.pop();
@@ -630,7 +748,7 @@ class IMAPServer extends node_stream_1.Stream {
630
748
  const prefix = key.length ? key.substr(0, key.length - this.storage[key].separator.length) : key;
631
749
  if (key.length && (path === prefix || path.substr(0, key.length) === key)) {
632
750
  if (path === prefix) {
633
- throw mailboxError('Used mailbox name is a namespace value', 'CANNOT');
751
+ throw (0, store_operations_js_1.storeError)('Used mailbox name is a namespace value', 'CANNOT');
634
752
  }
635
753
  namespace = key;
636
754
  }
@@ -640,10 +758,10 @@ class IMAPServer extends node_stream_1.Stream {
640
758
  });
641
759
  const storage = this.storage[namespace];
642
760
  if (!storage) {
643
- throw mailboxError('Unknown namespace', 'CANNOT');
761
+ throw (0, store_operations_js_1.storeError)('Unknown namespace', 'CANNOT');
644
762
  }
645
763
  if (storage.type !== 'personal') {
646
- throw mailboxError('Permission denied', 'NOPERM');
764
+ throw (0, store_operations_js_1.storeError)('Permission denied', 'NOPERM');
647
765
  }
648
766
  return { namespace, storage };
649
767
  }
@@ -884,6 +1002,17 @@ class IMAPServer extends node_stream_1.Stream {
884
1002
  const knownFlags = (mailbox.knownFlags = mailbox.knownFlags || []);
885
1003
  flags.forEach(flag => this.ensureFlag(knownFlags, flag));
886
1004
  }
1005
+ /**
1006
+ * The current time for dates the server sets itself (INTERNALDATE of a message without one, SAVEDATE). The `now`
1007
+ * option fixes it for repeatable tests: a Date, a timestamp, or a function that returns one
1008
+ *
1009
+ * @return {Date} current time
1010
+ */
1011
+ now() {
1012
+ const now = this.options.now;
1013
+ const value = typeof now === 'function' ? now() : now;
1014
+ return value === undefined || value === null ? new Date() : new Date(value);
1015
+ }
887
1016
  normalizeDateTime(value) {
888
1017
  if (value instanceof Date) {
889
1018
  return this.formatInternalDate(value);
@@ -902,7 +1031,7 @@ class IMAPServer extends node_stream_1.Stream {
902
1031
  * @param mailbox Mailbox of the message
903
1032
  */
904
1033
  processMessage(message, mailbox) {
905
- message.internaldate = this.normalizeDateTime(message.internaldate || new Date());
1034
+ message.internaldate = this.normalizeDateTime(message.internaldate || this.now());
906
1035
  message.flags = [].concat(message.flags || []);
907
1036
  if (message.flags.indexOf('\\Recent') >= 0) {
908
1037
  // \Recent is not a stored flag, it belongs to the first session that selects the mailbox
@@ -1293,6 +1422,7 @@ class IMAPConnection {
1293
1422
  this.notificationQueue = [];
1294
1423
  this.server.on('notify', this._notificationCallback);
1295
1424
  this.server.connections.add(this);
1425
+ this.emitSession('open');
1296
1426
  this.scriptOutput('greeting', '* OK ImapKit ready for rumble\r\n', {});
1297
1427
  }
1298
1428
  /**
@@ -1327,7 +1457,7 @@ class IMAPConnection {
1327
1457
  }
1328
1458
  this._outputQueue = this._outputQueue || [];
1329
1459
  this._outputQueue.push(Object.assign({}, operation, { transport: this.transport }));
1330
- if (!this._outputTimer) {
1460
+ if (!this._outputTimer && !this._outputWaiting) {
1331
1461
  this.flushOutput();
1332
1462
  }
1333
1463
  }
@@ -1348,10 +1478,28 @@ class IMAPConnection {
1348
1478
  return;
1349
1479
  }
1350
1480
  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);
1481
+ // the rest waits for chunkDelay, like a delay of its own. Without a delay each piece waits for the next
1482
+ // event loop turn, and Nagle's algorithm must not hold it back to merge it with the next one
1483
+ const piece = operation.data.subarray(0, operation.chunk);
1353
1484
  operation.data = operation.data.subarray(operation.chunk);
1485
+ if (!operation.chunkDelay || operation.chunkDelay === 'tick') {
1486
+ // the next piece waits until this one was handed to the system, and one more event loop turn, so
1487
+ // the pieces leave as separate segments without a wall clock delay (#89)
1488
+ this.socket?.setNoDelay?.(true);
1489
+ this._outputWaiting = true;
1490
+ this.writeLayer(piece, operation.transport || null, () => {
1491
+ if (this._outputWaiting && this._outputQueue === queue) {
1492
+ this._outputTimer = nextTurn(() => {
1493
+ this._outputTimer = null;
1494
+ this._outputWaiting = false;
1495
+ this.flushOutput();
1496
+ });
1497
+ }
1498
+ });
1499
+ return;
1500
+ }
1354
1501
  operation.delay = operation.chunkDelay;
1502
+ this.writeLayer(piece, operation.transport || null);
1355
1503
  continue;
1356
1504
  }
1357
1505
  if (operation.data) {
@@ -1371,8 +1519,10 @@ class IMAPConnection {
1371
1519
  clearOutputQueue() {
1372
1520
  if (this._outputTimer) {
1373
1521
  clearTimeout(this._outputTimer);
1522
+ clearImmediate(this._outputTimer);
1374
1523
  this._outputTimer = null;
1375
1524
  }
1525
+ this._outputWaiting = false;
1376
1526
  this._outputQueue = null;
1377
1527
  this._deferredOutput = null;
1378
1528
  }
@@ -1381,13 +1531,17 @@ class IMAPConnection {
1381
1531
  *
1382
1532
  * @param {Buffer} data Output
1383
1533
  * @param {Object|null} transport Transport layer
1534
+ * @param {Function} [callback] Called once the data was handed to the system (a transport: on the next turn)
1384
1535
  */
1385
- writeLayer(data, transport) {
1536
+ writeLayer(data, transport, callback) {
1386
1537
  if (transport) {
1387
1538
  transport.write(data);
1539
+ if (callback) {
1540
+ setImmediate(callback);
1541
+ }
1388
1542
  }
1389
1543
  else {
1390
- this.writeRaw(data);
1544
+ this.writeRaw(data, callback);
1391
1545
  }
1392
1546
  }
1393
1547
  /**
@@ -1441,17 +1595,70 @@ class IMAPConnection {
1441
1595
  *
1442
1596
  * @param {Buffer} data Data to send
1443
1597
  */
1444
- writeRaw(data) {
1598
+ writeRaw(data, callback) {
1445
1599
  if (this.socket && !this.socket.destroyed) {
1446
- this.socket.write(data);
1600
+ this.socket.write(data, callback);
1601
+ this.touch();
1447
1602
  }
1448
1603
  }
1604
+ /**
1605
+ * Notes input or output for the quiet events of script rules (#88)
1606
+ */
1607
+ touch() {
1608
+ if (!this.server.script.watches('quiet')) {
1609
+ // nothing waits for quiet sessions, a rule added later counts from then on
1610
+ this._lastActivity = undefined;
1611
+ return;
1612
+ }
1613
+ this._lastActivity = Date.now();
1614
+ if (!this._quietTimer) {
1615
+ this.watchQuiet();
1616
+ }
1617
+ }
1618
+ /**
1619
+ * Starts waiting for the next quiet event, if a rule waits for one and the session is not waiting already. When
1620
+ * the time is up, the first rule that matches handles the event (`send`, `close`), the output it sends starts
1621
+ * the next quiet time
1622
+ *
1623
+ * @param {Number} [checked] Quiet time the rules were checked for already, only longer ones are waited for
1624
+ */
1625
+ watchQuiet(checked = 0) {
1626
+ if (this._quietTimer || !this.socket || this._closing) {
1627
+ return;
1628
+ }
1629
+ const last = this._lastActivity || Date.now();
1630
+ this._lastActivity = last;
1631
+ // the time is measured from the last activity, a rule whose time is up already fires right away
1632
+ const next = this.server.script.nextQuiet(checked);
1633
+ if (next === null) {
1634
+ return;
1635
+ }
1636
+ this._quietTimer = setTimeout(() => {
1637
+ this._quietTimer = null;
1638
+ if (!this.isOpen() || this._lastActivity !== last) {
1639
+ // there was activity in between, it started a new wait
1640
+ return this.watchQuiet();
1641
+ }
1642
+ // a timer can fire a millisecond before Date.now() says the time is up
1643
+ const quiet = Math.max(Date.now() - last, next);
1644
+ const found = this.server.script.check(this, 'quiet', { data: '', quiet });
1645
+ if (found) {
1646
+ (0, script_js_1.handleQuiet)(this, found.rule, found.context);
1647
+ }
1648
+ if (this._lastActivity === last) {
1649
+ // nothing was sent, wait for the rules with a longer quiet time
1650
+ this.watchQuiet(quiet);
1651
+ }
1652
+ }, Math.max(0, next - (Date.now() - last)));
1653
+ this._quietTimer.unref();
1654
+ }
1449
1655
  /**
1450
1656
  * Handles data from the socket, through the transport layer if there is one
1451
1657
  *
1452
1658
  * @param {Buffer} chunk Received data
1453
1659
  */
1454
1660
  receive(chunk) {
1661
+ this.touch();
1455
1662
  if (this.transport) {
1456
1663
  this.transport.receive(chunk);
1457
1664
  }
@@ -1491,16 +1698,10 @@ class IMAPConnection {
1491
1698
  socket.pause();
1492
1699
  // a RST sent in the same tick as the last output can get lost: on macOS the client received the output but
1493
1700
  // never the RST (#81). Node has no signal for output that reached the peer, so the RST waits a little
1701
+ const tcpSocket = this.tcpSocket;
1494
1702
  setTimeout(() => {
1495
- if (socket.destroyed) {
1496
- return;
1497
- }
1498
- // resetAndDestroy sends a TCP RST (Node 16.17), not every runtime has it
1499
- if (typeof socket.resetAndDestroy === 'function') {
1500
- socket.resetAndDestroy();
1501
- }
1502
- else {
1503
- socket.destroy();
1703
+ if (!socket.destroyed) {
1704
+ resetSocket(socket, tcpSocket);
1504
1705
  }
1505
1706
  }, RESET_DELAY);
1506
1707
  }
@@ -1590,11 +1791,15 @@ class IMAPConnection {
1590
1791
  * notifications that were not sent yet are dropped. Sends nothing, the caller answers the command
1591
1792
  */
1592
1793
  closeMailbox() {
1794
+ const wasSelected = !!this.selectedMailbox;
1593
1795
  this.state = 'Authenticated';
1594
1796
  this.selectedMailbox = false;
1595
1797
  this.readOnly = false;
1596
1798
  this.recent = null;
1597
1799
  this.notificationQueue = [];
1800
+ if (wasSelected) {
1801
+ this.emitSession('unselect');
1802
+ }
1598
1803
  }
1599
1804
  onClose() {
1600
1805
  if (this.socket) {
@@ -1606,8 +1811,14 @@ class IMAPConnection {
1606
1811
  this.transport = null;
1607
1812
  }
1608
1813
  this.clearOutputQueue();
1814
+ if (this._quietTimer) {
1815
+ clearTimeout(this._quietTimer);
1816
+ this._quietTimer = null;
1817
+ }
1609
1818
  this.server.removeListener('notify', this._notificationCallback);
1610
- this.server.connections.delete(this);
1819
+ if (this.server.connections.delete(this)) {
1820
+ this.emitSession('close');
1821
+ }
1611
1822
  }
1612
1823
  onError(err) {
1613
1824
  if (this.options.debug) {
@@ -1959,8 +2170,8 @@ class IMAPConnection {
1959
2170
  * @param {Array} messages Messages with changed flags
1960
2171
  */
1961
2172
  notifyFlagChanges(messages) {
1962
- if (messages.length && this.selectedMailbox) {
1963
- this.server.notify({ tag: '*', flagUpdate: messages }, this.selectedMailbox, this);
2173
+ if (this.selectedMailbox) {
2174
+ (0, store_operations_js_1.notifyFlagChanges)(this.server, this.selectedMailbox, messages, this);
1964
2175
  }
1965
2176
  }
1966
2177
  /**
@@ -2121,6 +2332,9 @@ class IMAPConnection {
2121
2332
  };
2122
2333
  // STARTTLS runs on a live connection
2123
2334
  const socket = this.socket;
2335
+ // the RST of a script rule goes to the TCP socket under the TLS layer, see resetSocket. Node also has it as the
2336
+ // _parent of the TLS socket, Bun does not
2337
+ this.tcpSocket = socket;
2124
2338
  // remove all listeners from the original socket besides the error handler
2125
2339
  socket.removeAllListeners();
2126
2340
  socket.on('error', this.onError.bind(this));
@@ -2213,7 +2427,7 @@ class IMAPConnection {
2213
2427
  */
2214
2428
  send(response, description, parsed, data, ...extra) {
2215
2429
  // nothing goes out once the connection is closing (RFC 3501 section 7.1.5)
2216
- if (!this.socket || this.socket.destroyed || this._closing) {
2430
+ if (!this.isOpen()) {
2217
2431
  return;
2218
2432
  }
2219
2433
  if (!response.notification && response.tag !== '*') {
@@ -2223,6 +2437,9 @@ class IMAPConnection {
2223
2437
  this.server.outputHandlers.forEach(handler => {
2224
2438
  handler(this, response, description, parsed, data, ...extra);
2225
2439
  });
2440
+ if (this._commandStart && parsed && response.tag === this._commandStart.tag && parsed.tag === response.tag) {
2441
+ this.commandCompleted(String(response.command || '').toUpperCase());
2442
+ }
2226
2443
  // No need to display this response to user
2227
2444
  if (response.skipResponse) {
2228
2445
  return;
@@ -2598,6 +2815,64 @@ class IMAPConnection {
2598
2815
  }, 'UNKNOWN COMMAND', parsed, data);
2599
2816
  }
2600
2817
  }
2818
+ /**
2819
+ * Emits the `command` event when the tagged response of a command goes out, and the `session` events "login"
2820
+ * (the command authenticated the session) and "logout" (back to Not Authenticated, UNAUTHENTICATE). Every command
2821
+ * that changes these does so before its tagged response. "select" and "unselect" come from SELECT and
2822
+ * closeMailbox(). Tests wait for these instead of polling
2823
+ *
2824
+ * @param {String} status OK, NO or BAD
2825
+ */
2826
+ commandCompleted(status) {
2827
+ const start = this._commandStart;
2828
+ this._commandStart = null;
2829
+ const server = this.server;
2830
+ if (!server.listenerCount('command') && !server.listenerCount('session')) {
2831
+ return;
2832
+ }
2833
+ server.emit('command', { session: this.sessionNumber, tag: start.tag, command: start.command, status, user: this.username || null });
2834
+ const authenticated = (state) => state === 'Authenticated' || state === 'Selected';
2835
+ if (authenticated(start.state) !== authenticated(this.state) && this.state !== 'Logout') {
2836
+ this.emitSession(authenticated(this.state) ? 'login' : 'logout');
2837
+ }
2838
+ }
2839
+ /**
2840
+ * Emits a `session` event, `{ type, session, ...fields }`
2841
+ *
2842
+ * @param {String} type Event type
2843
+ * @param {Object} [fields] More fields of the event
2844
+ */
2845
+ emitSession(type, fields) {
2846
+ if (this.server.listenerCount('session')) {
2847
+ this.server.emit('session', Object.assign({ type, session: this.describe() }, fields));
2848
+ }
2849
+ }
2850
+ /**
2851
+ * Describes the session without credentials or sockets, for the control API and the `session` events
2852
+ *
2853
+ * @return {Object} `{ session, user, state, mailbox, readOnly, enabled, secure, compressed, remoteAddress }`
2854
+ */
2855
+ describe() {
2856
+ return {
2857
+ session: this.sessionNumber,
2858
+ user: this.username || null,
2859
+ state: this.state,
2860
+ mailbox: this.selectedMailbox ? this.selectedMailbox.path : null,
2861
+ readOnly: !!this.selectedMailbox && !!this.readOnly,
2862
+ enabled: Array.isArray(this.enabled) ? this.enabled.slice() : [],
2863
+ secure: !!this.secureConnection,
2864
+ compressed: !!this.transport,
2865
+ remoteAddress: (this.socket && this.socket.remoteAddress) || null
2866
+ };
2867
+ }
2868
+ /**
2869
+ * Checks if output can still be sent: the socket is open and the connection is not closing (BYE, LOGOUT)
2870
+ *
2871
+ * @return {Boolean} true for a live session
2872
+ */
2873
+ isOpen() {
2874
+ return !!this.socket && !this.socket.destroyed && !this._closing;
2875
+ }
2601
2876
  /**
2602
2877
  * Handles a command line with the script rule that matched it, after the rule's delay. With `run` the line
2603
2878
  * goes through the parser and the command handler as usual afterwards
@@ -2638,6 +2913,8 @@ class IMAPConnection {
2638
2913
  const command = element.parsed.command.toUpperCase();
2639
2914
  this._runningCommand = element;
2640
2915
  const options = this.server.getCommandOptions(command);
2916
+ // the session events of the command are worked out when its tagged response goes out, see commandCompleted
2917
+ this._commandStart = { tag: element.parsed.tag, command, state: this.state };
2641
2918
  let done = false;
2642
2919
  const next = () => {
2643
2920
  if (done) {
@@ -2700,53 +2977,52 @@ class IMAPConnection {
2700
2977
  // criteria, processNotifications knows when EXPUNGE must wait
2701
2978
  this.processNotifications(element.parsed);
2702
2979
  }
2703
- try {
2704
- // changes made while the handler runs are attributed to this session (the `origin` of notifications)
2705
- this.server.activeConnection = this;
2706
- const inputHandler = this.inputHandler;
2707
- this.server.getCommandHandler(element.parsed.command)(this, element.parsed, element.data, next);
2708
- if (this.inputHandler && this.inputHandler !== inputHandler) {
2709
- // the command reads the lines that follow (IDLE, AUTHENTICATE), script rules match them with it
2710
- this.inputCommand = { tag: element.parsed.tag, command: element.parsed.command };
2980
+ // changes made while the handler runs are attributed to this session (the `origin` of notifications)
2981
+ this.server.withOrigin(this, () => {
2982
+ try {
2983
+ const inputHandler = this.inputHandler;
2984
+ this.server.getCommandHandler(element.parsed.command)(this, element.parsed, element.data, next);
2985
+ if (this.inputHandler && this.inputHandler !== inputHandler) {
2986
+ // the command reads the lines that follow (IDLE, AUTHENTICATE), script rules match them with it
2987
+ this.inputCommand = { tag: element.parsed.tag, command: element.parsed.command };
2988
+ this.emitSession('waiting', { command });
2989
+ }
2711
2990
  }
2712
- }
2713
- catch (E) {
2714
- const ex = E;
2715
- const badInput = ex.imapResponse === 'BAD';
2716
- if (!badInput && this.options.debug) {
2717
- console.error('Error processing command:', ex, '\n', ex.stack);
2991
+ catch (E) {
2992
+ const ex = E;
2993
+ const badInput = ex.imapResponse === 'BAD';
2994
+ if (!badInput && this.options.debug) {
2995
+ console.error('Error processing command:', ex, '\n', ex.stack);
2996
+ }
2997
+ this.send({
2998
+ tag: element.parsed.tag,
2999
+ command: badInput ? 'BAD' : 'NO',
3000
+ attributes: [].concat(badInput
3001
+ ? []
3002
+ : {
3003
+ type: 'SECTION',
3004
+ section: [
3005
+ {
3006
+ type: 'ATOM',
3007
+ value: 'SERVERBUG'
3008
+ }
3009
+ ]
3010
+ }, {
3011
+ type: 'TEXT',
3012
+ value: badInput ? ex.message : 'Server error: ' + ex.message
3013
+ })
3014
+ }, badInput ? 'INVALID COMMAND' : 'SERVER ERROR', element.parsed, element.data);
3015
+ // keep the connection usable, otherwise every later command would hang
3016
+ next();
2718
3017
  }
2719
- this.send({
2720
- tag: element.parsed.tag,
2721
- command: badInput ? 'BAD' : 'NO',
2722
- attributes: [].concat(badInput
2723
- ? []
2724
- : {
2725
- type: 'SECTION',
2726
- section: [
2727
- {
2728
- type: 'ATOM',
2729
- value: 'SERVERBUG'
2730
- }
2731
- ]
2732
- }, {
2733
- type: 'TEXT',
2734
- value: badInput ? ex.message : 'Server error: ' + ex.message
2735
- })
2736
- }, badInput ? 'INVALID COMMAND' : 'SERVER ERROR', element.parsed, element.data);
2737
- // keep the connection usable, otherwise every later command would hang
2738
- next();
2739
- }
2740
- finally {
2741
- this.server.activeConnection = null;
2742
- }
3018
+ });
2743
3019
  }
2744
3020
  /**
2745
3021
  * Removes messages with \Deleted flag
2746
3022
  *
2747
3023
  * @param {Object} mailbox Mailbox to check for
2748
3024
  * @param {Boolean} [ignoreSelf] If set to true, does not send any notices to itself
2749
- * @param {Boolean} [ignoreSelf] If set to true, does not send EXISTS notice to itself
3025
+ * @param {Boolean} [ignoreExists] If set to true, does not send EXISTS notice to itself
2750
3026
  */
2751
3027
  expungeDeleted(mailbox, ignoreSelf, ignoreExists) {
2752
3028
  this.expungeSpecificMessages(mailbox, (message) => {
@@ -2755,81 +3031,82 @@ class IMAPConnection {
2755
3031
  }
2756
3032
  /**
2757
3033
  * Given a set of messages in a mailbox (possibly via getMessageRange), remove
2758
- * them from the mailbox and generate EXPUNGE notifications.
3034
+ * them from the mailbox and generate EXPUNGE notifications, see expungeMessages() in store-operations.ts
2759
3035
  *
2760
3036
  * @param {Object} mailbox Mailbox to check for
2761
3037
  * @param {Function|Array} messagesOrFilterFunc An Array of messages in the
2762
3038
  * folder that should be removed or a filtering function that indicates
2763
3039
  * messages to be removed by returning true.
2764
3040
  * @param {Boolean} [ignoreSelf] If set to true, does not send any notices to itself
2765
- * @param {Boolean} [ignoreSelf] If set to true, does not send EXISTS notice to itself
3041
+ * @param {Boolean} [ignoreExists] If set to true, does not send EXISTS notice to itself
2766
3042
  * @param {Boolean} [highestFirst] If set to true, the EXPUNGE responses go from the highest UID to the lowest
2767
3043
  * (MESSAGELIMIT, RFC 9738 section 3.1), otherwise from the lowest
2768
3044
  */
2769
3045
  expungeSpecificMessages(mailbox, messagesOrFilterFunc, ignoreSelf, ignoreExists, highestFirst) {
2770
- let filterFunc;
2771
- if (Array.isArray(messagesOrFilterFunc)) {
2772
- const messageSet = new Set(messagesOrFilterFunc);
2773
- filterFunc = (message) => messageSet.has(message);
2774
- }
2775
- else {
2776
- filterFunc = messagesOrFilterFunc;
2777
- }
2778
- // sequence numbers of the removed messages, each one as it is after the earlier EXPUNGE responses. From the
2779
- // highest message down, the earlier responses do not change the sequence numbers of the later ones
2780
- const expunged = [];
2781
- const kept = [];
2782
- mailbox.messages.forEach((message, i) => {
2783
- if (filterFunc(message)) {
2784
- message.ghost = true;
2785
- expunged.push({ seq: highestFirst ? i + 1 : kept.length + 1, message });
3046
+ (0, store_operations_js_1.expungeMessages)(this.server, mailbox, messagesOrFilterFunc, {
3047
+ origin: this,
3048
+ skip: ignoreSelf ? this : null,
3049
+ skipExists: ignoreExists ? this : null,
3050
+ highestFirst: !!highestFirst
3051
+ });
3052
+ }
3053
+ }
3054
+ exports.IMAPConnection = IMAPConnection;
3055
+ // setImmediate does not let Deno send what sockets were given, see nextTurn
3056
+ const IS_DENO = 'Deno' in globalThis;
3057
+ /**
3058
+ * Runs a function on the next event loop turn that also handles network I/O: setImmediate on Node and Bun, a zero
3059
+ * timer on Deno, where setImmediate runs before the sockets send what was written (#89)
3060
+ *
3061
+ * @param {Function} fn Function to run
3062
+ * @return {Object} the timer, clearOutputQueue clears it
3063
+ */
3064
+ function nextTurn(fn) {
3065
+ return IS_DENO ? setTimeout(fn, 0) : setImmediate(fn);
3066
+ }
3067
+ /**
3068
+ * Starts listening, resolves with the port or rejects with the listen error (EADDRINUSE)
3069
+ *
3070
+ * @param {Object} server net, tls or http server
3071
+ * @param {Number} [port] Port, a free one if not set
3072
+ * @param {String} [host] Address, all addresses if not set
3073
+ * @return {Promise<Number>} the port
3074
+ */
3075
+ function listenOn(server, port, host) {
3076
+ return new Promise((resolve, reject) => {
3077
+ server.once('error', reject);
3078
+ server.listen({ port: port || 0, host }, () => {
3079
+ server.removeListener('error', reject);
3080
+ resolve(server.address().port);
3081
+ });
3082
+ });
3083
+ }
3084
+ /**
3085
+ * Destroys a socket with a TCP RST (`close: 'reset'` of a script rule). resetAndDestroy (Node 16.17) needs a socket with
3086
+ * a TCP handle, a TLS socket throws ERR_INVALID_HANDLE_TYPE (#84), so the RST goes to the TCP socket under the TLS
3087
+ * layer: the plain socket of STARTTLS, or the parent socket of implicit TLS (Node). A runtime without resetAndDestroy,
3088
+ * or a socket that can not be reset, is destroyed without a RST
3089
+ *
3090
+ * @param {Object} socket The socket of the connection
3091
+ * @param {Object} [tcpSocket] The plain socket that STARTTLS wrapped
3092
+ */
3093
+ function resetSocket(socket, tcpSocket) {
3094
+ const parent = socket._parent;
3095
+ const candidates = [tcpSocket, parent instanceof node_net_1.default.Socket ? parent : null, socket];
3096
+ for (const candidate of candidates) {
3097
+ if (candidate && !candidate.destroyed && typeof candidate.resetAndDestroy === 'function') {
3098
+ try {
3099
+ candidate.resetAndDestroy();
3100
+ break;
2786
3101
  }
2787
- else {
2788
- kept.push(message);
3102
+ catch {
3103
+ // not a TCP handle, try the next one
2789
3104
  }
2790
- });
2791
- if (!expunged.length) {
2792
- return;
2793
3105
  }
2794
- // old copy is required for those sessions that run FETCH before
2795
- // displaying the EXPUNGE notice
2796
- const mailboxCopy = mailbox.messages.slice();
2797
- // update the list in place, other code might hold a reference to it
2798
- kept.forEach((message, i) => {
2799
- mailbox.messages[i] = message;
2800
- });
2801
- mailbox.messages.length = kept.length;
2802
- // lets plugins track the removal (e.g. mod-sequences of CONDSTORE and QRESYNC) before any notification
2803
- this.server.emit('expunge', mailbox, expunged.map(entry => entry.message), this);
2804
- (highestFirst ? expunged.slice().reverse() : expunged).forEach(entry => {
2805
- this.server.notify({
2806
- tag: '*',
2807
- attributes: [
2808
- entry.seq,
2809
- {
2810
- type: 'ATOM',
2811
- value: 'EXPUNGE'
2812
- }
2813
- ],
2814
- // the removed message, for plugins that report it differently (e.g. VANISHED of QRESYNC)
2815
- message: entry.message
2816
- }, mailbox, ignoreSelf ? this : false);
2817
- });
2818
- this.server.notify({
2819
- tag: '*',
2820
- attributes: [
2821
- mailbox.messages.length,
2822
- {
2823
- type: 'ATOM',
2824
- value: 'EXISTS'
2825
- }
2826
- ],
2827
- // distribute the old mailbox data with the notification
2828
- mailboxCopy: mailboxCopy
2829
- }, mailbox, ignoreSelf || ignoreExists ? this : false);
2830
3106
  }
3107
+ // the TLS socket closes with the TCP socket under it, and emits close for the connection
3108
+ socket.destroy();
2831
3109
  }
2832
- exports.IMAPConnection = IMAPConnection;
2833
3110
  /**
2834
3111
  * Copies a response tree for the `mutate` action of a script rule: arrays and plain objects are copied,
2835
3112
  * other values (Buffers) are shared