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
@@ -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();
1405
1560
  }
1406
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();
1574
+ }
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
  }
@@ -1449,16 +1656,10 @@ class IMAPConnection {
1449
1656
  socket.pause();
1450
1657
  // a RST sent in the same tick as the last output can get lost: on macOS the client received the output but
1451
1658
  // never the RST (#81). Node has no signal for output that reached the peer, so the RST waits a little
1659
+ const tcpSocket = this.tcpSocket;
1452
1660
  setTimeout(() => {
1453
- if (socket.destroyed) {
1454
- return;
1455
- }
1456
- // resetAndDestroy sends a TCP RST (Node 16.17), not every runtime has it
1457
- if (typeof socket.resetAndDestroy === 'function') {
1458
- socket.resetAndDestroy();
1459
- }
1460
- else {
1461
- socket.destroy();
1661
+ if (!socket.destroyed) {
1662
+ resetSocket(socket, tcpSocket);
1462
1663
  }
1463
1664
  }, RESET_DELAY);
1464
1665
  }
@@ -1548,11 +1749,15 @@ class IMAPConnection {
1548
1749
  * notifications that were not sent yet are dropped. Sends nothing, the caller answers the command
1549
1750
  */
1550
1751
  closeMailbox() {
1752
+ const wasSelected = !!this.selectedMailbox;
1551
1753
  this.state = 'Authenticated';
1552
1754
  this.selectedMailbox = false;
1553
1755
  this.readOnly = false;
1554
1756
  this.recent = null;
1555
1757
  this.notificationQueue = [];
1758
+ if (wasSelected) {
1759
+ this.emitSession('unselect');
1760
+ }
1556
1761
  }
1557
1762
  onClose() {
1558
1763
  if (this.socket) {
@@ -1564,8 +1769,14 @@ class IMAPConnection {
1564
1769
  this.transport = null;
1565
1770
  }
1566
1771
  this.clearOutputQueue();
1772
+ if (this._quietTimer) {
1773
+ clearTimeout(this._quietTimer);
1774
+ this._quietTimer = null;
1775
+ }
1567
1776
  this.server.removeListener('notify', this._notificationCallback);
1568
- this.server.connections.delete(this);
1777
+ if (this.server.connections.delete(this)) {
1778
+ this.emitSession('close');
1779
+ }
1569
1780
  }
1570
1781
  onError(err) {
1571
1782
  if (this.options.debug) {
@@ -1917,8 +2128,8 @@ class IMAPConnection {
1917
2128
  * @param {Array} messages Messages with changed flags
1918
2129
  */
1919
2130
  notifyFlagChanges(messages) {
1920
- if (messages.length && this.selectedMailbox) {
1921
- this.server.notify({ tag: '*', flagUpdate: messages }, this.selectedMailbox, this);
2131
+ if (this.selectedMailbox) {
2132
+ notifyFlagChanges(this.server, this.selectedMailbox, messages, this);
1922
2133
  }
1923
2134
  }
1924
2135
  /**
@@ -2079,6 +2290,9 @@ class IMAPConnection {
2079
2290
  };
2080
2291
  // STARTTLS runs on a live connection
2081
2292
  const socket = this.socket;
2293
+ // the RST of a script rule goes to the TCP socket under the TLS layer, see resetSocket. Node also has it as the
2294
+ // _parent of the TLS socket, Bun does not
2295
+ this.tcpSocket = socket;
2082
2296
  // remove all listeners from the original socket besides the error handler
2083
2297
  socket.removeAllListeners();
2084
2298
  socket.on('error', this.onError.bind(this));
@@ -2171,7 +2385,7 @@ class IMAPConnection {
2171
2385
  */
2172
2386
  send(response, description, parsed, data, ...extra) {
2173
2387
  // nothing goes out once the connection is closing (RFC 3501 section 7.1.5)
2174
- if (!this.socket || this.socket.destroyed || this._closing) {
2388
+ if (!this.isOpen()) {
2175
2389
  return;
2176
2390
  }
2177
2391
  if (!response.notification && response.tag !== '*') {
@@ -2181,6 +2395,9 @@ class IMAPConnection {
2181
2395
  this.server.outputHandlers.forEach(handler => {
2182
2396
  handler(this, response, description, parsed, data, ...extra);
2183
2397
  });
2398
+ if (this._commandStart && parsed && response.tag === this._commandStart.tag && parsed.tag === response.tag) {
2399
+ this.commandCompleted(String(response.command || '').toUpperCase());
2400
+ }
2184
2401
  // No need to display this response to user
2185
2402
  if (response.skipResponse) {
2186
2403
  return;
@@ -2556,6 +2773,64 @@ class IMAPConnection {
2556
2773
  }, 'UNKNOWN COMMAND', parsed, data);
2557
2774
  }
2558
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
+ }
2559
2834
  /**
2560
2835
  * Handles a command line with the script rule that matched it, after the rule's delay. With `run` the line
2561
2836
  * goes through the parser and the command handler as usual afterwards
@@ -2596,6 +2871,8 @@ class IMAPConnection {
2596
2871
  const command = element.parsed.command.toUpperCase();
2597
2872
  this._runningCommand = element;
2598
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 };
2599
2876
  let done = false;
2600
2877
  const next = () => {
2601
2878
  if (done) {
@@ -2658,53 +2935,52 @@ class IMAPConnection {
2658
2935
  // criteria, processNotifications knows when EXPUNGE must wait
2659
2936
  this.processNotifications(element.parsed);
2660
2937
  }
2661
- try {
2662
- // changes made while the handler runs are attributed to this session (the `origin` of notifications)
2663
- this.server.activeConnection = this;
2664
- const inputHandler = this.inputHandler;
2665
- this.server.getCommandHandler(element.parsed.command)(this, element.parsed, element.data, next);
2666
- if (this.inputHandler && this.inputHandler !== inputHandler) {
2667
- // the command reads the lines that follow (IDLE, AUTHENTICATE), script rules match them with it
2668
- 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
+ }
2669
2948
  }
2670
- }
2671
- catch (E) {
2672
- const ex = E;
2673
- const badInput = ex.imapResponse === 'BAD';
2674
- if (!badInput && this.options.debug) {
2675
- 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();
2676
2975
  }
2677
- this.send({
2678
- tag: element.parsed.tag,
2679
- command: badInput ? 'BAD' : 'NO',
2680
- attributes: [].concat(badInput
2681
- ? []
2682
- : {
2683
- type: 'SECTION',
2684
- section: [
2685
- {
2686
- type: 'ATOM',
2687
- value: 'SERVERBUG'
2688
- }
2689
- ]
2690
- }, {
2691
- type: 'TEXT',
2692
- value: badInput ? ex.message : 'Server error: ' + ex.message
2693
- })
2694
- }, badInput ? 'INVALID COMMAND' : 'SERVER ERROR', element.parsed, element.data);
2695
- // keep the connection usable, otherwise every later command would hang
2696
- next();
2697
- }
2698
- finally {
2699
- this.server.activeConnection = null;
2700
- }
2976
+ });
2701
2977
  }
2702
2978
  /**
2703
2979
  * Removes messages with \Deleted flag
2704
2980
  *
2705
2981
  * @param {Object} mailbox Mailbox to check for
2706
2982
  * @param {Boolean} [ignoreSelf] If set to true, does not send any notices to itself
2707
- * @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
2708
2984
  */
2709
2985
  expungeDeleted(mailbox, ignoreSelf, ignoreExists) {
2710
2986
  this.expungeSpecificMessages(mailbox, (message) => {
@@ -2713,79 +2989,80 @@ class IMAPConnection {
2713
2989
  }
2714
2990
  /**
2715
2991
  * Given a set of messages in a mailbox (possibly via getMessageRange), remove
2716
- * them from the mailbox and generate EXPUNGE notifications.
2992
+ * them from the mailbox and generate EXPUNGE notifications, see expungeMessages() in store-operations.ts
2717
2993
  *
2718
2994
  * @param {Object} mailbox Mailbox to check for
2719
2995
  * @param {Function|Array} messagesOrFilterFunc An Array of messages in the
2720
2996
  * folder that should be removed or a filtering function that indicates
2721
2997
  * messages to be removed by returning true.
2722
2998
  * @param {Boolean} [ignoreSelf] If set to true, does not send any notices to itself
2723
- * @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
2724
3000
  * @param {Boolean} [highestFirst] If set to true, the EXPUNGE responses go from the highest UID to the lowest
2725
3001
  * (MESSAGELIMIT, RFC 9738 section 3.1), otherwise from the lowest
2726
3002
  */
2727
3003
  expungeSpecificMessages(mailbox, messagesOrFilterFunc, ignoreSelf, ignoreExists, highestFirst) {
2728
- let filterFunc;
2729
- if (Array.isArray(messagesOrFilterFunc)) {
2730
- const messageSet = new Set(messagesOrFilterFunc);
2731
- filterFunc = (message) => messageSet.has(message);
2732
- }
2733
- else {
2734
- filterFunc = messagesOrFilterFunc;
2735
- }
2736
- // sequence numbers of the removed messages, each one as it is after the earlier EXPUNGE responses. From the
2737
- // highest message down, the earlier responses do not change the sequence numbers of the later ones
2738
- const expunged = [];
2739
- const kept = [];
2740
- mailbox.messages.forEach((message, i) => {
2741
- if (filterFunc(message)) {
2742
- message.ghost = true;
2743
- expunged.push({ seq: highestFirst ? i + 1 : kept.length + 1, message });
3004
+ expungeMessages(this.server, mailbox, messagesOrFilterFunc, {
3005
+ origin: this,
3006
+ skip: ignoreSelf ? this : null,
3007
+ skipExists: ignoreExists ? this : null,
3008
+ highestFirst: !!highestFirst
3009
+ });
3010
+ }
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
+ }
3041
+ /**
3042
+ * Destroys a socket with a TCP RST (`close: 'reset'` of a script rule). resetAndDestroy (Node 16.17) needs a socket with
3043
+ * a TCP handle, a TLS socket throws ERR_INVALID_HANDLE_TYPE (#84), so the RST goes to the TCP socket under the TLS
3044
+ * layer: the plain socket of STARTTLS, or the parent socket of implicit TLS (Node). A runtime without resetAndDestroy,
3045
+ * or a socket that can not be reset, is destroyed without a RST
3046
+ *
3047
+ * @param {Object} socket The socket of the connection
3048
+ * @param {Object} [tcpSocket] The plain socket that STARTTLS wrapped
3049
+ */
3050
+ function resetSocket(socket, tcpSocket) {
3051
+ const parent = socket._parent;
3052
+ const candidates = [tcpSocket, parent instanceof net.Socket ? parent : null, socket];
3053
+ for (const candidate of candidates) {
3054
+ if (candidate && !candidate.destroyed && typeof candidate.resetAndDestroy === 'function') {
3055
+ try {
3056
+ candidate.resetAndDestroy();
3057
+ break;
2744
3058
  }
2745
- else {
2746
- kept.push(message);
3059
+ catch {
3060
+ // not a TCP handle, try the next one
2747
3061
  }
2748
- });
2749
- if (!expunged.length) {
2750
- return;
2751
3062
  }
2752
- // old copy is required for those sessions that run FETCH before
2753
- // displaying the EXPUNGE notice
2754
- const mailboxCopy = mailbox.messages.slice();
2755
- // update the list in place, other code might hold a reference to it
2756
- kept.forEach((message, i) => {
2757
- mailbox.messages[i] = message;
2758
- });
2759
- mailbox.messages.length = kept.length;
2760
- // lets plugins track the removal (e.g. mod-sequences of CONDSTORE and QRESYNC) before any notification
2761
- this.server.emit('expunge', mailbox, expunged.map(entry => entry.message), this);
2762
- (highestFirst ? expunged.slice().reverse() : expunged).forEach(entry => {
2763
- this.server.notify({
2764
- tag: '*',
2765
- attributes: [
2766
- entry.seq,
2767
- {
2768
- type: 'ATOM',
2769
- value: 'EXPUNGE'
2770
- }
2771
- ],
2772
- // the removed message, for plugins that report it differently (e.g. VANISHED of QRESYNC)
2773
- message: entry.message
2774
- }, mailbox, ignoreSelf ? this : false);
2775
- });
2776
- this.server.notify({
2777
- tag: '*',
2778
- attributes: [
2779
- mailbox.messages.length,
2780
- {
2781
- type: 'ATOM',
2782
- value: 'EXISTS'
2783
- }
2784
- ],
2785
- // distribute the old mailbox data with the notification
2786
- mailboxCopy: mailboxCopy
2787
- }, mailbox, ignoreSelf || ignoreExists ? this : false);
2788
3063
  }
3064
+ // the TLS socket closes with the TCP socket under it, and emits close for the connection
3065
+ socket.destroy();
2789
3066
  }
2790
3067
  /**
2791
3068
  * Copies a response tree for the `mutate` action of a script rule: arrays and plain objects are copied,