imapkit 4.3.1 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +167 -51
  2. package/bin/help.txt +17 -3
  3. package/bin/imapkit.js +53 -23
  4. package/dist/cjs/commands/append.js +1 -1
  5. package/dist/cjs/commands/create.js +2 -2
  6. package/dist/cjs/commands/delete.js +2 -14
  7. package/dist/cjs/commands/handlers/flags.d.ts +3 -3
  8. package/dist/cjs/commands/handlers/flags.js +3 -3
  9. package/dist/cjs/commands/handlers/store.js +3 -3
  10. package/dist/cjs/commands/rename.js +6 -46
  11. package/dist/cjs/commands/select.js +1 -0
  12. package/dist/cjs/commands/subscribe.js +7 -8
  13. package/dist/cjs/commands/unsubscribe.js +3 -9
  14. package/dist/cjs/control.d.ts +430 -0
  15. package/dist/cjs/control.js +637 -0
  16. package/dist/cjs/index.d.ts +8 -1
  17. package/dist/cjs/index.js +9 -2
  18. package/dist/cjs/load-plugins.d.ts +1 -1
  19. package/dist/cjs/load-plugins.js +7 -2
  20. package/dist/cjs/plugins/acl.js +109 -42
  21. package/dist/cjs/plugins/condstore.js +7 -0
  22. package/dist/cjs/plugins/index.js +1 -3
  23. package/dist/cjs/plugins/metadata.js +140 -57
  24. package/dist/cjs/plugins/objectid.js +10 -0
  25. package/dist/cjs/plugins/quota.js +50 -2
  26. package/dist/cjs/plugins/savedate.js +1 -1
  27. package/dist/cjs/plugins/special-use.d.ts +0 -8
  28. package/dist/cjs/plugins/special-use.js +23 -0
  29. package/dist/cjs/quirks.d.ts +28 -0
  30. package/dist/cjs/quirks.js +124 -0
  31. package/dist/cjs/random.d.ts +10 -0
  32. package/dist/cjs/random.js +22 -0
  33. package/dist/cjs/rest.d.ts +50 -0
  34. package/dist/cjs/rest.js +561 -0
  35. package/dist/cjs/script.d.ts +33 -4
  36. package/dist/cjs/script.js +64 -4
  37. package/dist/cjs/server.d.ts +147 -8
  38. package/dist/cjs/server.js +398 -144
  39. package/dist/cjs/smtp-listener.d.ts +3 -2
  40. package/dist/cjs/smtp-listener.js +10 -6
  41. package/dist/cjs/storage-schema.d.ts +139 -0
  42. package/dist/cjs/storage-schema.js +215 -0
  43. package/dist/cjs/store-operations.d.ts +168 -0
  44. package/dist/cjs/store-operations.js +422 -0
  45. package/dist/cjs/types.d.ts +22 -1
  46. package/dist/esm/commands/append.js +1 -1
  47. package/dist/esm/commands/create.js +2 -2
  48. package/dist/esm/commands/delete.js +2 -14
  49. package/dist/esm/commands/handlers/flags.d.ts +3 -3
  50. package/dist/esm/commands/handlers/flags.js +3 -3
  51. package/dist/esm/commands/handlers/store.js +3 -3
  52. package/dist/esm/commands/rename.js +6 -46
  53. package/dist/esm/commands/select.js +1 -0
  54. package/dist/esm/commands/subscribe.js +7 -8
  55. package/dist/esm/commands/unsubscribe.js +3 -9
  56. package/dist/esm/control.d.ts +430 -0
  57. package/dist/esm/control.js +630 -0
  58. package/dist/esm/index.d.ts +8 -1
  59. package/dist/esm/index.js +5 -2
  60. package/dist/esm/load-plugins.d.ts +1 -1
  61. package/dist/esm/load-plugins.js +7 -2
  62. package/dist/esm/plugins/acl.js +109 -42
  63. package/dist/esm/plugins/condstore.js +7 -0
  64. package/dist/esm/plugins/index.js +1 -3
  65. package/dist/esm/plugins/metadata.js +140 -57
  66. package/dist/esm/plugins/objectid.js +10 -0
  67. package/dist/esm/plugins/quota.js +50 -2
  68. package/dist/esm/plugins/savedate.js +1 -1
  69. package/dist/esm/plugins/special-use.d.ts +0 -8
  70. package/dist/esm/plugins/special-use.js +23 -0
  71. package/dist/esm/quirks.d.ts +28 -0
  72. package/dist/esm/quirks.js +120 -0
  73. package/dist/esm/random.d.ts +10 -0
  74. package/dist/esm/random.js +19 -0
  75. package/dist/esm/rest.d.ts +50 -0
  76. package/dist/esm/rest.js +553 -0
  77. package/dist/esm/script.d.ts +33 -4
  78. package/dist/esm/script.js +63 -4
  79. package/dist/esm/server.d.ts +147 -8
  80. package/dist/esm/server.js +399 -145
  81. package/dist/esm/smtp-listener.d.ts +3 -2
  82. package/dist/esm/smtp-listener.js +10 -6
  83. package/dist/esm/storage-schema.d.ts +139 -0
  84. package/dist/esm/storage-schema.js +211 -0
  85. package/dist/esm/store-operations.d.ts +168 -0
  86. package/dist/esm/store-operations.js +407 -0
  87. package/dist/esm/types.d.ts +22 -1
  88. package/dist/plugin-help.json +0 -14
  89. package/package.json +11 -3
  90. package/dist/cjs/plugins/xtoybird.d.ts +0 -2
  91. package/dist/cjs/plugins/xtoybird.js +0 -236
  92. package/dist/esm/plugins/xtoybird.d.ts +0 -2
  93. package/dist/esm/plugins/xtoybird.js +0 -218
@@ -13,8 +13,13 @@ import { hasSequenceSetKey } from './commands/handlers/search.js';
13
13
  import { isSequenceSet } from './numbers.js';
14
14
  import { restoreNilAtoms } from './arguments.js';
15
15
  import { refuseMissingTarget } from './commands/append.js';
16
+ import { DEFAULT_SESSION_TIMEOUT, storeError, expungeMessages, notifyFlagChanges } from './store-operations.js';
17
+ import { Control } from './control.js';
18
+ import { resolveQuirks } from './quirks.js';
19
+ import { validateStorage } from './storage-schema.js';
20
+ import { createRestServer } from './rest.js';
16
21
  import * as bundledCert from './cert.js';
17
- import { ServerScript, sendOutput, handleLine, literalResponse, releaseDeferred } from './script.js';
22
+ import { ServerScript, sendOutput, handleLine, handleQuiet, literalResponse, releaseDeferred } from './script.js';
18
23
  // longest command line (not counting literals) accepted from a client
19
24
  const MAX_LINE_LENGTH = 1024 * 1024;
20
25
  // milliseconds between the last output and the RST of a script rule with `close: 'reset'`
@@ -59,18 +64,6 @@ function getResponseTag(line) {
59
64
  function stateError(command, state) {
60
65
  return command + ' is not allowed in the ' + state + ' state';
61
66
  }
62
- /**
63
- * Creates an error for a failed mailbox operation, with a RFC 5530 response code
64
- *
65
- * @param {String} message Error message
66
- * @param {String} code Response code, e.g. "ALREADYEXISTS"
67
- * @return {Error} Error object
68
- */
69
- function mailboxError(message, code) {
70
- const err = new Error(message);
71
- err.code = code;
72
- return err;
73
- }
74
67
  /**
75
68
  * Creates a new IMAP server, call `listen()` on it to start accepting connections
76
69
  *
@@ -159,11 +152,33 @@ class IMAPServer extends Stream {
159
152
  // the session whose command is running, see IMAPServer#notify
160
153
  this.activeConnection = null;
161
154
  this.sessionCounter = 0;
162
- // rules that make the server misbehave on purpose, from the script option or server.script.add()
155
+ // rules that make the server misbehave on purpose, from the script option or server.script.add(), and the rules
156
+ // of the quirk presets after them
163
157
  this.script = new ServerScript(this, this.options.script);
164
- // users and storage are deep copied, so that runtime changes never leak into
165
- // the caller's objects or into other servers built from the same fixture.
166
- // Without a prototype, user names like "__proto__" or "toString" are plain keys
158
+ const quirks = resolveQuirks(this.options.quirks);
159
+ if (quirks.rules.length) {
160
+ this.script.add(quirks.rules);
161
+ }
162
+ this.loadUsers();
163
+ // plugins register their control operations
164
+ this.control = new Control(this);
165
+ // a quirk preset can leave plugins out (no-move, no-uidplus)
166
+ loadPlugins(this, this.options.plugins, [...quirks.removePlugins]);
167
+ if (this.options.storage) {
168
+ // a typo in a fixture fails here with the path of the problem
169
+ validateStorage(this.options.storage);
170
+ }
171
+ this.systemFlags = [].concat(this.options.systemFlags || ['\\Answered', '\\Flagged', '\\Draft', '\\Deleted', '\\Seen']);
172
+ this.loadStorage();
173
+ this.smtpServer = null;
174
+ this.restServer = null;
175
+ }
176
+ /**
177
+ * Sets the users from the `users` option, or the default user. The users are deep copied, so that runtime changes
178
+ * never leak into the caller's objects or into other servers built from the same fixture. Without a prototype,
179
+ * user names like "__proto__" or "toString" are plain keys
180
+ */
181
+ loadUsers() {
167
182
  this.users = Object.assign(Object.create(null), this.options.users
168
183
  ? structuredClone(this.options.users)
169
184
  : {
@@ -171,12 +186,16 @@ class IMAPServer extends Stream {
171
186
  password: 'testpass',
172
187
  xoauth2: {
173
188
  accessToken: 'testtoken',
174
- sessionTimeout: 3600 * 1000
189
+ sessionTimeout: DEFAULT_SESSION_TIMEOUT
175
190
  }
176
191
  }
177
192
  });
178
- loadPlugins(this, this.options.plugins);
179
- this.systemFlags = [].concat(this.options.systemFlags || ['\\Answered', '\\Flagged', '\\Draft', '\\Deleted', '\\Seen']);
193
+ }
194
+ /**
195
+ * Builds the mailboxes from the `storage` option, a deep copy of it. Message and mailbox handlers of plugins run
196
+ * on every message and mailbox
197
+ */
198
+ loadStorage() {
180
199
  // indexFolders() below turns the storage option into namespaces and mailboxes in place
181
200
  this.storage = (this.options.storage
182
201
  ? structuredClone(this.options.storage)
@@ -184,6 +203,7 @@ class IMAPServer extends Stream {
184
203
  INBOX: {},
185
204
  '': {}
186
205
  });
206
+ this.referenceNamespace = false;
187
207
  this.uidvalidityCounter = 0; // highest UIDVALIDITY in use, new mailboxes get a higher one
188
208
  // subscribed mailbox names (RFC 3501 section 6.3.6). Names, not mailboxes: a subscription outlives
189
209
  // DELETE and stays with the old name on RENAME (RFC 9051 section 6.3.6), see trackSubscription
@@ -191,10 +211,67 @@ class IMAPServer extends Stream {
191
211
  this.folderCache = Object.create(null);
192
212
  this.indexFolders(true);
193
213
  }
214
+ /**
215
+ * Starts accepting connections
216
+ *
217
+ * @param {Number} [port] Port to listen on, a free port if not set
218
+ * @param {String} [host] Address to listen on, all addresses if not set
219
+ * @return {Promise<Number>} resolves with the port the server listens on
220
+ */
221
+ async start(port, host) {
222
+ const imapPort = await listenOn(this.server, port, host);
223
+ await Promise.all([this.options.smtp ? this.startSmtp() : null, this.options.rest ? this.startRest() : null]);
224
+ return imapPort;
225
+ }
226
+ /**
227
+ * Starts the REST API of the `rest` option, see src/rest.ts
228
+ *
229
+ * @return {Promise<Number>} resolves with the HTTP port
230
+ */
231
+ async startRest() {
232
+ const { port, host, token } = this.options.rest || {};
233
+ this.restServer = createRestServer(this, { host, token });
234
+ return listenOn(this.restServer, port, host || '127.0.0.1');
235
+ }
236
+ /**
237
+ * Starts the SMTP server of the `smtp` option. smtp-server is an optional dependency, loaded only here
238
+ *
239
+ * @return {Promise<Number>} resolves with the SMTP port
240
+ */
241
+ startSmtp() {
242
+ const { port, host } = this.options.smtp || {};
243
+ return this.loadSmtpListener().then(({ startSMTPServer }) => new Promise((resolve, reject) => {
244
+ const smtp = startSMTPServer(port || 0, this, smtpPort => {
245
+ smtp.removeListener('error', reject);
246
+ resolve(smtpPort);
247
+ }, host);
248
+ smtp.once('error', reject);
249
+ this.smtpServer = smtp;
250
+ }));
251
+ }
252
+ /**
253
+ * Loads the SMTP listener, which needs the smtp-server package
254
+ *
255
+ * @return {Promise<Object>} the smtp-listener module
256
+ */
257
+ loadSmtpListener() {
258
+ return import('./smtp-listener.js').catch((err) => {
259
+ throw new Error('The smtp option needs the smtp-server package, install it with: npm install smtp-server (' + err.message + ')');
260
+ });
261
+ }
262
+ /**
263
+ * Stops the server and closes every connection
264
+ *
265
+ * @return {Promise} resolves when the server is closed
266
+ */
267
+ stop() {
268
+ return this.control.shutdown({ graceful: false });
269
+ }
194
270
  listen(...args) {
195
271
  this.server.listen.apply(this.server, args);
196
272
  }
197
273
  close(callback) {
274
+ this.closeListeners(true);
198
275
  this.server.close(callback);
199
276
  // close() only completes once all connections are gone
200
277
  this.connections.forEach((connection) => {
@@ -203,6 +280,29 @@ class IMAPServer extends Stream {
203
280
  }
204
281
  });
205
282
  }
283
+ /**
284
+ * Stops the SMTP and REST listeners of `start()`
285
+ *
286
+ * @param {Boolean} force true closes the open REST connections too, otherwise only the idle ones (a request that
287
+ * is answered, like POST /v1/shutdown, finishes first)
288
+ */
289
+ closeListeners(force) {
290
+ if (this.smtpServer) {
291
+ this.smtpServer.close(() => false);
292
+ this.smtpServer = null;
293
+ }
294
+ if (this.restServer) {
295
+ this.restServer.close();
296
+ // keep-alive connections would hold the server open
297
+ if (force) {
298
+ this.restServer.closeAllConnections?.();
299
+ }
300
+ else {
301
+ this.restServer.closeIdleConnections?.();
302
+ }
303
+ this.restServer = null;
304
+ }
305
+ }
206
306
  /**
207
307
  * Returns TLS key and certificate. Without credentials in the options the
208
308
  * bundled self-signed certificate for localhost is used.
@@ -322,13 +422,31 @@ class IMAPServer extends Stream {
322
422
  *
323
423
  * @param {String} type Kind of change
324
424
  * @param {String} path Storage name of the mailbox
325
- * @param {Object} [details] `{ oldPath, mailbox }`: the earlier name of a renamed mailbox, the mailbox
326
- * object that a DELETE removed
425
+ * @param {Object} [details] `{ oldPath, mailbox, created }`: the earlier name of a renamed mailbox, the mailbox
426
+ * object that a DELETE removed, the mailboxes a CREATE made (superior levels first)
327
427
  */
328
428
  mailboxChanged(type, path, details) {
329
429
  const event = Object.assign({ type, path, oldPath: null, mailbox: null }, details, { origin: this.activeConnection });
330
430
  this.emit('mailbox', event);
331
431
  }
432
+ /**
433
+ * Runs a change with `origin` as the session that caused it, the `origin` of the notifications and events it
434
+ * makes. Commands run with their session, the control API with null
435
+ *
436
+ * @param {Object|null} origin Session, or null for a change from outside
437
+ * @param {Function} fn Change
438
+ * @return {*} the result of fn
439
+ */
440
+ withOrigin(origin, fn) {
441
+ const previous = this.activeConnection;
442
+ this.activeConnection = origin;
443
+ try {
444
+ return fn();
445
+ }
446
+ finally {
447
+ this.activeConnection = previous;
448
+ }
449
+ }
332
450
  /**
333
451
  * Retrieves the handler of an IMAP command
334
452
  *
@@ -461,22 +579,22 @@ class IMAPServer extends Stream {
461
579
  */
462
580
  createMailbox(path, defaultMailbox) {
463
581
  if (!path) {
464
- throw mailboxError('Invalid mailbox name', 'CANNOT');
582
+ throw storeError('Invalid mailbox name', 'CANNOT');
465
583
  }
466
584
  // Ensure case insensitive INBOX
467
585
  if (path.toUpperCase() === 'INBOX') {
468
- throw mailboxError('INBOX can not be modified', 'ALREADYEXISTS');
586
+ throw storeError('INBOX can not be modified', 'ALREADYEXISTS');
469
587
  }
470
588
  const { namespace, storage } = this.getPersonalNamespace(path);
471
589
  path = this.stripSeparator(path, storage.separator);
472
590
  if (this.folderCache[path] && this.folderCache[path].flags.indexOf('\\Noselect') < 0) {
473
- throw mailboxError('Mailbox already exists', 'ALREADYEXISTS');
591
+ throw storeError('Mailbox already exists', 'ALREADYEXISTS');
474
592
  }
475
593
  const folderPath = path.substr(namespace.length).split(storage.separator);
476
594
  if (folderPath.some(name => !name)) {
477
595
  // an empty hierarchy level ("foo//bar", "/foo", "foo//"). RFC 5530 section 3 has this very case as the
478
596
  // example of CANNOT, Dovecot refuses it too
479
- throw mailboxError('Mailbox names can not have empty hierarchy levels', 'CANNOT');
597
+ throw storeError('Mailbox names can not have empty hierarchy levels', 'CANNOT');
480
598
  }
481
599
  let parent = storage;
482
600
  let curPath = namespace;
@@ -487,7 +605,7 @@ class IMAPServer extends Stream {
487
605
  curPath += (curPath.length ? storage.separator : '') + folderName;
488
606
  let folder = this.getMailbox(curPath) || false;
489
607
  if (folder && folder.flags && folder.flags.indexOf('\\Noinferiors') >= 0) {
490
- throw mailboxError('Can not create subfolders for ' + folder.path, 'CANNOT');
608
+ throw storeError('Can not create subfolders for ' + folder.path, 'CANNOT');
491
609
  }
492
610
  // a \Noselect placeholder that is created again is replaced with a new mailbox that only keeps
493
611
  // the children, nothing else of a deleted mailbox may come back (RFC 3501 section 6.3.3)
@@ -529,17 +647,17 @@ class IMAPServer extends Stream {
529
647
  deleteMailbox(path, keepContents) {
530
648
  // Ensure case insensitive INBOX
531
649
  if (path.toUpperCase() === 'INBOX') {
532
- throw mailboxError('INBOX can not be modified', 'CANNOT');
650
+ throw storeError('INBOX can not be modified', 'CANNOT');
533
651
  }
534
652
  const { namespace, storage } = this.getPersonalNamespace(path);
535
653
  const mailbox = this.folderCache[this.stripSeparator(path, storage.separator)];
536
654
  if (!mailbox) {
537
- throw mailboxError('Mailbox does not exist', 'NONEXISTENT');
655
+ throw storeError('Mailbox does not exist', 'NONEXISTENT');
538
656
  }
539
657
  if (mailbox.flags.indexOf('\\Noselect') >= 0 && Object.keys(mailbox.folders || {}).length) {
540
658
  // RFC 9051 section 6.3.5: deleting a \Noselect name that has inferior names is an error, the RFC 5530
541
659
  // section 3 HASCHILDREN response code tells the client to delete the children first
542
- throw mailboxError('Mailbox has children, delete them first', 'HASCHILDREN');
660
+ throw storeError('Mailbox has children, delete them first', 'HASCHILDREN');
543
661
  }
544
662
  const levels = mailbox.path.split(storage.separator);
545
663
  const folderName = levels.pop();
@@ -589,7 +707,7 @@ class IMAPServer extends Stream {
589
707
  const prefix = key.length ? key.substr(0, key.length - this.storage[key].separator.length) : key;
590
708
  if (key.length && (path === prefix || path.substr(0, key.length) === key)) {
591
709
  if (path === prefix) {
592
- throw mailboxError('Used mailbox name is a namespace value', 'CANNOT');
710
+ throw storeError('Used mailbox name is a namespace value', 'CANNOT');
593
711
  }
594
712
  namespace = key;
595
713
  }
@@ -599,10 +717,10 @@ class IMAPServer extends Stream {
599
717
  });
600
718
  const storage = this.storage[namespace];
601
719
  if (!storage) {
602
- throw mailboxError('Unknown namespace', 'CANNOT');
720
+ throw storeError('Unknown namespace', 'CANNOT');
603
721
  }
604
722
  if (storage.type !== 'personal') {
605
- throw mailboxError('Permission denied', 'NOPERM');
723
+ throw storeError('Permission denied', 'NOPERM');
606
724
  }
607
725
  return { namespace, storage };
608
726
  }
@@ -843,6 +961,17 @@ class IMAPServer extends Stream {
843
961
  const knownFlags = (mailbox.knownFlags = mailbox.knownFlags || []);
844
962
  flags.forEach(flag => this.ensureFlag(knownFlags, flag));
845
963
  }
964
+ /**
965
+ * The current time for dates the server sets itself (INTERNALDATE of a message without one, SAVEDATE). The `now`
966
+ * option fixes it for repeatable tests: a Date, a timestamp, or a function that returns one
967
+ *
968
+ * @return {Date} current time
969
+ */
970
+ now() {
971
+ const now = this.options.now;
972
+ const value = typeof now === 'function' ? now() : now;
973
+ return value === undefined || value === null ? new Date() : new Date(value);
974
+ }
846
975
  normalizeDateTime(value) {
847
976
  if (value instanceof Date) {
848
977
  return this.formatInternalDate(value);
@@ -861,7 +990,7 @@ class IMAPServer extends Stream {
861
990
  * @param mailbox Mailbox of the message
862
991
  */
863
992
  processMessage(message, mailbox) {
864
- message.internaldate = this.normalizeDateTime(message.internaldate || new Date());
993
+ message.internaldate = this.normalizeDateTime(message.internaldate || this.now());
865
994
  message.flags = [].concat(message.flags || []);
866
995
  if (message.flags.indexOf('\\Recent') >= 0) {
867
996
  // \Recent is not a stored flag, it belongs to the first session that selects the mailbox
@@ -1251,6 +1380,7 @@ class IMAPConnection {
1251
1380
  this.notificationQueue = [];
1252
1381
  this.server.on('notify', this._notificationCallback);
1253
1382
  this.server.connections.add(this);
1383
+ this.emitSession('open');
1254
1384
  this.scriptOutput('greeting', '* OK ImapKit ready for rumble\r\n', {});
1255
1385
  }
1256
1386
  /**
@@ -1285,7 +1415,7 @@ class IMAPConnection {
1285
1415
  }
1286
1416
  this._outputQueue = this._outputQueue || [];
1287
1417
  this._outputQueue.push(Object.assign({}, operation, { transport: this.transport }));
1288
- if (!this._outputTimer) {
1418
+ if (!this._outputTimer && !this._outputWaiting) {
1289
1419
  this.flushOutput();
1290
1420
  }
1291
1421
  }
@@ -1306,10 +1436,28 @@ class IMAPConnection {
1306
1436
  return;
1307
1437
  }
1308
1438
  if (operation.data && operation.chunk && operation.data.length > operation.chunk) {
1309
- // the rest waits for chunkDelay, like a delay of its own
1310
- this.writeLayer(operation.data.subarray(0, operation.chunk), operation.transport || null);
1439
+ // the rest waits for chunkDelay, like a delay of its own. Without a delay each piece waits for the next
1440
+ // event loop turn, and Nagle's algorithm must not hold it back to merge it with the next one
1441
+ const piece = operation.data.subarray(0, operation.chunk);
1311
1442
  operation.data = operation.data.subarray(operation.chunk);
1443
+ if (!operation.chunkDelay || operation.chunkDelay === 'tick') {
1444
+ // the next piece waits until this one was handed to the system, and one more event loop turn, so
1445
+ // the pieces leave as separate segments without a wall clock delay (#89)
1446
+ this.socket?.setNoDelay?.(true);
1447
+ this._outputWaiting = true;
1448
+ this.writeLayer(piece, operation.transport || null, () => {
1449
+ if (this._outputWaiting && this._outputQueue === queue) {
1450
+ this._outputTimer = nextTurn(() => {
1451
+ this._outputTimer = null;
1452
+ this._outputWaiting = false;
1453
+ this.flushOutput();
1454
+ });
1455
+ }
1456
+ });
1457
+ return;
1458
+ }
1312
1459
  operation.delay = operation.chunkDelay;
1460
+ this.writeLayer(piece, operation.transport || null);
1313
1461
  continue;
1314
1462
  }
1315
1463
  if (operation.data) {
@@ -1329,8 +1477,10 @@ class IMAPConnection {
1329
1477
  clearOutputQueue() {
1330
1478
  if (this._outputTimer) {
1331
1479
  clearTimeout(this._outputTimer);
1480
+ clearImmediate(this._outputTimer);
1332
1481
  this._outputTimer = null;
1333
1482
  }
1483
+ this._outputWaiting = false;
1334
1484
  this._outputQueue = null;
1335
1485
  this._deferredOutput = null;
1336
1486
  }
@@ -1339,13 +1489,17 @@ class IMAPConnection {
1339
1489
  *
1340
1490
  * @param {Buffer} data Output
1341
1491
  * @param {Object|null} transport Transport layer
1492
+ * @param {Function} [callback] Called once the data was handed to the system (a transport: on the next turn)
1342
1493
  */
1343
- writeLayer(data, transport) {
1494
+ writeLayer(data, transport, callback) {
1344
1495
  if (transport) {
1345
1496
  transport.write(data);
1497
+ if (callback) {
1498
+ setImmediate(callback);
1499
+ }
1346
1500
  }
1347
1501
  else {
1348
- this.writeRaw(data);
1502
+ this.writeRaw(data, callback);
1349
1503
  }
1350
1504
  }
1351
1505
  /**
@@ -1399,17 +1553,70 @@ class IMAPConnection {
1399
1553
  *
1400
1554
  * @param {Buffer} data Data to send
1401
1555
  */
1402
- writeRaw(data) {
1556
+ writeRaw(data, callback) {
1403
1557
  if (this.socket && !this.socket.destroyed) {
1404
- this.socket.write(data);
1558
+ this.socket.write(data, callback);
1559
+ this.touch();
1560
+ }
1561
+ }
1562
+ /**
1563
+ * Notes input or output for the quiet events of script rules (#88)
1564
+ */
1565
+ touch() {
1566
+ if (!this.server.script.watches('quiet')) {
1567
+ // nothing waits for quiet sessions, a rule added later counts from then on
1568
+ this._lastActivity = undefined;
1569
+ return;
1570
+ }
1571
+ this._lastActivity = Date.now();
1572
+ if (!this._quietTimer) {
1573
+ this.watchQuiet();
1405
1574
  }
1406
1575
  }
1576
+ /**
1577
+ * Starts waiting for the next quiet event, if a rule waits for one and the session is not waiting already. When
1578
+ * the time is up, the first rule that matches handles the event (`send`, `close`), the output it sends starts
1579
+ * the next quiet time
1580
+ *
1581
+ * @param {Number} [checked] Quiet time the rules were checked for already, only longer ones are waited for
1582
+ */
1583
+ watchQuiet(checked = 0) {
1584
+ if (this._quietTimer || !this.socket || this._closing) {
1585
+ return;
1586
+ }
1587
+ const last = this._lastActivity || Date.now();
1588
+ this._lastActivity = last;
1589
+ // the time is measured from the last activity, a rule whose time is up already fires right away
1590
+ const next = this.server.script.nextQuiet(checked);
1591
+ if (next === null) {
1592
+ return;
1593
+ }
1594
+ this._quietTimer = setTimeout(() => {
1595
+ this._quietTimer = null;
1596
+ if (!this.isOpen() || this._lastActivity !== last) {
1597
+ // there was activity in between, it started a new wait
1598
+ return this.watchQuiet();
1599
+ }
1600
+ // a timer can fire a millisecond before Date.now() says the time is up
1601
+ const quiet = Math.max(Date.now() - last, next);
1602
+ const found = this.server.script.check(this, 'quiet', { data: '', quiet });
1603
+ if (found) {
1604
+ handleQuiet(this, found.rule, found.context);
1605
+ }
1606
+ if (this._lastActivity === last) {
1607
+ // nothing was sent, wait for the rules with a longer quiet time
1608
+ this.watchQuiet(quiet);
1609
+ }
1610
+ }, Math.max(0, next - (Date.now() - last)));
1611
+ this._quietTimer.unref();
1612
+ }
1407
1613
  /**
1408
1614
  * Handles data from the socket, through the transport layer if there is one
1409
1615
  *
1410
1616
  * @param {Buffer} chunk Received data
1411
1617
  */
1412
1618
  receive(chunk) {
1619
+ this.touch();
1413
1620
  if (this.transport) {
1414
1621
  this.transport.receive(chunk);
1415
1622
  }
@@ -1542,11 +1749,15 @@ class IMAPConnection {
1542
1749
  * notifications that were not sent yet are dropped. Sends nothing, the caller answers the command
1543
1750
  */
1544
1751
  closeMailbox() {
1752
+ const wasSelected = !!this.selectedMailbox;
1545
1753
  this.state = 'Authenticated';
1546
1754
  this.selectedMailbox = false;
1547
1755
  this.readOnly = false;
1548
1756
  this.recent = null;
1549
1757
  this.notificationQueue = [];
1758
+ if (wasSelected) {
1759
+ this.emitSession('unselect');
1760
+ }
1550
1761
  }
1551
1762
  onClose() {
1552
1763
  if (this.socket) {
@@ -1558,8 +1769,14 @@ class IMAPConnection {
1558
1769
  this.transport = null;
1559
1770
  }
1560
1771
  this.clearOutputQueue();
1772
+ if (this._quietTimer) {
1773
+ clearTimeout(this._quietTimer);
1774
+ this._quietTimer = null;
1775
+ }
1561
1776
  this.server.removeListener('notify', this._notificationCallback);
1562
- this.server.connections.delete(this);
1777
+ if (this.server.connections.delete(this)) {
1778
+ this.emitSession('close');
1779
+ }
1563
1780
  }
1564
1781
  onError(err) {
1565
1782
  if (this.options.debug) {
@@ -1911,8 +2128,8 @@ class IMAPConnection {
1911
2128
  * @param {Array} messages Messages with changed flags
1912
2129
  */
1913
2130
  notifyFlagChanges(messages) {
1914
- if (messages.length && this.selectedMailbox) {
1915
- this.server.notify({ tag: '*', flagUpdate: messages }, this.selectedMailbox, this);
2131
+ if (this.selectedMailbox) {
2132
+ notifyFlagChanges(this.server, this.selectedMailbox, messages, this);
1916
2133
  }
1917
2134
  }
1918
2135
  /**
@@ -2168,7 +2385,7 @@ class IMAPConnection {
2168
2385
  */
2169
2386
  send(response, description, parsed, data, ...extra) {
2170
2387
  // nothing goes out once the connection is closing (RFC 3501 section 7.1.5)
2171
- if (!this.socket || this.socket.destroyed || this._closing) {
2388
+ if (!this.isOpen()) {
2172
2389
  return;
2173
2390
  }
2174
2391
  if (!response.notification && response.tag !== '*') {
@@ -2178,6 +2395,9 @@ class IMAPConnection {
2178
2395
  this.server.outputHandlers.forEach(handler => {
2179
2396
  handler(this, response, description, parsed, data, ...extra);
2180
2397
  });
2398
+ if (this._commandStart && parsed && response.tag === this._commandStart.tag && parsed.tag === response.tag) {
2399
+ this.commandCompleted(String(response.command || '').toUpperCase());
2400
+ }
2181
2401
  // No need to display this response to user
2182
2402
  if (response.skipResponse) {
2183
2403
  return;
@@ -2553,6 +2773,64 @@ class IMAPConnection {
2553
2773
  }, 'UNKNOWN COMMAND', parsed, data);
2554
2774
  }
2555
2775
  }
2776
+ /**
2777
+ * Emits the `command` event when the tagged response of a command goes out, and the `session` events "login"
2778
+ * (the command authenticated the session) and "logout" (back to Not Authenticated, UNAUTHENTICATE). Every command
2779
+ * that changes these does so before its tagged response. "select" and "unselect" come from SELECT and
2780
+ * closeMailbox(). Tests wait for these instead of polling
2781
+ *
2782
+ * @param {String} status OK, NO or BAD
2783
+ */
2784
+ commandCompleted(status) {
2785
+ const start = this._commandStart;
2786
+ this._commandStart = null;
2787
+ const server = this.server;
2788
+ if (!server.listenerCount('command') && !server.listenerCount('session')) {
2789
+ return;
2790
+ }
2791
+ server.emit('command', { session: this.sessionNumber, tag: start.tag, command: start.command, status, user: this.username || null });
2792
+ const authenticated = (state) => state === 'Authenticated' || state === 'Selected';
2793
+ if (authenticated(start.state) !== authenticated(this.state) && this.state !== 'Logout') {
2794
+ this.emitSession(authenticated(this.state) ? 'login' : 'logout');
2795
+ }
2796
+ }
2797
+ /**
2798
+ * Emits a `session` event, `{ type, session, ...fields }`
2799
+ *
2800
+ * @param {String} type Event type
2801
+ * @param {Object} [fields] More fields of the event
2802
+ */
2803
+ emitSession(type, fields) {
2804
+ if (this.server.listenerCount('session')) {
2805
+ this.server.emit('session', Object.assign({ type, session: this.describe() }, fields));
2806
+ }
2807
+ }
2808
+ /**
2809
+ * Describes the session without credentials or sockets, for the control API and the `session` events
2810
+ *
2811
+ * @return {Object} `{ session, user, state, mailbox, readOnly, enabled, secure, compressed, remoteAddress }`
2812
+ */
2813
+ describe() {
2814
+ return {
2815
+ session: this.sessionNumber,
2816
+ user: this.username || null,
2817
+ state: this.state,
2818
+ mailbox: this.selectedMailbox ? this.selectedMailbox.path : null,
2819
+ readOnly: !!this.selectedMailbox && !!this.readOnly,
2820
+ enabled: Array.isArray(this.enabled) ? this.enabled.slice() : [],
2821
+ secure: !!this.secureConnection,
2822
+ compressed: !!this.transport,
2823
+ remoteAddress: (this.socket && this.socket.remoteAddress) || null
2824
+ };
2825
+ }
2826
+ /**
2827
+ * Checks if output can still be sent: the socket is open and the connection is not closing (BYE, LOGOUT)
2828
+ *
2829
+ * @return {Boolean} true for a live session
2830
+ */
2831
+ isOpen() {
2832
+ return !!this.socket && !this.socket.destroyed && !this._closing;
2833
+ }
2556
2834
  /**
2557
2835
  * Handles a command line with the script rule that matched it, after the rule's delay. With `run` the line
2558
2836
  * goes through the parser and the command handler as usual afterwards
@@ -2593,6 +2871,8 @@ class IMAPConnection {
2593
2871
  const command = element.parsed.command.toUpperCase();
2594
2872
  this._runningCommand = element;
2595
2873
  const options = this.server.getCommandOptions(command);
2874
+ // the session events of the command are worked out when its tagged response goes out, see commandCompleted
2875
+ this._commandStart = { tag: element.parsed.tag, command, state: this.state };
2596
2876
  let done = false;
2597
2877
  const next = () => {
2598
2878
  if (done) {
@@ -2655,53 +2935,52 @@ class IMAPConnection {
2655
2935
  // criteria, processNotifications knows when EXPUNGE must wait
2656
2936
  this.processNotifications(element.parsed);
2657
2937
  }
2658
- try {
2659
- // changes made while the handler runs are attributed to this session (the `origin` of notifications)
2660
- this.server.activeConnection = this;
2661
- const inputHandler = this.inputHandler;
2662
- this.server.getCommandHandler(element.parsed.command)(this, element.parsed, element.data, next);
2663
- if (this.inputHandler && this.inputHandler !== inputHandler) {
2664
- // the command reads the lines that follow (IDLE, AUTHENTICATE), script rules match them with it
2665
- this.inputCommand = { tag: element.parsed.tag, command: element.parsed.command };
2938
+ // changes made while the handler runs are attributed to this session (the `origin` of notifications)
2939
+ this.server.withOrigin(this, () => {
2940
+ try {
2941
+ const inputHandler = this.inputHandler;
2942
+ this.server.getCommandHandler(element.parsed.command)(this, element.parsed, element.data, next);
2943
+ if (this.inputHandler && this.inputHandler !== inputHandler) {
2944
+ // the command reads the lines that follow (IDLE, AUTHENTICATE), script rules match them with it
2945
+ this.inputCommand = { tag: element.parsed.tag, command: element.parsed.command };
2946
+ this.emitSession('waiting', { command });
2947
+ }
2666
2948
  }
2667
- }
2668
- catch (E) {
2669
- const ex = E;
2670
- const badInput = ex.imapResponse === 'BAD';
2671
- if (!badInput && this.options.debug) {
2672
- console.error('Error processing command:', ex, '\n', ex.stack);
2949
+ catch (E) {
2950
+ const ex = E;
2951
+ const badInput = ex.imapResponse === 'BAD';
2952
+ if (!badInput && this.options.debug) {
2953
+ console.error('Error processing command:', ex, '\n', ex.stack);
2954
+ }
2955
+ this.send({
2956
+ tag: element.parsed.tag,
2957
+ command: badInput ? 'BAD' : 'NO',
2958
+ attributes: [].concat(badInput
2959
+ ? []
2960
+ : {
2961
+ type: 'SECTION',
2962
+ section: [
2963
+ {
2964
+ type: 'ATOM',
2965
+ value: 'SERVERBUG'
2966
+ }
2967
+ ]
2968
+ }, {
2969
+ type: 'TEXT',
2970
+ value: badInput ? ex.message : 'Server error: ' + ex.message
2971
+ })
2972
+ }, badInput ? 'INVALID COMMAND' : 'SERVER ERROR', element.parsed, element.data);
2973
+ // keep the connection usable, otherwise every later command would hang
2974
+ next();
2673
2975
  }
2674
- this.send({
2675
- tag: element.parsed.tag,
2676
- command: badInput ? 'BAD' : 'NO',
2677
- attributes: [].concat(badInput
2678
- ? []
2679
- : {
2680
- type: 'SECTION',
2681
- section: [
2682
- {
2683
- type: 'ATOM',
2684
- value: 'SERVERBUG'
2685
- }
2686
- ]
2687
- }, {
2688
- type: 'TEXT',
2689
- value: badInput ? ex.message : 'Server error: ' + ex.message
2690
- })
2691
- }, badInput ? 'INVALID COMMAND' : 'SERVER ERROR', element.parsed, element.data);
2692
- // keep the connection usable, otherwise every later command would hang
2693
- next();
2694
- }
2695
- finally {
2696
- this.server.activeConnection = null;
2697
- }
2976
+ });
2698
2977
  }
2699
2978
  /**
2700
2979
  * Removes messages with \Deleted flag
2701
2980
  *
2702
2981
  * @param {Object} mailbox Mailbox to check for
2703
2982
  * @param {Boolean} [ignoreSelf] If set to true, does not send any notices to itself
2704
- * @param {Boolean} [ignoreSelf] If set to true, does not send EXISTS notice to itself
2983
+ * @param {Boolean} [ignoreExists] If set to true, does not send EXISTS notice to itself
2705
2984
  */
2706
2985
  expungeDeleted(mailbox, ignoreSelf, ignoreExists) {
2707
2986
  this.expungeSpecificMessages(mailbox, (message) => {
@@ -2710,80 +2989,55 @@ class IMAPConnection {
2710
2989
  }
2711
2990
  /**
2712
2991
  * Given a set of messages in a mailbox (possibly via getMessageRange), remove
2713
- * them from the mailbox and generate EXPUNGE notifications.
2992
+ * them from the mailbox and generate EXPUNGE notifications, see expungeMessages() in store-operations.ts
2714
2993
  *
2715
2994
  * @param {Object} mailbox Mailbox to check for
2716
2995
  * @param {Function|Array} messagesOrFilterFunc An Array of messages in the
2717
2996
  * folder that should be removed or a filtering function that indicates
2718
2997
  * messages to be removed by returning true.
2719
2998
  * @param {Boolean} [ignoreSelf] If set to true, does not send any notices to itself
2720
- * @param {Boolean} [ignoreSelf] If set to true, does not send EXISTS notice to itself
2999
+ * @param {Boolean} [ignoreExists] If set to true, does not send EXISTS notice to itself
2721
3000
  * @param {Boolean} [highestFirst] If set to true, the EXPUNGE responses go from the highest UID to the lowest
2722
3001
  * (MESSAGELIMIT, RFC 9738 section 3.1), otherwise from the lowest
2723
3002
  */
2724
3003
  expungeSpecificMessages(mailbox, messagesOrFilterFunc, ignoreSelf, ignoreExists, highestFirst) {
2725
- let filterFunc;
2726
- if (Array.isArray(messagesOrFilterFunc)) {
2727
- const messageSet = new Set(messagesOrFilterFunc);
2728
- filterFunc = (message) => messageSet.has(message);
2729
- }
2730
- else {
2731
- filterFunc = messagesOrFilterFunc;
2732
- }
2733
- // sequence numbers of the removed messages, each one as it is after the earlier EXPUNGE responses. From the
2734
- // highest message down, the earlier responses do not change the sequence numbers of the later ones
2735
- const expunged = [];
2736
- const kept = [];
2737
- mailbox.messages.forEach((message, i) => {
2738
- if (filterFunc(message)) {
2739
- message.ghost = true;
2740
- expunged.push({ seq: highestFirst ? i + 1 : kept.length + 1, message });
2741
- }
2742
- else {
2743
- kept.push(message);
2744
- }
2745
- });
2746
- if (!expunged.length) {
2747
- return;
2748
- }
2749
- // old copy is required for those sessions that run FETCH before
2750
- // displaying the EXPUNGE notice
2751
- const mailboxCopy = mailbox.messages.slice();
2752
- // update the list in place, other code might hold a reference to it
2753
- kept.forEach((message, i) => {
2754
- mailbox.messages[i] = message;
2755
- });
2756
- mailbox.messages.length = kept.length;
2757
- // lets plugins track the removal (e.g. mod-sequences of CONDSTORE and QRESYNC) before any notification
2758
- this.server.emit('expunge', mailbox, expunged.map(entry => entry.message), this);
2759
- (highestFirst ? expunged.slice().reverse() : expunged).forEach(entry => {
2760
- this.server.notify({
2761
- tag: '*',
2762
- attributes: [
2763
- entry.seq,
2764
- {
2765
- type: 'ATOM',
2766
- value: 'EXPUNGE'
2767
- }
2768
- ],
2769
- // the removed message, for plugins that report it differently (e.g. VANISHED of QRESYNC)
2770
- message: entry.message
2771
- }, mailbox, ignoreSelf ? this : false);
3004
+ expungeMessages(this.server, mailbox, messagesOrFilterFunc, {
3005
+ origin: this,
3006
+ skip: ignoreSelf ? this : null,
3007
+ skipExists: ignoreExists ? this : null,
3008
+ highestFirst: !!highestFirst
2772
3009
  });
2773
- this.server.notify({
2774
- tag: '*',
2775
- attributes: [
2776
- mailbox.messages.length,
2777
- {
2778
- type: 'ATOM',
2779
- value: 'EXISTS'
2780
- }
2781
- ],
2782
- // distribute the old mailbox data with the notification
2783
- mailboxCopy: mailboxCopy
2784
- }, mailbox, ignoreSelf || ignoreExists ? this : false);
2785
3010
  }
2786
3011
  }
3012
+ // setImmediate does not let Deno send what sockets were given, see nextTurn
3013
+ const IS_DENO = 'Deno' in globalThis;
3014
+ /**
3015
+ * Runs a function on the next event loop turn that also handles network I/O: setImmediate on Node and Bun, a zero
3016
+ * timer on Deno, where setImmediate runs before the sockets send what was written (#89)
3017
+ *
3018
+ * @param {Function} fn Function to run
3019
+ * @return {Object} the timer, clearOutputQueue clears it
3020
+ */
3021
+ function nextTurn(fn) {
3022
+ return IS_DENO ? setTimeout(fn, 0) : setImmediate(fn);
3023
+ }
3024
+ /**
3025
+ * Starts listening, resolves with the port or rejects with the listen error (EADDRINUSE)
3026
+ *
3027
+ * @param {Object} server net, tls or http server
3028
+ * @param {Number} [port] Port, a free one if not set
3029
+ * @param {String} [host] Address, all addresses if not set
3030
+ * @return {Promise<Number>} the port
3031
+ */
3032
+ function listenOn(server, port, host) {
3033
+ return new Promise((resolve, reject) => {
3034
+ server.once('error', reject);
3035
+ server.listen({ port: port || 0, host }, () => {
3036
+ server.removeListener('error', reject);
3037
+ resolve(server.address().port);
3038
+ });
3039
+ });
3040
+ }
2787
3041
  /**
2788
3042
  * Destroys a socket with a TCP RST (`close: 'reset'` of a script rule). resetAndDestroy (Node 16.17) needs a socket with
2789
3043
  * a TCP handle, a TLS socket throws ERR_INVALID_HANDLE_TYPE (#84), so the RST goes to the TCP socket under the TLS