imapkit 4.3.1 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +187 -67
  2. package/bin/help.txt +20 -5
  3. package/bin/imapkit.js +64 -24
  4. package/dist/cjs/commands/append.js +1 -1
  5. package/dist/cjs/commands/create.js +2 -2
  6. package/dist/cjs/commands/delete.js +2 -14
  7. package/dist/cjs/commands/handlers/fetch.js +2 -1
  8. package/dist/cjs/commands/handlers/flags.d.ts +3 -3
  9. package/dist/cjs/commands/handlers/flags.js +3 -3
  10. package/dist/cjs/commands/handlers/store.js +7 -7
  11. package/dist/cjs/commands/list.js +17 -3
  12. package/dist/cjs/commands/rename.js +6 -46
  13. package/dist/cjs/commands/select.js +1 -0
  14. package/dist/cjs/commands/subscribe.js +7 -8
  15. package/dist/cjs/commands/unsubscribe.js +3 -9
  16. package/dist/cjs/control.d.ts +425 -0
  17. package/dist/cjs/control.js +637 -0
  18. package/dist/cjs/dates.d.ts +9 -1
  19. package/dist/cjs/dates.js +20 -0
  20. package/dist/cjs/envelope.d.ts +1 -1
  21. package/dist/cjs/envelope.js +26 -1
  22. package/dist/cjs/index.d.ts +9 -1
  23. package/dist/cjs/index.js +9 -2
  24. package/dist/cjs/load-plugins.d.ts +3 -1
  25. package/dist/cjs/load-plugins.js +28 -4
  26. package/dist/cjs/plugins/acl.js +109 -42
  27. package/dist/cjs/plugins/binary.js +2 -1
  28. package/dist/cjs/plugins/condstore.js +7 -0
  29. package/dist/cjs/plugins/context-search.js +2 -2
  30. package/dist/cjs/plugins/create-special-use.d.ts +0 -5
  31. package/dist/cjs/plugins/create-special-use.js +11 -1
  32. package/dist/cjs/plugins/imap4rev2.js +5 -0
  33. package/dist/cjs/plugins/index.js +1 -3
  34. package/dist/cjs/plugins/metadata-server.d.ts +6 -2
  35. package/dist/cjs/plugins/metadata-server.js +3 -1
  36. package/dist/cjs/plugins/metadata.d.ts +5 -1
  37. package/dist/cjs/plugins/metadata.js +143 -57
  38. package/dist/cjs/plugins/objectid.js +10 -0
  39. package/dist/cjs/plugins/quota.js +50 -2
  40. package/dist/cjs/plugins/savedate.js +1 -1
  41. package/dist/cjs/plugins/special-use.d.ts +0 -8
  42. package/dist/cjs/plugins/special-use.js +23 -0
  43. package/dist/cjs/quirks.d.ts +28 -0
  44. package/dist/cjs/quirks.js +125 -0
  45. package/dist/cjs/random.d.ts +10 -0
  46. package/dist/cjs/random.js +22 -0
  47. package/dist/cjs/rest.d.ts +54 -0
  48. package/dist/cjs/rest.js +567 -0
  49. package/dist/cjs/script.d.ts +33 -4
  50. package/dist/cjs/script.js +64 -4
  51. package/dist/cjs/server.d.ts +199 -9
  52. package/dist/cjs/server.js +603 -227
  53. package/dist/cjs/smtp-listener.d.ts +3 -2
  54. package/dist/cjs/smtp-listener.js +10 -6
  55. package/dist/cjs/storage-schema.d.ts +139 -0
  56. package/dist/cjs/storage-schema.js +222 -0
  57. package/dist/cjs/store-operations.d.ts +171 -0
  58. package/dist/cjs/store-operations.js +425 -0
  59. package/dist/cjs/types.d.ts +26 -1
  60. package/dist/esm/commands/append.js +1 -1
  61. package/dist/esm/commands/create.js +2 -2
  62. package/dist/esm/commands/delete.js +2 -14
  63. package/dist/esm/commands/handlers/fetch.js +2 -1
  64. package/dist/esm/commands/handlers/flags.d.ts +3 -3
  65. package/dist/esm/commands/handlers/flags.js +3 -3
  66. package/dist/esm/commands/handlers/store.js +7 -7
  67. package/dist/esm/commands/list.js +17 -3
  68. package/dist/esm/commands/rename.js +6 -46
  69. package/dist/esm/commands/select.js +1 -0
  70. package/dist/esm/commands/subscribe.js +7 -8
  71. package/dist/esm/commands/unsubscribe.js +3 -9
  72. package/dist/esm/control.d.ts +425 -0
  73. package/dist/esm/control.js +630 -0
  74. package/dist/esm/dates.d.ts +9 -1
  75. package/dist/esm/dates.js +20 -1
  76. package/dist/esm/envelope.d.ts +1 -1
  77. package/dist/esm/envelope.js +26 -1
  78. package/dist/esm/index.d.ts +9 -1
  79. package/dist/esm/index.js +5 -2
  80. package/dist/esm/load-plugins.d.ts +3 -1
  81. package/dist/esm/load-plugins.js +28 -4
  82. package/dist/esm/plugins/acl.js +109 -42
  83. package/dist/esm/plugins/binary.js +2 -1
  84. package/dist/esm/plugins/condstore.js +7 -0
  85. package/dist/esm/plugins/context-search.js +2 -2
  86. package/dist/esm/plugins/create-special-use.d.ts +0 -5
  87. package/dist/esm/plugins/create-special-use.js +8 -1
  88. package/dist/esm/plugins/imap4rev2.js +5 -0
  89. package/dist/esm/plugins/index.js +1 -3
  90. package/dist/esm/plugins/metadata-server.d.ts +6 -2
  91. package/dist/esm/plugins/metadata-server.js +3 -1
  92. package/dist/esm/plugins/metadata.d.ts +5 -1
  93. package/dist/esm/plugins/metadata.js +143 -57
  94. package/dist/esm/plugins/objectid.js +10 -0
  95. package/dist/esm/plugins/quota.js +50 -2
  96. package/dist/esm/plugins/savedate.js +1 -1
  97. package/dist/esm/plugins/special-use.d.ts +0 -8
  98. package/dist/esm/plugins/special-use.js +23 -0
  99. package/dist/esm/quirks.d.ts +28 -0
  100. package/dist/esm/quirks.js +121 -0
  101. package/dist/esm/random.d.ts +10 -0
  102. package/dist/esm/random.js +19 -0
  103. package/dist/esm/rest.d.ts +54 -0
  104. package/dist/esm/rest.js +559 -0
  105. package/dist/esm/script.d.ts +33 -4
  106. package/dist/esm/script.js +63 -4
  107. package/dist/esm/server.d.ts +199 -9
  108. package/dist/esm/server.js +606 -230
  109. package/dist/esm/smtp-listener.d.ts +3 -2
  110. package/dist/esm/smtp-listener.js +10 -6
  111. package/dist/esm/storage-schema.d.ts +139 -0
  112. package/dist/esm/storage-schema.js +218 -0
  113. package/dist/esm/store-operations.d.ts +171 -0
  114. package/dist/esm/store-operations.js +410 -0
  115. package/dist/esm/types.d.ts +26 -1
  116. package/dist/plugin-help.json +3 -16
  117. package/package.json +11 -3
  118. package/dist/cjs/plugins/xtoybird.d.ts +0 -2
  119. package/dist/cjs/plugins/xtoybird.js +0 -236
  120. package/dist/esm/plugins/xtoybird.d.ts +0 -2
  121. package/dist/esm/plugins/xtoybird.js +0 -218
@@ -7,14 +7,19 @@ import loadPlugins from './load-plugins.js';
7
7
  import { commands as builtinCommands } from './commands/index.js';
8
8
  import { getCommandOptions, commandOptions } from './command-states.js';
9
9
  import validateMailboxName from './mailbox-name.js';
10
- import { MONTHS, monthIndex, isRealDate } from './dates.js';
10
+ import { MONTHS, monthIndex, isDateTime } from './dates.js';
11
11
  import fetchHandlers from './commands/handlers/fetch.js';
12
12
  import { hasSequenceSetKey } from './commands/handlers/search.js';
13
13
  import { isSequenceSet } from './numbers.js';
14
- import { restoreNilAtoms } from './arguments.js';
14
+ import { restoreNilAtoms, isAtom } 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'`
@@ -60,17 +65,35 @@ function stateError(command, state) {
60
65
  return command + ' is not allowed in the ' + state + ' state';
61
66
  }
62
67
  /**
63
- * Creates an error for a failed mailbox operation, with a RFC 5530 response code
68
+ * Checks if a queued notification changes message sequence numbers by removing messages: an EXPUNGE response, or the
69
+ * EXISTS that follows the EXPUNGE responses of another session (it carries the snapshot of the old message list)
64
70
  *
65
- * @param {String} message Error message
66
- * @param {String} code Response code, e.g. "ALREADYEXISTS"
67
- * @return {Error} Error object
71
+ * @param {Object} notification Queued notification
72
+ * @return {Boolean} true if the notification must wait while EXPUNGE responses are not allowed
68
73
  */
69
- function mailboxError(message, code) {
70
- const err = new Error(message);
71
- err.code = code;
72
- return err;
74
+ function isPendingExpunge(notification) {
75
+ return !!notification.mailboxCopy || isAtom(notification.attributes && notification.attributes[1], 'EXPUNGE');
73
76
  }
77
+ /**
78
+ * Checks if a notification is an untagged EXISTS response
79
+ *
80
+ * @param {Object} [notification] Notification to send
81
+ * @return {Boolean} true for an EXISTS response
82
+ */
83
+ function isExists(notification) {
84
+ return !!notification && !notification.command && isAtom(notification.attributes && notification.attributes[1], 'EXISTS');
85
+ }
86
+ /**
87
+ * Answers AUTHENTICATE with a mechanism no plugin supports: NO, not a syntax error (RFC 3501 section 6.2.2). It runs
88
+ * through the command queue, so the state check comes first (AUTHENTICATE is only valid in the Not Authenticated
89
+ * state, RFC 3501 section 6.2)
90
+ */
91
+ // usesSequenceNumbers() of each running command
92
+ const sequenceNumberUse = new WeakMap();
93
+ const unsupportedMechanism = (connection, parsed, data, callback) => {
94
+ connection.sendStatus(parsed, data, 'NO', 'Unsupported authentication mechanism', false, 'UNKNOWN COMMAND');
95
+ callback();
96
+ };
74
97
  /**
75
98
  * Creates a new IMAP server, call `listen()` on it to start accepting connections
76
99
  *
@@ -159,11 +182,33 @@ class IMAPServer extends Stream {
159
182
  // the session whose command is running, see IMAPServer#notify
160
183
  this.activeConnection = null;
161
184
  this.sessionCounter = 0;
162
- // rules that make the server misbehave on purpose, from the script option or server.script.add()
185
+ // rules that make the server misbehave on purpose, from the script option or server.script.add(), and the rules
186
+ // of the quirk presets after them
163
187
  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
188
+ const quirks = resolveQuirks(this.options.quirks);
189
+ if (quirks.rules.length) {
190
+ this.script.add(quirks.rules);
191
+ }
192
+ this.loadUsers();
193
+ // plugins register their control operations
194
+ this.control = new Control(this);
195
+ // a quirk preset can leave plugins out (no-move, no-uidplus)
196
+ loadPlugins(this, this.options.plugins, quirks.removePlugins);
197
+ if (this.options.storage) {
198
+ // a typo in a fixture fails here with the path of the problem
199
+ validateStorage(this.options.storage);
200
+ }
201
+ this.systemFlags = [].concat(this.options.systemFlags || ['\\Answered', '\\Flagged', '\\Draft', '\\Deleted', '\\Seen']);
202
+ this.loadStorage();
203
+ this.smtpServer = null;
204
+ this.restServer = null;
205
+ }
206
+ /**
207
+ * Sets the users from the `users` option, or the default user. The users are deep copied, so that runtime changes
208
+ * never leak into the caller's objects or into other servers built from the same fixture. Without a prototype,
209
+ * user names like "__proto__" or "toString" are plain keys
210
+ */
211
+ loadUsers() {
167
212
  this.users = Object.assign(Object.create(null), this.options.users
168
213
  ? structuredClone(this.options.users)
169
214
  : {
@@ -171,12 +216,16 @@ class IMAPServer extends Stream {
171
216
  password: 'testpass',
172
217
  xoauth2: {
173
218
  accessToken: 'testtoken',
174
- sessionTimeout: 3600 * 1000
219
+ sessionTimeout: DEFAULT_SESSION_TIMEOUT
175
220
  }
176
221
  }
177
222
  });
178
- loadPlugins(this, this.options.plugins);
179
- this.systemFlags = [].concat(this.options.systemFlags || ['\\Answered', '\\Flagged', '\\Draft', '\\Deleted', '\\Seen']);
223
+ }
224
+ /**
225
+ * Builds the mailboxes from the `storage` option, a deep copy of it. Message and mailbox handlers of plugins run
226
+ * on every message and mailbox
227
+ */
228
+ loadStorage() {
180
229
  // indexFolders() below turns the storage option into namespaces and mailboxes in place
181
230
  this.storage = (this.options.storage
182
231
  ? structuredClone(this.options.storage)
@@ -184,6 +233,7 @@ class IMAPServer extends Stream {
184
233
  INBOX: {},
185
234
  '': {}
186
235
  });
236
+ this.referenceNamespace = false;
187
237
  this.uidvalidityCounter = 0; // highest UIDVALIDITY in use, new mailboxes get a higher one
188
238
  // subscribed mailbox names (RFC 3501 section 6.3.6). Names, not mailboxes: a subscription outlives
189
239
  // DELETE and stays with the old name on RENAME (RFC 9051 section 6.3.6), see trackSubscription
@@ -191,10 +241,67 @@ class IMAPServer extends Stream {
191
241
  this.folderCache = Object.create(null);
192
242
  this.indexFolders(true);
193
243
  }
244
+ /**
245
+ * Starts accepting connections
246
+ *
247
+ * @param {Number} [port] Port to listen on, a free port if not set
248
+ * @param {String} [host] Address to listen on, all addresses if not set
249
+ * @return {Promise<Number>} resolves with the port the server listens on
250
+ */
251
+ async start(port, host) {
252
+ const imapPort = await listenOn(this.server, port, host);
253
+ await Promise.all([this.options.smtp ? this.startSmtp() : null, this.options.rest ? this.startRest() : null]);
254
+ return imapPort;
255
+ }
256
+ /**
257
+ * Starts the REST API of the `rest` option, see src/rest.ts
258
+ *
259
+ * @return {Promise<Number>} resolves with the HTTP port
260
+ */
261
+ async startRest() {
262
+ const { port, host, token } = this.options.rest || {};
263
+ this.restServer = createRestServer(this, { host, token });
264
+ return listenOn(this.restServer, port, host || '127.0.0.1');
265
+ }
266
+ /**
267
+ * Starts the SMTP server of the `smtp` option. smtp-server is an optional dependency, loaded only here
268
+ *
269
+ * @return {Promise<Number>} resolves with the SMTP port
270
+ */
271
+ startSmtp() {
272
+ const { port, host } = this.options.smtp || {};
273
+ return this.loadSmtpListener().then(({ startSMTPServer }) => new Promise((resolve, reject) => {
274
+ const smtp = startSMTPServer(port || 0, this, smtpPort => {
275
+ smtp.removeListener('error', reject);
276
+ resolve(smtpPort);
277
+ }, host);
278
+ smtp.once('error', reject);
279
+ this.smtpServer = smtp;
280
+ }));
281
+ }
282
+ /**
283
+ * Loads the SMTP listener, which needs the smtp-server package
284
+ *
285
+ * @return {Promise<Object>} the smtp-listener module
286
+ */
287
+ loadSmtpListener() {
288
+ return import('./smtp-listener.js').catch((err) => {
289
+ throw new Error('The smtp option needs the smtp-server package, install it with: npm install smtp-server (' + err.message + ')');
290
+ });
291
+ }
292
+ /**
293
+ * Stops the server and closes every connection
294
+ *
295
+ * @return {Promise} resolves when the server is closed
296
+ */
297
+ stop() {
298
+ return this.control.shutdown({ graceful: false });
299
+ }
194
300
  listen(...args) {
195
301
  this.server.listen.apply(this.server, args);
196
302
  }
197
303
  close(callback) {
304
+ this.closeListeners(true);
198
305
  this.server.close(callback);
199
306
  // close() only completes once all connections are gone
200
307
  this.connections.forEach((connection) => {
@@ -203,6 +310,31 @@ class IMAPServer extends Stream {
203
310
  }
204
311
  });
205
312
  }
313
+ /**
314
+ * Stops the SMTP and REST listeners of `start()`
315
+ *
316
+ * @param {Boolean} force true closes the open REST connections too, otherwise only the idle ones (a request that
317
+ * is answered, like POST /v1/shutdown, finishes first)
318
+ */
319
+ closeListeners(force) {
320
+ if (this.smtpServer) {
321
+ this.smtpServer.close(() => false);
322
+ this.smtpServer = null;
323
+ }
324
+ if (this.restServer) {
325
+ // event streams never end on their own, a graceful shutdown would wait for them forever
326
+ this.restServer.endStreams?.();
327
+ this.restServer.close();
328
+ // keep-alive connections would hold the server open
329
+ if (force) {
330
+ this.restServer.closeAllConnections?.();
331
+ }
332
+ else {
333
+ this.restServer.closeIdleConnections?.();
334
+ }
335
+ this.restServer = null;
336
+ }
337
+ }
206
338
  /**
207
339
  * Returns TLS key and certificate. Without credentials in the options the
208
340
  * bundled self-signed certificate for localhost is used.
@@ -322,13 +454,31 @@ class IMAPServer extends Stream {
322
454
  *
323
455
  * @param {String} type Kind of change
324
456
  * @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
457
+ * @param {Object} [details] `{ oldPath, mailbox, created }`: the earlier name of a renamed mailbox, the mailbox
458
+ * object that a DELETE removed, the mailboxes a CREATE made (superior levels first)
327
459
  */
328
460
  mailboxChanged(type, path, details) {
329
461
  const event = Object.assign({ type, path, oldPath: null, mailbox: null }, details, { origin: this.activeConnection });
330
462
  this.emit('mailbox', event);
331
463
  }
464
+ /**
465
+ * Runs a change with `origin` as the session that caused it, the `origin` of the notifications and events it
466
+ * makes. Commands run with their session, the control API with null
467
+ *
468
+ * @param {Object|null} origin Session, or null for a change from outside
469
+ * @param {Function} fn Change
470
+ * @return {*} the result of fn
471
+ */
472
+ withOrigin(origin, fn) {
473
+ const previous = this.activeConnection;
474
+ this.activeConnection = origin;
475
+ try {
476
+ return fn();
477
+ }
478
+ finally {
479
+ this.activeConnection = previous;
480
+ }
481
+ }
332
482
  /**
333
483
  * Retrieves the handler of an IMAP command
334
484
  *
@@ -352,8 +502,7 @@ class IMAPServer extends Stream {
352
502
  let seen = 0;
353
503
  let unseen = 0;
354
504
  // flags stay defined in the mailbox once a message had them, see rememberFlags
355
- const permanentFlags = [].concat(mailbox.permanentFlags || []);
356
- (mailbox.knownFlags || []).forEach(flag => this.ensureFlag(permanentFlags, flag));
505
+ const permanentFlags = this.permanentFlagList(mailbox);
357
506
  let recent = 0;
358
507
  // \Recent sets of the sessions that have this mailbox selected
359
508
  const recentSets = [];
@@ -399,20 +548,8 @@ class IMAPServer extends Stream {
399
548
  * @return {Boolean} Returns true if the date string is in IMAP date-time format
400
549
  */
401
550
  validateInternalDate(date) {
402
- if (!date || typeof date !== 'string') {
403
- return false;
404
- }
405
- // date-time from RFC 3501 section 9, month names are case-insensitive like all ABNF strings
406
- const match = date.match(/^( \d|\d\d)-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{4}) (\d{2}):(\d{2}):(\d{2}) [-+](\d{2})(\d{2})$/i);
407
- if (!match) {
408
- return false;
409
- }
410
- // the values must also make a real date and time
411
- return (isRealDate(match[1], monthIndex(match[2]), match[3]) &&
412
- Number(match[4]) < 24 &&
413
- Number(match[5]) < 60 &&
414
- Number(match[6]) < 61 &&
415
- Number(match[8]) < 60);
551
+ // date-time from RFC 3501 section 9
552
+ return isDateTime(date);
416
553
  }
417
554
  /**
418
555
  * Converts a date object to a valid date-time string format
@@ -461,22 +598,22 @@ class IMAPServer extends Stream {
461
598
  */
462
599
  createMailbox(path, defaultMailbox) {
463
600
  if (!path) {
464
- throw mailboxError('Invalid mailbox name', 'CANNOT');
601
+ throw storeError('Invalid mailbox name', 'CANNOT');
465
602
  }
466
603
  // Ensure case insensitive INBOX
467
604
  if (path.toUpperCase() === 'INBOX') {
468
- throw mailboxError('INBOX can not be modified', 'ALREADYEXISTS');
605
+ throw storeError('INBOX can not be modified', 'ALREADYEXISTS');
469
606
  }
470
607
  const { namespace, storage } = this.getPersonalNamespace(path);
471
608
  path = this.stripSeparator(path, storage.separator);
472
609
  if (this.folderCache[path] && this.folderCache[path].flags.indexOf('\\Noselect') < 0) {
473
- throw mailboxError('Mailbox already exists', 'ALREADYEXISTS');
610
+ throw storeError('Mailbox already exists', 'ALREADYEXISTS');
474
611
  }
475
612
  const folderPath = path.substr(namespace.length).split(storage.separator);
476
613
  if (folderPath.some(name => !name)) {
477
614
  // an empty hierarchy level ("foo//bar", "/foo", "foo//"). RFC 5530 section 3 has this very case as the
478
615
  // example of CANNOT, Dovecot refuses it too
479
- throw mailboxError('Mailbox names can not have empty hierarchy levels', 'CANNOT');
616
+ throw storeError('Mailbox names can not have empty hierarchy levels', 'CANNOT');
480
617
  }
481
618
  let parent = storage;
482
619
  let curPath = namespace;
@@ -487,7 +624,7 @@ class IMAPServer extends Stream {
487
624
  curPath += (curPath.length ? storage.separator : '') + folderName;
488
625
  let folder = this.getMailbox(curPath) || false;
489
626
  if (folder && folder.flags && folder.flags.indexOf('\\Noinferiors') >= 0) {
490
- throw mailboxError('Can not create subfolders for ' + folder.path, 'CANNOT');
627
+ throw storeError('Can not create subfolders for ' + folder.path, 'CANNOT');
491
628
  }
492
629
  // a \Noselect placeholder that is created again is replaced with a new mailbox that only keeps
493
630
  // the children, nothing else of a deleted mailbox may come back (RFC 3501 section 6.3.3)
@@ -529,17 +666,17 @@ class IMAPServer extends Stream {
529
666
  deleteMailbox(path, keepContents) {
530
667
  // Ensure case insensitive INBOX
531
668
  if (path.toUpperCase() === 'INBOX') {
532
- throw mailboxError('INBOX can not be modified', 'CANNOT');
669
+ throw storeError('INBOX can not be modified', 'CANNOT');
533
670
  }
534
671
  const { namespace, storage } = this.getPersonalNamespace(path);
535
672
  const mailbox = this.folderCache[this.stripSeparator(path, storage.separator)];
536
673
  if (!mailbox) {
537
- throw mailboxError('Mailbox does not exist', 'NONEXISTENT');
674
+ throw storeError('Mailbox does not exist', 'NONEXISTENT');
538
675
  }
539
676
  if (mailbox.flags.indexOf('\\Noselect') >= 0 && Object.keys(mailbox.folders || {}).length) {
540
677
  // RFC 9051 section 6.3.5: deleting a \Noselect name that has inferior names is an error, the RFC 5530
541
678
  // section 3 HASCHILDREN response code tells the client to delete the children first
542
- throw mailboxError('Mailbox has children, delete them first', 'HASCHILDREN');
679
+ throw storeError('Mailbox has children, delete them first', 'HASCHILDREN');
543
680
  }
544
681
  const levels = mailbox.path.split(storage.separator);
545
682
  const folderName = levels.pop();
@@ -589,7 +726,7 @@ class IMAPServer extends Stream {
589
726
  const prefix = key.length ? key.substr(0, key.length - this.storage[key].separator.length) : key;
590
727
  if (key.length && (path === prefix || path.substr(0, key.length) === key)) {
591
728
  if (path === prefix) {
592
- throw mailboxError('Used mailbox name is a namespace value', 'CANNOT');
729
+ throw storeError('Used mailbox name is a namespace value', 'CANNOT');
593
730
  }
594
731
  namespace = key;
595
732
  }
@@ -599,10 +736,10 @@ class IMAPServer extends Stream {
599
736
  });
600
737
  const storage = this.storage[namespace];
601
738
  if (!storage) {
602
- throw mailboxError('Unknown namespace', 'CANNOT');
739
+ throw storeError('Unknown namespace', 'CANNOT');
603
740
  }
604
741
  if (storage.type !== 'personal') {
605
- throw mailboxError('Permission denied', 'NOPERM');
742
+ throw storeError('Permission denied', 'NOPERM');
606
743
  }
607
744
  return { namespace, storage };
608
745
  }
@@ -843,6 +980,43 @@ class IMAPServer extends Stream {
843
980
  const knownFlags = (mailbox.knownFlags = mailbox.knownFlags || []);
844
981
  flags.forEach(flag => this.ensureFlag(knownFlags, flag));
845
982
  }
983
+ /**
984
+ * Checks if a flag can be stored on messages of a mailbox: any flag when the mailbox allows new keywords
985
+ * (PERMANENTFLAGS has `\*`), otherwise only a flag in the PERMANENTFLAGS list that getStatus() builds, the
986
+ * `permanentFlags` of the mailbox and every flag its messages had. RFC 3501 section 7.1 (RFC 9051 section 7.1):
987
+ * PERMANENTFLAGS "indicates which of the known flags the client can change permanently", STORE ignores the others
988
+ *
989
+ * @param {Object} mailbox Mailbox object
990
+ * @param {String} flag Normalized flag
991
+ * @return {Boolean} true if the flag is a permanent flag of the mailbox
992
+ */
993
+ isPermanentFlag(mailbox, flag) {
994
+ // the lists of permanentFlagList(), without building it: this runs for every flag STORE and APPEND set
995
+ return (mailbox.allowPermanentFlags || (mailbox.permanentFlags || []).indexOf(flag) >= 0 || (!!mailbox.knownFlags && mailbox.knownFlags.indexOf(flag) >= 0));
996
+ }
997
+ /**
998
+ * The flags of the PERMANENTFLAGS list (without `\*`): the `permanentFlags` of the mailbox and every flag its
999
+ * messages have or had (`knownFlags`, see rememberFlags)
1000
+ *
1001
+ * @param {Object} mailbox Mailbox object
1002
+ * @return {Array} flags, a new list
1003
+ */
1004
+ permanentFlagList(mailbox) {
1005
+ const list = [].concat(mailbox.permanentFlags || []);
1006
+ (mailbox.knownFlags || []).forEach(flag => this.ensureFlag(list, flag));
1007
+ return list;
1008
+ }
1009
+ /**
1010
+ * The current time for dates the server sets itself (INTERNALDATE of a message without one, SAVEDATE). The `now`
1011
+ * option fixes it for repeatable tests: a Date, a timestamp, or a function that returns one
1012
+ *
1013
+ * @return {Date} current time
1014
+ */
1015
+ now() {
1016
+ const now = this.options.now;
1017
+ const value = typeof now === 'function' ? now() : now;
1018
+ return value === undefined || value === null ? new Date() : new Date(value);
1019
+ }
846
1020
  normalizeDateTime(value) {
847
1021
  if (value instanceof Date) {
848
1022
  return this.formatInternalDate(value);
@@ -861,7 +1035,7 @@ class IMAPServer extends Stream {
861
1035
  * @param mailbox Mailbox of the message
862
1036
  */
863
1037
  processMessage(message, mailbox) {
864
- message.internaldate = this.normalizeDateTime(message.internaldate || new Date());
1038
+ message.internaldate = this.normalizeDateTime(message.internaldate || this.now());
865
1039
  message.flags = [].concat(message.flags || []);
866
1040
  if (message.flags.indexOf('\\Recent') >= 0) {
867
1041
  // \Recent is not a stored flag, it belongs to the first session that selects the mailbox
@@ -903,9 +1077,10 @@ class IMAPServer extends Stream {
903
1077
  */
904
1078
  appendMessage(path, flags, internaldate, raw, ignoreConnection, properties) {
905
1079
  const mailbox = typeof path === 'string' ? this.getMailbox(path) : path;
906
- // processMessage() below sets the UID
1080
+ // processMessage() below sets the UID. Flags the mailbox can not store are left out (APPEND, COPY: the flags
1081
+ // SHOULD be set, RFC 3501 sections 6.3.11 and 6.4.7), as STORE ignores them (RFC 3501 section 7.1, PERMANENTFLAGS)
907
1082
  const message = Object.assign({}, properties, {
908
- flags: flags,
1083
+ flags: flags.filter(flag => this.isPermanentFlag(mailbox, flag)),
909
1084
  internaldate: internaldate,
910
1085
  raw: raw,
911
1086
  recent: true
@@ -1091,49 +1266,48 @@ class IMAPServer extends Stream {
1091
1266
  return flags;
1092
1267
  }
1093
1268
  matchFolders(referenceName, match, exportName, folders) {
1094
- let includeINBOX = false;
1095
1269
  const source = folders || this.folderCache;
1096
1270
  const toName = exportName || ((name) => name);
1097
- let reference = referenceName || '';
1098
- if (reference === '' && this.referenceNamespace !== false) {
1099
- reference = toName(this.referenceNamespace);
1100
- includeINBOX = true;
1101
- }
1102
- // the reference does not have to be a namespace, use the namespace it belongs to
1103
- let nsKey = false;
1104
- let nsName = '';
1105
- for (const key of Object.keys(this.storage)) {
1106
- const name = toName(key);
1107
- if (key !== 'INBOX' && reference.substr(0, name.length) === name && (nsKey === false || name.length > nsName.length)) {
1108
- nsKey = key;
1109
- nsName = name;
1110
- }
1111
- }
1112
- if (nsKey === false) {
1113
- return [];
1114
- }
1115
- const namespace = this.storage[nsKey];
1116
- const lookup = reference + match;
1117
- const result = [];
1118
- const pattern = '^' +
1271
+ // RFC 3501 section 6.3.8 (RFC 9051 section 6.3.9): "An empty ("" string) reference name argument
1272
+ // indicates that the mailbox name is interpreted as by SELECT", and without break out characters
1273
+ // "the canonical form is normally the reference name appended with the mailbox name". The pattern
1274
+ // matches full mailbox names, also in a personal namespace with a prefix such as "INBOX.". With the
1275
+ // namespace convention "#" is a break out character "and must be treated as such" (RFC 3501 section 6.3.8,
1276
+ // RFC 9051 section 6.3.9): a pattern that starts with it is a name of its own, the reference is ignored
1277
+ const lookup = match.charAt(0) === '#' ? match : (referenceName || '') + match;
1278
+ // "%" does not match the hierarchy delimiter, which is the one of the namespace a name belongs to
1279
+ const toRegExp = (separator, flags = '') => new RegExp('^' +
1119
1280
  lookup
1120
1281
  // escape regex symbols
1121
1282
  .replace(/([\\^$+?!.():=[\]{}|,-])/g, '\\$1')
1122
1283
  .replace(/[*]/g, '.*')
1123
- .replace(/[%]/g, '[^' + namespace.separator.replace(/([\\^$+*?!.():=[\]{}|,-])/g, '\\$1') + ']*') +
1124
- '$';
1125
- const query = new RegExp(pattern, '');
1126
- // INBOX is case-insensitive
1127
- if (includeINBOX && source.INBOX && ((reference ? reference + namespace.separator : '') + 'INBOX').match(new RegExp(pattern, 'i'))) {
1284
+ .replace(/[%]/g, '[^' + separator.replace(/([\\^$+*?!.():=[\]{}|,-])/g, '\\$1') + ']*') +
1285
+ '$', flags);
1286
+ // RFC 3501 section 6.3.8 allows to "hide" otherwise accessible mailboxes from the wildcards: the
1287
+ // mailboxes of namespaces other than the personal one are only matched when the pattern names
1288
+ // the prefix of the namespace before any wildcard (LIST "" "user.%", LIST "#news." "*"). The pattern
1289
+ // of every namespace that is matched, null for a hidden one
1290
+ const fixedPrefix = lookup.replace(/[*%].*$/, '');
1291
+ const queries = new Map();
1292
+ Object.keys(this.storage).forEach(key => {
1293
+ const name = toName(key);
1294
+ const visible = key === this.referenceNamespace || fixedPrefix.substr(0, name.length) === name;
1295
+ queries.set(key, visible ? toRegExp(this.storage[key].separator) : null);
1296
+ });
1297
+ const result = [];
1298
+ // "The special name INBOX is included in the output from LIST, if [...] the uppercase string "INBOX"
1299
+ // matches the interpreted reference and mailbox name arguments", INBOX is case-insensitive
1300
+ if (source.INBOX && toRegExp((this.storage.INBOX && this.storage.INBOX.separator) || '/', 'i').test('INBOX')) {
1128
1301
  result.push(source.INBOX);
1129
1302
  }
1130
1303
  Object.keys(source).forEach(path => {
1131
1304
  const folder = source[path];
1132
- if (folder.namespace !== nsKey) {
1305
+ const query = path !== 'INBOX' && folder.namespace !== false && folder.namespace !== 'INBOX' ? queries.get(folder.namespace) : null;
1306
+ if (!query) {
1133
1307
  return;
1134
1308
  }
1135
1309
  const name = toName(path);
1136
- if (name.match(query) && (folder.flags.indexOf('\\NonExistent') < 0 || name === match)) {
1310
+ if (query.test(name) && (folder.flags.indexOf('\\NonExistent') < 0 || name === lookup)) {
1137
1311
  result.push(folder);
1138
1312
  }
1139
1313
  });
@@ -1251,6 +1425,7 @@ class IMAPConnection {
1251
1425
  this.notificationQueue = [];
1252
1426
  this.server.on('notify', this._notificationCallback);
1253
1427
  this.server.connections.add(this);
1428
+ this.emitSession('open');
1254
1429
  this.scriptOutput('greeting', '* OK ImapKit ready for rumble\r\n', {});
1255
1430
  }
1256
1431
  /**
@@ -1285,7 +1460,7 @@ class IMAPConnection {
1285
1460
  }
1286
1461
  this._outputQueue = this._outputQueue || [];
1287
1462
  this._outputQueue.push(Object.assign({}, operation, { transport: this.transport }));
1288
- if (!this._outputTimer) {
1463
+ if (!this._outputTimer && !this._outputWaiting) {
1289
1464
  this.flushOutput();
1290
1465
  }
1291
1466
  }
@@ -1306,10 +1481,28 @@ class IMAPConnection {
1306
1481
  return;
1307
1482
  }
1308
1483
  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);
1484
+ // the rest waits for chunkDelay, like a delay of its own. Without a delay each piece waits for the next
1485
+ // event loop turn, and Nagle's algorithm must not hold it back to merge it with the next one
1486
+ const piece = operation.data.subarray(0, operation.chunk);
1311
1487
  operation.data = operation.data.subarray(operation.chunk);
1488
+ if (!operation.chunkDelay || operation.chunkDelay === 'tick') {
1489
+ // the next piece waits until this one was handed to the system, and one more event loop turn, so
1490
+ // the pieces leave as separate segments without a wall clock delay (#89)
1491
+ this.socket?.setNoDelay?.(true);
1492
+ this._outputWaiting = true;
1493
+ this.writeLayer(piece, operation.transport || null, () => {
1494
+ if (this._outputWaiting && this._outputQueue === queue) {
1495
+ this._outputTimer = nextTurn(() => {
1496
+ this._outputTimer = null;
1497
+ this._outputWaiting = false;
1498
+ this.flushOutput();
1499
+ });
1500
+ }
1501
+ });
1502
+ return;
1503
+ }
1312
1504
  operation.delay = operation.chunkDelay;
1505
+ this.writeLayer(piece, operation.transport || null);
1313
1506
  continue;
1314
1507
  }
1315
1508
  if (operation.data) {
@@ -1329,8 +1522,10 @@ class IMAPConnection {
1329
1522
  clearOutputQueue() {
1330
1523
  if (this._outputTimer) {
1331
1524
  clearTimeout(this._outputTimer);
1525
+ clearImmediate(this._outputTimer);
1332
1526
  this._outputTimer = null;
1333
1527
  }
1528
+ this._outputWaiting = false;
1334
1529
  this._outputQueue = null;
1335
1530
  this._deferredOutput = null;
1336
1531
  }
@@ -1339,13 +1534,17 @@ class IMAPConnection {
1339
1534
  *
1340
1535
  * @param {Buffer} data Output
1341
1536
  * @param {Object|null} transport Transport layer
1537
+ * @param {Function} [callback] Called once the data was handed to the system (a transport: on the next turn)
1342
1538
  */
1343
- writeLayer(data, transport) {
1539
+ writeLayer(data, transport, callback) {
1344
1540
  if (transport) {
1345
1541
  transport.write(data);
1542
+ if (callback) {
1543
+ setImmediate(callback);
1544
+ }
1346
1545
  }
1347
1546
  else {
1348
- this.writeRaw(data);
1547
+ this.writeRaw(data, callback);
1349
1548
  }
1350
1549
  }
1351
1550
  /**
@@ -1399,17 +1598,70 @@ class IMAPConnection {
1399
1598
  *
1400
1599
  * @param {Buffer} data Data to send
1401
1600
  */
1402
- writeRaw(data) {
1601
+ writeRaw(data, callback) {
1403
1602
  if (this.socket && !this.socket.destroyed) {
1404
- this.socket.write(data);
1603
+ this.socket.write(data, callback);
1604
+ this.touch();
1605
+ }
1606
+ }
1607
+ /**
1608
+ * Notes input or output for the quiet events of script rules (#88)
1609
+ */
1610
+ touch() {
1611
+ if (!this.server.script.watches('quiet')) {
1612
+ // nothing waits for quiet sessions, a rule added later counts from then on
1613
+ this._lastActivity = undefined;
1614
+ return;
1615
+ }
1616
+ this._lastActivity = Date.now();
1617
+ if (!this._quietTimer) {
1618
+ this.watchQuiet();
1405
1619
  }
1406
1620
  }
1621
+ /**
1622
+ * Starts waiting for the next quiet event, if a rule waits for one and the session is not waiting already. When
1623
+ * the time is up, the first rule that matches handles the event (`send`, `close`), the output it sends starts
1624
+ * the next quiet time
1625
+ *
1626
+ * @param {Number} [checked] Quiet time the rules were checked for already, only longer ones are waited for
1627
+ */
1628
+ watchQuiet(checked = 0) {
1629
+ if (this._quietTimer || !this.socket || this._closing) {
1630
+ return;
1631
+ }
1632
+ const last = this._lastActivity || Date.now();
1633
+ this._lastActivity = last;
1634
+ // the time is measured from the last activity, a rule whose time is up already fires right away
1635
+ const next = this.server.script.nextQuiet(checked);
1636
+ if (next === null) {
1637
+ return;
1638
+ }
1639
+ this._quietTimer = setTimeout(() => {
1640
+ this._quietTimer = null;
1641
+ if (!this.isOpen() || this._lastActivity !== last) {
1642
+ // there was activity in between, it started a new wait
1643
+ return this.watchQuiet();
1644
+ }
1645
+ // a timer can fire a millisecond before Date.now() says the time is up
1646
+ const quiet = Math.max(Date.now() - last, next);
1647
+ const found = this.server.script.check(this, 'quiet', { data: '', quiet });
1648
+ if (found) {
1649
+ handleQuiet(this, found.rule, found.context);
1650
+ }
1651
+ if (this._lastActivity === last) {
1652
+ // nothing was sent, wait for the rules with a longer quiet time
1653
+ this.watchQuiet(quiet);
1654
+ }
1655
+ }, Math.max(0, next - (Date.now() - last)));
1656
+ this._quietTimer.unref();
1657
+ }
1407
1658
  /**
1408
1659
  * Handles data from the socket, through the transport layer if there is one
1409
1660
  *
1410
1661
  * @param {Buffer} chunk Received data
1411
1662
  */
1412
1663
  receive(chunk) {
1664
+ this.touch();
1413
1665
  if (this.transport) {
1414
1666
  this.transport.receive(chunk);
1415
1667
  }
@@ -1542,11 +1794,15 @@ class IMAPConnection {
1542
1794
  * notifications that were not sent yet are dropped. Sends nothing, the caller answers the command
1543
1795
  */
1544
1796
  closeMailbox() {
1797
+ const wasSelected = !!this.selectedMailbox;
1545
1798
  this.state = 'Authenticated';
1546
1799
  this.selectedMailbox = false;
1547
1800
  this.readOnly = false;
1548
1801
  this.recent = null;
1549
1802
  this.notificationQueue = [];
1803
+ if (wasSelected) {
1804
+ this.emitSession('unselect');
1805
+ }
1550
1806
  }
1551
1807
  onClose() {
1552
1808
  if (this.socket) {
@@ -1558,8 +1814,14 @@ class IMAPConnection {
1558
1814
  this.transport = null;
1559
1815
  }
1560
1816
  this.clearOutputQueue();
1817
+ if (this._quietTimer) {
1818
+ clearTimeout(this._quietTimer);
1819
+ this._quietTimer = null;
1820
+ }
1561
1821
  this.server.removeListener('notify', this._notificationCallback);
1562
- this.server.connections.delete(this);
1822
+ if (this.server.connections.delete(this)) {
1823
+ this.emitSession('close');
1824
+ }
1563
1825
  }
1564
1826
  onError(err) {
1565
1827
  if (this.options.debug) {
@@ -1902,7 +2164,7 @@ class IMAPConnection {
1902
2164
  * @return {Boolean} true if an EXPUNGE response is pending
1903
2165
  */
1904
2166
  hasPendingExpunge() {
1905
- return this.notificationQueue.some(notification => notification.attributes && (notification.attributes[1] || {}).value === 'EXPUNGE');
2167
+ return this.notificationQueue.some(isPendingExpunge);
1906
2168
  }
1907
2169
  /**
1908
2170
  * Tells the other sessions that have the selected mailbox open about changed flags, they get
@@ -1911,8 +2173,8 @@ class IMAPConnection {
1911
2173
  * @param {Array} messages Messages with changed flags
1912
2174
  */
1913
2175
  notifyFlagChanges(messages) {
1914
- if (messages.length && this.selectedMailbox) {
1915
- this.server.notify({ tag: '*', flagUpdate: messages }, this.selectedMailbox, this);
2176
+ if (this.selectedMailbox) {
2177
+ notifyFlagChanges(this.server, this.selectedMailbox, messages, this);
1916
2178
  }
1917
2179
  }
1918
2180
  /**
@@ -2109,22 +2371,52 @@ class IMAPConnection {
2109
2371
  prepareNotifications(queue) {
2110
2372
  return queue;
2111
2373
  }
2112
- processNotifications(data) {
2113
- const options = data && this.server.getCommandOptions(data.command);
2114
- if (options && (options.noExpunge || (options.searchCriteria !== false && this.usesSequenceNumbers(data)))) {
2115
- // EXPUNGE responses are not allowed during FETCH, STORE and SEARCH (RFC 3501 section 7.4.1), during
2116
- // the commands that extensions add to this list (see the noExpunge command option), nor during UID
2117
- // SEARCH with message numbers in the search criteria (RFC 7162 section 3.2.10.2 for VANISHED, EXPUNGE
2118
- // may wait as well, RFC 3501 only allows it during UID commands)
2119
- return;
2120
- }
2374
+ /**
2375
+ * Checks if EXPUNGE responses must wait while a command runs: during FETCH, STORE and SEARCH (RFC 3501 section
2376
+ * 7.4.1), during the commands that extensions add to this list (see the noExpunge command option), and during UID
2377
+ * SEARCH with message numbers in the search criteria (RFC 7162 section 3.2.10.2 for VANISHED, EXPUNGE may wait as
2378
+ * well, RFC 3501 only allows it during UID commands)
2379
+ *
2380
+ * @param {Object} data Parsed command
2381
+ * @return {Boolean} true if the command holds back EXPUNGE responses
2382
+ */
2383
+ holdsExpunge(data) {
2384
+ const options = this.server.getCommandOptions(data.command);
2385
+ return options.noExpunge || (options.searchCriteria !== false && this.usesSequenceNumbers(data));
2386
+ }
2387
+ /**
2388
+ * Sends the queued notifications. During a command that holds back EXPUNGE responses (see holdsExpunge), or with
2389
+ * `beforeCommand` before a command that refers to messages by sequence number, only the notifications queued before
2390
+ * the first pending EXPUNGE go out: new messages (EXISTS) and flag changes. RFC 3501 section 5.2: "A server MUST send
2391
+ * mailbox size updates automatically if a mailbox size change is observed during the processing of a command",
2392
+ * section 7.4.1 forbids only EXPUNGE during FETCH, STORE and SEARCH. What was queued after the EXPUNGE waits with it,
2393
+ * an EXISTS sent before it would describe a list that the client can not know yet
2394
+ *
2395
+ * @param {Object} [data] Parsed command that runs, or null between commands
2396
+ * @param {Boolean} [beforeCommand] true when the command has not run yet, then a command with message sequence
2397
+ * numbers (COPY, MOVE) also waits with the EXPUNGE responses, its numbers refer to the messages before them
2398
+ */
2399
+ processNotifications(data, beforeCommand) {
2121
2400
  if (!this.notificationQueue.length) {
2122
2401
  return;
2123
2402
  }
2124
- const queue = this.prepareNotifications(this.notificationQueue);
2125
- this.notificationQueue = [];
2403
+ let queue = this.notificationQueue;
2126
2404
  // Flag updates use the sequence numbers this session knows: before the EXPUNGE responses of
2127
2405
  // the snapshot are sent, the snapshot, afterwards the current message list
2406
+ const snapshot = queue.find(notification => notification.mailboxCopy);
2407
+ let held = [];
2408
+ if (data && (this.holdsExpunge(data) || (beforeCommand && this.usesSequenceNumbers(data)))) {
2409
+ const first = queue.findIndex(isPendingExpunge);
2410
+ if (first >= 0) {
2411
+ held = queue.slice(first);
2412
+ queue = queue.slice(0, first);
2413
+ }
2414
+ if (!queue.length) {
2415
+ return;
2416
+ }
2417
+ }
2418
+ this.notificationQueue = held;
2419
+ queue = this.prepareNotifications(queue);
2128
2420
  const snapshotIndex = queue.findIndex(notification => notification.mailboxCopy);
2129
2421
  const sequenceMaps = new Map();
2130
2422
  const getSequence = (messages) => {
@@ -2138,15 +2430,57 @@ class IMAPConnection {
2138
2430
  const current = this.selectedMailbox ? this.selectedMailbox.messages : [];
2139
2431
  // a message changed several times is reported once, its FETCH response carries the current flags
2140
2432
  const reported = new Set();
2433
+ // before the snapshot (or with all of it still held back, then the split at the first pending expunge leaves
2434
+ // no snapshot in the queue) the session knows the old list
2435
+ const sessionList = (i) => (snapshot && (snapshotIndex < 0 || i < snapshotIndex) ? snapshot.mailboxCopy : current);
2436
+ // the last EXISTS response of a run of EXISTS responses, and if the run announces new messages
2437
+ let lastExists = -1;
2438
+ let newMessages = false;
2141
2439
  queue.forEach((notification, i) => {
2142
2440
  if (notification.flagUpdate) {
2143
- // i < snapshotIndex only when there is a snapshot
2144
- this.sendFlagUpdate(notification.flagUpdate, getSequence(i < snapshotIndex ? queue[snapshotIndex].mailboxCopy : current), reported);
2441
+ this.sendFlagUpdate(notification.flagUpdate, getSequence(sessionList(i)), reported);
2145
2442
  }
2146
2443
  else {
2147
2444
  this.send(notification);
2148
2445
  }
2446
+ if (isExists(notification)) {
2447
+ lastExists = i;
2448
+ // the EXISTS after the EXPUNGE responses of another session carries the snapshot instead of a message
2449
+ newMessages = newMessages || !!notification.message;
2450
+ }
2451
+ const next = queue[i + 1];
2452
+ if (lastExists < 0 || isExists(next) || (next && next.fetchedMessage)) {
2453
+ // not the end of a run of EXISTS responses (and the FETCH responses NOTIFY sends after them)
2454
+ return;
2455
+ }
2456
+ // RFC 3501 section 7.3.2: the RECENT response "occurs as a result of a SELECT or EXAMINE command, and if the
2457
+ // size of the mailbox changes (e.g., new messages)". One for consecutive EXISTS responses that announce new
2458
+ // messages, with the number of \Recent messages among those the client was told about. After expunges the
2459
+ // EXPUNGE responses report the change (RFC 3501 section 7.4.1). It goes after the FETCH responses that NOTIFY
2460
+ // sends for new messages, RFC 5465 section 5.2: "an unsolicited EXISTS response, followed by an unsolicited
2461
+ // FETCH response [...] The server MAY also send a RECENT response"
2462
+ if (newMessages) {
2463
+ this.sendRecent(Number(queue[lastExists].attributes[0]), getSequence(sessionList(lastExists)));
2464
+ }
2465
+ lastExists = -1;
2466
+ newMessages = false;
2467
+ });
2468
+ }
2469
+ /**
2470
+ * Sends an untagged RECENT response with the number of \Recent messages among the first `count` messages
2471
+ *
2472
+ * @param {Number} count Number of messages the client was told about
2473
+ * @param {Map} sequence Message to the sequence number this session knows it by
2474
+ */
2475
+ sendRecent(count, sequence) {
2476
+ let recent = 0;
2477
+ (this.recent || new Set()).forEach(message => {
2478
+ const seq = sequence.get(message);
2479
+ if (seq && seq <= count) {
2480
+ recent++;
2481
+ }
2149
2482
  });
2483
+ this.send({ tag: '*', notification: true, attributes: [recent, { type: 'ATOM', value: 'RECENT' }] }, 'RECENT NOTIFICATION');
2150
2484
  }
2151
2485
  /**
2152
2486
  * Compile a command object to a response string and write it to socket.
@@ -2168,7 +2502,7 @@ class IMAPConnection {
2168
2502
  */
2169
2503
  send(response, description, parsed, data, ...extra) {
2170
2504
  // nothing goes out once the connection is closing (RFC 3501 section 7.1.5)
2171
- if (!this.socket || this.socket.destroyed || this._closing) {
2505
+ if (!this.isOpen()) {
2172
2506
  return;
2173
2507
  }
2174
2508
  if (!response.notification && response.tag !== '*') {
@@ -2178,6 +2512,9 @@ class IMAPConnection {
2178
2512
  this.server.outputHandlers.forEach(handler => {
2179
2513
  handler(this, response, description, parsed, data, ...extra);
2180
2514
  });
2515
+ if (this._commandStart && parsed && response.tag === this._commandStart.tag && parsed.tag === response.tag) {
2516
+ this.commandCompleted(String(response.command || '').toUpperCase());
2517
+ }
2181
2518
  // No need to display this response to user
2182
2519
  if (response.skipResponse) {
2183
2520
  return;
@@ -2187,10 +2524,11 @@ class IMAPConnection {
2187
2524
  response.tag === parsed.tag &&
2188
2525
  response.command === 'OK' &&
2189
2526
  this.hasPendingExpunge() &&
2190
- this.server.getCommandOptions(parsed.command).noExpunge &&
2527
+ this.holdsExpunge(parsed) &&
2191
2528
  !(Array.isArray(response.attributes) && response.attributes.some(attr => attr && attr.type === 'SECTION'))) {
2192
2529
  // After the output handlers, they might add a response code of their own (MODIFIED of CONDSTORE).
2193
- // FETCH, STORE, SEARCH and the like can not report the EXPUNGE of another session (RFC 3501 section 7.4.1),
2530
+ // FETCH, STORE, SEARCH and the like can not report the EXPUNGE of another session (RFC 3501 section 7.4.1), nor
2531
+ // can UID SEARCH with message numbers in the criteria (see holdsExpunge),
2194
2532
  // EXPUNGEISSUED tells the client to issue NOOP soon (RFC 5530 section 3, RFC 9051 section 7.1)
2195
2533
  response.attributes = [{ type: 'SECTION', section: [{ type: 'ATOM', value: 'EXPUNGEISSUED' }] }].concat(response.attributes || []);
2196
2534
  }
@@ -2334,6 +2672,15 @@ class IMAPConnection {
2334
2672
  * @return {Boolean} true if the command uses message sequence numbers
2335
2673
  */
2336
2674
  usesSequenceNumbers(parsed) {
2675
+ // the notification checks ask several times per command, the answer does not change
2676
+ let cached = sequenceNumberUse.get(parsed);
2677
+ if (cached === undefined) {
2678
+ cached = this.findSequenceNumbers(parsed);
2679
+ sequenceNumberUse.set(parsed, cached);
2680
+ }
2681
+ return cached;
2682
+ }
2683
+ findSequenceNumbers(parsed) {
2337
2684
  const { sequenceSet, searchCriteria } = this.server.getCommandOptions(parsed.command);
2338
2685
  if (sequenceSet !== false) {
2339
2686
  // other forms of sequence sets, like "$" of SEARCHRES (RFC 5182 section 2.3), do not use numbers
@@ -2512,12 +2859,14 @@ class IMAPConnection {
2512
2859
  this.refusePipelined(parsed, data);
2513
2860
  return;
2514
2861
  }
2515
- if (this.server.getCommandHandler(parsed.command)) {
2862
+ // an unknown SASL mechanism is answered by its own handler, after the central checks (state, script rules)
2863
+ const fallback = /^AUTHENTICATE /i.test(parsed.command) && !this.server.getCommandHandler(parsed.command) ? unsupportedMechanism : undefined;
2864
+ if (fallback || this.server.getCommandHandler(parsed.command)) {
2516
2865
  if (this.isAmbiguous(parsed)) {
2517
2866
  this.sendStatus(parsed, data, 'BAD', 'Commands with message sequence numbers must wait for the completion of earlier commands');
2518
2867
  return;
2519
2868
  }
2520
- const element = { parsed, data };
2869
+ const element = { parsed, data, handler: fallback };
2521
2870
  if (scripted) {
2522
2871
  // processQueue runs it once the script rule released the queue
2523
2872
  this._commandQueue.unshift(element);
@@ -2527,19 +2876,6 @@ class IMAPConnection {
2527
2876
  }
2528
2877
  this.processQueue();
2529
2878
  }
2530
- else if (/^AUTHENTICATE /i.test(parsed.command)) {
2531
- // an unsupported mechanism is a NO, not a syntax error (RFC 3501 section 6.2.2)
2532
- this.send({
2533
- tag: parsed.tag,
2534
- command: 'NO',
2535
- attributes: [
2536
- {
2537
- type: 'TEXT',
2538
- value: 'Unsupported authentication mechanism'
2539
- }
2540
- ]
2541
- }, 'UNKNOWN COMMAND', parsed, data);
2542
- }
2543
2879
  else {
2544
2880
  this.send({
2545
2881
  tag: parsed.tag,
@@ -2553,6 +2889,64 @@ class IMAPConnection {
2553
2889
  }, 'UNKNOWN COMMAND', parsed, data);
2554
2890
  }
2555
2891
  }
2892
+ /**
2893
+ * Emits the `command` event when the tagged response of a command goes out, and the `session` events "login"
2894
+ * (the command authenticated the session) and "logout" (back to Not Authenticated, UNAUTHENTICATE). Every command
2895
+ * that changes these does so before its tagged response. "select" and "unselect" come from SELECT and
2896
+ * closeMailbox(). Tests wait for these instead of polling
2897
+ *
2898
+ * @param {String} status OK, NO or BAD
2899
+ */
2900
+ commandCompleted(status) {
2901
+ const start = this._commandStart;
2902
+ this._commandStart = null;
2903
+ const server = this.server;
2904
+ if (!server.listenerCount('command') && !server.listenerCount('session')) {
2905
+ return;
2906
+ }
2907
+ server.emit('command', { session: this.sessionNumber, tag: start.tag, command: start.command, status, user: this.username || null });
2908
+ const authenticated = (state) => state === 'Authenticated' || state === 'Selected';
2909
+ if (authenticated(start.state) !== authenticated(this.state) && this.state !== 'Logout') {
2910
+ this.emitSession(authenticated(this.state) ? 'login' : 'logout');
2911
+ }
2912
+ }
2913
+ /**
2914
+ * Emits a `session` event, `{ type, session, ...fields }`
2915
+ *
2916
+ * @param {String} type Event type
2917
+ * @param {Object} [fields] More fields of the event
2918
+ */
2919
+ emitSession(type, fields) {
2920
+ if (this.server.listenerCount('session')) {
2921
+ this.server.emit('session', Object.assign({ type, session: this.describe() }, fields));
2922
+ }
2923
+ }
2924
+ /**
2925
+ * Describes the session without credentials or sockets, for the control API and the `session` events
2926
+ *
2927
+ * @return {Object} `{ session, user, state, mailbox, readOnly, enabled, secure, compressed, remoteAddress }`
2928
+ */
2929
+ describe() {
2930
+ return {
2931
+ session: this.sessionNumber,
2932
+ user: this.username || null,
2933
+ state: this.state,
2934
+ mailbox: this.selectedMailbox ? this.selectedMailbox.path : null,
2935
+ readOnly: !!this.selectedMailbox && !!this.readOnly,
2936
+ enabled: Array.isArray(this.enabled) ? this.enabled.slice() : [],
2937
+ secure: !!this.secureConnection,
2938
+ compressed: !!this.transport,
2939
+ remoteAddress: (this.socket && this.socket.remoteAddress) || null
2940
+ };
2941
+ }
2942
+ /**
2943
+ * Checks if output can still be sent: the socket is open and the connection is not closing (BYE, LOGOUT)
2944
+ *
2945
+ * @return {Boolean} true for a live session
2946
+ */
2947
+ isOpen() {
2948
+ return !!this.socket && !this.socket.destroyed && !this._closing;
2949
+ }
2556
2950
  /**
2557
2951
  * Handles a command line with the script rule that matched it, after the rule's delay. With `run` the line
2558
2952
  * goes through the parser and the command handler as usual afterwards
@@ -2593,6 +2987,8 @@ class IMAPConnection {
2593
2987
  const command = element.parsed.command.toUpperCase();
2594
2988
  this._runningCommand = element;
2595
2989
  const options = this.server.getCommandOptions(command);
2990
+ // the session events of the command are worked out when its tagged response goes out, see commandCompleted
2991
+ this._commandStart = { tag: element.parsed.tag, command, state: this.state };
2596
2992
  let done = false;
2597
2993
  const next = () => {
2598
2994
  if (done) {
@@ -2647,61 +3043,66 @@ class IMAPConnection {
2647
3043
  return next();
2648
3044
  }
2649
3045
  }
2650
- if (command.substr(0, 4) === 'UID ' && this.hasPendingExpunge()) {
2651
- // EXPUNGE responses may be sent during UID commands (RFC 3501 section 7.4.1). The expunges of other sessions
2652
- // are reported first, then the command runs on the current mailbox, where the UIDs of the expunged messages
2653
- // do not exist and are ignored (RFC 3501 section 6.4.8), so the ghost handling of STORE, COPY and MOVE (RFC 2180
2654
- // section 4) only applies to their sequence number forms. Not for UID SEARCH with message numbers in the
2655
- // criteria, processNotifications knows when EXPUNGE must wait
2656
- this.processNotifications(element.parsed);
2657
- }
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 };
3046
+ if (this.state === 'Selected' &&
3047
+ (command.startsWith('UID ') || options.noExpunge || options.sequenceSet !== false || options.searchCriteria !== false)) {
3048
+ // A command that refers to messages runs on the message list the client was told about. Only these: CLOSE
3049
+ // must not send pending EXPUNGE responses (RFC 3501 section 6.4.2) and SELECT leaves the old mailbox. RFC 3501 section 5.2:
3050
+ // "A server MUST send mailbox size updates automatically if a mailbox size change is observed during the
3051
+ // processing of a command", so the new messages and flag changes of other sessions are reported first,
3052
+ // before the command resolves its sequence set or search criteria.
3053
+ // EXPUNGE responses may be sent during UID commands (RFC 3501 section 7.4.1), so these report the expunges of
3054
+ // other sessions first as well, then the command runs on the current mailbox, where the UIDs of the expunged
3055
+ // messages do not exist and are ignored (RFC 3501 section 6.4.8). The ghost handling of STORE, COPY and MOVE
3056
+ // (RFC 2180 section 4) only applies to their sequence number forms, these and UID SEARCH with message numbers
3057
+ // in the criteria keep the EXPUNGE responses for later, see processNotifications
3058
+ this.processNotifications(element.parsed, true);
3059
+ }
3060
+ // changes made while the handler runs are attributed to this session (the `origin` of notifications)
3061
+ this.server.withOrigin(this, () => {
3062
+ try {
3063
+ const inputHandler = this.inputHandler;
3064
+ (element.handler || this.server.getCommandHandler(element.parsed.command))(this, element.parsed, element.data, next);
3065
+ if (this.inputHandler && this.inputHandler !== inputHandler) {
3066
+ // the command reads the lines that follow (IDLE, AUTHENTICATE), script rules match them with it
3067
+ this.inputCommand = { tag: element.parsed.tag, command: element.parsed.command };
3068
+ this.emitSession('waiting', { command });
3069
+ }
2666
3070
  }
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);
3071
+ catch (E) {
3072
+ const ex = E;
3073
+ const badInput = ex.imapResponse === 'BAD';
3074
+ if (!badInput && this.options.debug) {
3075
+ console.error('Error processing command:', ex, '\n', ex.stack);
3076
+ }
3077
+ this.send({
3078
+ tag: element.parsed.tag,
3079
+ command: badInput ? 'BAD' : 'NO',
3080
+ attributes: [].concat(badInput
3081
+ ? []
3082
+ : {
3083
+ type: 'SECTION',
3084
+ section: [
3085
+ {
3086
+ type: 'ATOM',
3087
+ value: 'SERVERBUG'
3088
+ }
3089
+ ]
3090
+ }, {
3091
+ type: 'TEXT',
3092
+ value: badInput ? ex.message : 'Server error: ' + ex.message
3093
+ })
3094
+ }, badInput ? 'INVALID COMMAND' : 'SERVER ERROR', element.parsed, element.data);
3095
+ // keep the connection usable, otherwise every later command would hang
3096
+ next();
2673
3097
  }
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
- }
3098
+ });
2698
3099
  }
2699
3100
  /**
2700
3101
  * Removes messages with \Deleted flag
2701
3102
  *
2702
3103
  * @param {Object} mailbox Mailbox to check for
2703
3104
  * @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
3105
+ * @param {Boolean} [ignoreExists] If set to true, does not send EXISTS notice to itself
2705
3106
  */
2706
3107
  expungeDeleted(mailbox, ignoreSelf, ignoreExists) {
2707
3108
  this.expungeSpecificMessages(mailbox, (message) => {
@@ -2710,80 +3111,55 @@ class IMAPConnection {
2710
3111
  }
2711
3112
  /**
2712
3113
  * Given a set of messages in a mailbox (possibly via getMessageRange), remove
2713
- * them from the mailbox and generate EXPUNGE notifications.
3114
+ * them from the mailbox and generate EXPUNGE notifications, see expungeMessages() in store-operations.ts
2714
3115
  *
2715
3116
  * @param {Object} mailbox Mailbox to check for
2716
3117
  * @param {Function|Array} messagesOrFilterFunc An Array of messages in the
2717
3118
  * folder that should be removed or a filtering function that indicates
2718
3119
  * messages to be removed by returning true.
2719
3120
  * @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
3121
+ * @param {Boolean} [ignoreExists] If set to true, does not send EXISTS notice to itself
2721
3122
  * @param {Boolean} [highestFirst] If set to true, the EXPUNGE responses go from the highest UID to the lowest
2722
3123
  * (MESSAGELIMIT, RFC 9738 section 3.1), otherwise from the lowest
2723
3124
  */
2724
3125
  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);
3126
+ expungeMessages(this.server, mailbox, messagesOrFilterFunc, {
3127
+ origin: this,
3128
+ skip: ignoreSelf ? this : null,
3129
+ skipExists: ignoreExists ? this : null,
3130
+ highestFirst: !!highestFirst
2772
3131
  });
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
3132
  }
2786
3133
  }
3134
+ // setImmediate does not let Deno send what sockets were given, see nextTurn
3135
+ const IS_DENO = 'Deno' in globalThis;
3136
+ /**
3137
+ * Runs a function on the next event loop turn that also handles network I/O: setImmediate on Node and Bun, a zero
3138
+ * timer on Deno, where setImmediate runs before the sockets send what was written (#89)
3139
+ *
3140
+ * @param {Function} fn Function to run
3141
+ * @return {Object} the timer, clearOutputQueue clears it
3142
+ */
3143
+ function nextTurn(fn) {
3144
+ return IS_DENO ? setTimeout(fn, 0) : setImmediate(fn);
3145
+ }
3146
+ /**
3147
+ * Starts listening, resolves with the port or rejects with the listen error (EADDRINUSE)
3148
+ *
3149
+ * @param {Object} server net, tls or http server
3150
+ * @param {Number} [port] Port, a free one if not set
3151
+ * @param {String} [host] Address, all addresses if not set
3152
+ * @return {Promise<Number>} the port
3153
+ */
3154
+ function listenOn(server, port, host) {
3155
+ return new Promise((resolve, reject) => {
3156
+ server.once('error', reject);
3157
+ server.listen({ port: port || 0, host }, () => {
3158
+ server.removeListener('error', reject);
3159
+ resolve(server.address().port);
3160
+ });
3161
+ });
3162
+ }
2787
3163
  /**
2788
3164
  * Destroys a socket with a TCP RST (`close: 'reset'` of a script rule). resetAndDestroy (Node 16.17) needs a socket with
2789
3165
  * a TCP handle, a TLS socket throws ERR_INVALID_HANDLE_TYPE (#84), so the RST goes to the TCP socket under the TLS