imapkit 5.0.0 → 5.1.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 (74) hide show
  1. package/README.md +51 -29
  2. package/bin/help.txt +3 -2
  3. package/bin/imapkit.js +15 -5
  4. package/dist/browser/imapkit.js +105 -0
  5. package/dist/cjs/commands/handlers/fetch.js +2 -1
  6. package/dist/cjs/commands/handlers/store.js +4 -4
  7. package/dist/cjs/commands/list.js +17 -3
  8. package/dist/cjs/control.d.ts +4 -9
  9. package/dist/cjs/control.js +1 -1
  10. package/dist/cjs/dates.d.ts +9 -1
  11. package/dist/cjs/dates.js +20 -0
  12. package/dist/cjs/envelope.d.ts +1 -1
  13. package/dist/cjs/envelope.js +26 -1
  14. package/dist/cjs/index.d.ts +3 -1
  15. package/dist/cjs/load-plugins.d.ts +3 -1
  16. package/dist/cjs/load-plugins.js +25 -6
  17. package/dist/cjs/memory-socket.d.ts +69 -0
  18. package/dist/cjs/memory-socket.js +187 -0
  19. package/dist/cjs/plugins/binary.js +2 -1
  20. package/dist/cjs/plugins/context-search.js +2 -2
  21. package/dist/cjs/plugins/create-special-use.d.ts +0 -5
  22. package/dist/cjs/plugins/create-special-use.js +11 -1
  23. package/dist/cjs/plugins/idle.js +2 -2
  24. package/dist/cjs/plugins/imap4rev2.js +5 -0
  25. package/dist/cjs/plugins/metadata-server.d.ts +6 -2
  26. package/dist/cjs/plugins/metadata-server.js +3 -1
  27. package/dist/cjs/plugins/metadata.d.ts +5 -1
  28. package/dist/cjs/plugins/metadata.js +3 -0
  29. package/dist/cjs/quirks.d.ts +2 -2
  30. package/dist/cjs/quirks.js +5 -4
  31. package/dist/cjs/rest.d.ts +7 -3
  32. package/dist/cjs/rest.js +11 -5
  33. package/dist/cjs/server.d.ts +67 -3
  34. package/dist/cjs/server.js +241 -93
  35. package/dist/cjs/storage-schema.js +12 -5
  36. package/dist/cjs/store-operations.d.ts +5 -2
  37. package/dist/cjs/store-operations.js +6 -3
  38. package/dist/cjs/types.d.ts +4 -0
  39. package/dist/esm/commands/handlers/fetch.js +2 -1
  40. package/dist/esm/commands/handlers/store.js +4 -4
  41. package/dist/esm/commands/list.js +17 -3
  42. package/dist/esm/control.d.ts +4 -9
  43. package/dist/esm/control.js +1 -1
  44. package/dist/esm/dates.d.ts +9 -1
  45. package/dist/esm/dates.js +20 -1
  46. package/dist/esm/envelope.d.ts +1 -1
  47. package/dist/esm/envelope.js +26 -1
  48. package/dist/esm/index.d.ts +3 -1
  49. package/dist/esm/load-plugins.d.ts +3 -1
  50. package/dist/esm/load-plugins.js +25 -6
  51. package/dist/esm/memory-socket.d.ts +69 -0
  52. package/dist/esm/memory-socket.js +182 -0
  53. package/dist/esm/plugins/binary.js +2 -1
  54. package/dist/esm/plugins/context-search.js +2 -2
  55. package/dist/esm/plugins/create-special-use.d.ts +0 -5
  56. package/dist/esm/plugins/create-special-use.js +8 -1
  57. package/dist/esm/plugins/idle.js +2 -2
  58. package/dist/esm/plugins/imap4rev2.js +5 -0
  59. package/dist/esm/plugins/metadata-server.d.ts +6 -2
  60. package/dist/esm/plugins/metadata-server.js +3 -1
  61. package/dist/esm/plugins/metadata.d.ts +5 -1
  62. package/dist/esm/plugins/metadata.js +3 -0
  63. package/dist/esm/quirks.d.ts +2 -2
  64. package/dist/esm/quirks.js +5 -4
  65. package/dist/esm/rest.d.ts +7 -3
  66. package/dist/esm/rest.js +11 -5
  67. package/dist/esm/server.d.ts +67 -3
  68. package/dist/esm/server.js +243 -95
  69. package/dist/esm/storage-schema.js +12 -5
  70. package/dist/esm/store-operations.d.ts +5 -2
  71. package/dist/esm/store-operations.js +6 -3
  72. package/dist/esm/types.d.ts +4 -0
  73. package/dist/plugin-help.json +3 -2
  74. package/package.json +8 -1
@@ -59,6 +59,7 @@ const quirks_js_1 = require("./quirks.js");
59
59
  const storage_schema_js_1 = require("./storage-schema.js");
60
60
  const rest_js_1 = require("./rest.js");
61
61
  const bundledCert = __importStar(require("./cert.js"));
62
+ const memory_socket_js_1 = require("./memory-socket.js");
62
63
  const script_js_1 = require("./script.js");
63
64
  // longest command line (not counting literals) accepted from a client
64
65
  const MAX_LINE_LENGTH = 1024 * 1024;
@@ -105,6 +106,36 @@ function getResponseTag(line) {
105
106
  function stateError(command, state) {
106
107
  return command + ' is not allowed in the ' + state + ' state';
107
108
  }
109
+ /**
110
+ * Checks if a queued notification changes message sequence numbers by removing messages: an EXPUNGE response, or the
111
+ * EXISTS that follows the EXPUNGE responses of another session (it carries the snapshot of the old message list)
112
+ *
113
+ * @param {Object} notification Queued notification
114
+ * @return {Boolean} true if the notification must wait while EXPUNGE responses are not allowed
115
+ */
116
+ function isPendingExpunge(notification) {
117
+ return !!notification.mailboxCopy || (0, arguments_js_1.isAtom)(notification.attributes && notification.attributes[1], 'EXPUNGE');
118
+ }
119
+ /**
120
+ * Checks if a notification is an untagged EXISTS response
121
+ *
122
+ * @param {Object} [notification] Notification to send
123
+ * @return {Boolean} true for an EXISTS response
124
+ */
125
+ function isExists(notification) {
126
+ return !!notification && !notification.command && (0, arguments_js_1.isAtom)(notification.attributes && notification.attributes[1], 'EXISTS');
127
+ }
128
+ /**
129
+ * Answers AUTHENTICATE with a mechanism no plugin supports: NO, not a syntax error (RFC 3501 section 6.2.2). It runs
130
+ * through the command queue, so the state check comes first (AUTHENTICATE is only valid in the Not Authenticated
131
+ * state, RFC 3501 section 6.2)
132
+ */
133
+ // usesSequenceNumbers() of each running command
134
+ const sequenceNumberUse = new WeakMap();
135
+ const unsupportedMechanism = (connection, parsed, data, callback) => {
136
+ connection.sendStatus(parsed, data, 'NO', 'Unsupported authentication mechanism', false, 'UNKNOWN COMMAND');
137
+ callback();
138
+ };
108
139
  /**
109
140
  * Creates a new IMAP server, call `listen()` on it to start accepting connections
110
141
  *
@@ -204,7 +235,7 @@ class IMAPServer extends node_stream_1.Stream {
204
235
  // plugins register their control operations
205
236
  this.control = new control_js_1.Control(this);
206
237
  // a quirk preset can leave plugins out (no-move, no-uidplus)
207
- (0, load_plugins_js_1.default)(this, this.options.plugins, [...quirks.removePlugins]);
238
+ (0, load_plugins_js_1.default)(this, this.options.plugins, quirks.removePlugins);
208
239
  if (this.options.storage) {
209
240
  // a typo in a fixture fails here with the path of the problem
210
241
  (0, storage_schema_js_1.validateStorage)(this.options.storage);
@@ -333,6 +364,8 @@ class IMAPServer extends node_stream_1.Stream {
333
364
  this.smtpServer = null;
334
365
  }
335
366
  if (this.restServer) {
367
+ // event streams never end on their own, a graceful shutdown would wait for them forever
368
+ this.restServer.endStreams?.();
336
369
  this.restServer.close();
337
370
  // keep-alive connections would hold the server open
338
371
  if (force) {
@@ -368,6 +401,21 @@ class IMAPServer extends node_stream_1.Stream {
368
401
  handler(connection);
369
402
  });
370
403
  }
404
+ /**
405
+ * Opens a connection to the server in memory, without a network socket (no `listen()` needed). This is how the
406
+ * server runs in a browser, see the README "Run in a browser"
407
+ *
408
+ * @param {Object} [options] `{ secure }`: false makes the connection a plaintext one, where STARTTLS turns on the
409
+ * encryption (nothing is encrypted, the session only counts as encrypted). By default the connection counts as
410
+ * one over TLS
411
+ * @return {MemorySocket} the client end, write commands to it and read the responses from its `data` events
412
+ */
413
+ connect(options) {
414
+ const [serverSocket, clientSocket] = (0, memory_socket_js_1.createSocketPair)();
415
+ serverSocket.encrypted = clientSocket.encrypted = !options || options.secure !== false;
416
+ this.createClient(serverSocket);
417
+ return clientSocket;
418
+ }
371
419
  registerCapability(keyword, handler) {
372
420
  this.capabilities[keyword] =
373
421
  handler ||
@@ -511,8 +559,7 @@ class IMAPServer extends node_stream_1.Stream {
511
559
  let seen = 0;
512
560
  let unseen = 0;
513
561
  // flags stay defined in the mailbox once a message had them, see rememberFlags
514
- const permanentFlags = [].concat(mailbox.permanentFlags || []);
515
- (mailbox.knownFlags || []).forEach(flag => this.ensureFlag(permanentFlags, flag));
562
+ const permanentFlags = this.permanentFlagList(mailbox);
516
563
  let recent = 0;
517
564
  // \Recent sets of the sessions that have this mailbox selected
518
565
  const recentSets = [];
@@ -558,20 +605,8 @@ class IMAPServer extends node_stream_1.Stream {
558
605
  * @return {Boolean} Returns true if the date string is in IMAP date-time format
559
606
  */
560
607
  validateInternalDate(date) {
561
- if (!date || typeof date !== 'string') {
562
- return false;
563
- }
564
- // date-time from RFC 3501 section 9, month names are case-insensitive like all ABNF strings
565
- 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);
566
- if (!match) {
567
- return false;
568
- }
569
- // the values must also make a real date and time
570
- return ((0, dates_js_1.isRealDate)(match[1], (0, dates_js_1.monthIndex)(match[2]), match[3]) &&
571
- Number(match[4]) < 24 &&
572
- Number(match[5]) < 60 &&
573
- Number(match[6]) < 61 &&
574
- Number(match[8]) < 60);
608
+ // date-time from RFC 3501 section 9
609
+ return (0, dates_js_1.isDateTime)(date);
575
610
  }
576
611
  /**
577
612
  * Converts a date object to a valid date-time string format
@@ -1002,6 +1037,32 @@ class IMAPServer extends node_stream_1.Stream {
1002
1037
  const knownFlags = (mailbox.knownFlags = mailbox.knownFlags || []);
1003
1038
  flags.forEach(flag => this.ensureFlag(knownFlags, flag));
1004
1039
  }
1040
+ /**
1041
+ * Checks if a flag can be stored on messages of a mailbox: any flag when the mailbox allows new keywords
1042
+ * (PERMANENTFLAGS has `\*`), otherwise only a flag in the PERMANENTFLAGS list that getStatus() builds, the
1043
+ * `permanentFlags` of the mailbox and every flag its messages had. RFC 3501 section 7.1 (RFC 9051 section 7.1):
1044
+ * PERMANENTFLAGS "indicates which of the known flags the client can change permanently", STORE ignores the others
1045
+ *
1046
+ * @param {Object} mailbox Mailbox object
1047
+ * @param {String} flag Normalized flag
1048
+ * @return {Boolean} true if the flag is a permanent flag of the mailbox
1049
+ */
1050
+ isPermanentFlag(mailbox, flag) {
1051
+ // the lists of permanentFlagList(), without building it: this runs for every flag STORE and APPEND set
1052
+ return (mailbox.allowPermanentFlags || (mailbox.permanentFlags || []).indexOf(flag) >= 0 || (!!mailbox.knownFlags && mailbox.knownFlags.indexOf(flag) >= 0));
1053
+ }
1054
+ /**
1055
+ * The flags of the PERMANENTFLAGS list (without `\*`): the `permanentFlags` of the mailbox and every flag its
1056
+ * messages have or had (`knownFlags`, see rememberFlags)
1057
+ *
1058
+ * @param {Object} mailbox Mailbox object
1059
+ * @return {Array} flags, a new list
1060
+ */
1061
+ permanentFlagList(mailbox) {
1062
+ const list = [].concat(mailbox.permanentFlags || []);
1063
+ (mailbox.knownFlags || []).forEach(flag => this.ensureFlag(list, flag));
1064
+ return list;
1065
+ }
1005
1066
  /**
1006
1067
  * The current time for dates the server sets itself (INTERNALDATE of a message without one, SAVEDATE). The `now`
1007
1068
  * option fixes it for repeatable tests: a Date, a timestamp, or a function that returns one
@@ -1073,9 +1134,10 @@ class IMAPServer extends node_stream_1.Stream {
1073
1134
  */
1074
1135
  appendMessage(path, flags, internaldate, raw, ignoreConnection, properties) {
1075
1136
  const mailbox = typeof path === 'string' ? this.getMailbox(path) : path;
1076
- // processMessage() below sets the UID
1137
+ // processMessage() below sets the UID. Flags the mailbox can not store are left out (APPEND, COPY: the flags
1138
+ // SHOULD be set, RFC 3501 sections 6.3.11 and 6.4.7), as STORE ignores them (RFC 3501 section 7.1, PERMANENTFLAGS)
1077
1139
  const message = Object.assign({}, properties, {
1078
- flags: flags,
1140
+ flags: flags.filter(flag => this.isPermanentFlag(mailbox, flag)),
1079
1141
  internaldate: internaldate,
1080
1142
  raw: raw,
1081
1143
  recent: true
@@ -1261,49 +1323,48 @@ class IMAPServer extends node_stream_1.Stream {
1261
1323
  return flags;
1262
1324
  }
1263
1325
  matchFolders(referenceName, match, exportName, folders) {
1264
- let includeINBOX = false;
1265
1326
  const source = folders || this.folderCache;
1266
1327
  const toName = exportName || ((name) => name);
1267
- let reference = referenceName || '';
1268
- if (reference === '' && this.referenceNamespace !== false) {
1269
- reference = toName(this.referenceNamespace);
1270
- includeINBOX = true;
1271
- }
1272
- // the reference does not have to be a namespace, use the namespace it belongs to
1273
- let nsKey = false;
1274
- let nsName = '';
1275
- for (const key of Object.keys(this.storage)) {
1276
- const name = toName(key);
1277
- if (key !== 'INBOX' && reference.substr(0, name.length) === name && (nsKey === false || name.length > nsName.length)) {
1278
- nsKey = key;
1279
- nsName = name;
1280
- }
1281
- }
1282
- if (nsKey === false) {
1283
- return [];
1284
- }
1285
- const namespace = this.storage[nsKey];
1286
- const lookup = reference + match;
1287
- const result = [];
1288
- const pattern = '^' +
1328
+ // RFC 3501 section 6.3.8 (RFC 9051 section 6.3.9): "An empty ("" string) reference name argument
1329
+ // indicates that the mailbox name is interpreted as by SELECT", and without break out characters
1330
+ // "the canonical form is normally the reference name appended with the mailbox name". The pattern
1331
+ // matches full mailbox names, also in a personal namespace with a prefix such as "INBOX.". With the
1332
+ // namespace convention "#" is a break out character "and must be treated as such" (RFC 3501 section 6.3.8,
1333
+ // RFC 9051 section 6.3.9): a pattern that starts with it is a name of its own, the reference is ignored
1334
+ const lookup = match.charAt(0) === '#' ? match : (referenceName || '') + match;
1335
+ // "%" does not match the hierarchy delimiter, which is the one of the namespace a name belongs to
1336
+ const toRegExp = (separator, flags = '') => new RegExp('^' +
1289
1337
  lookup
1290
1338
  // escape regex symbols
1291
1339
  .replace(/([\\^$+?!.():=[\]{}|,-])/g, '\\$1')
1292
1340
  .replace(/[*]/g, '.*')
1293
- .replace(/[%]/g, '[^' + namespace.separator.replace(/([\\^$+*?!.():=[\]{}|,-])/g, '\\$1') + ']*') +
1294
- '$';
1295
- const query = new RegExp(pattern, '');
1296
- // INBOX is case-insensitive
1297
- if (includeINBOX && source.INBOX && ((reference ? reference + namespace.separator : '') + 'INBOX').match(new RegExp(pattern, 'i'))) {
1341
+ .replace(/[%]/g, '[^' + separator.replace(/([\\^$+*?!.():=[\]{}|,-])/g, '\\$1') + ']*') +
1342
+ '$', flags);
1343
+ // RFC 3501 section 6.3.8 allows to "hide" otherwise accessible mailboxes from the wildcards: the
1344
+ // mailboxes of namespaces other than the personal one are only matched when the pattern names
1345
+ // the prefix of the namespace before any wildcard (LIST "" "user.%", LIST "#news." "*"). The pattern
1346
+ // of every namespace that is matched, null for a hidden one
1347
+ const fixedPrefix = lookup.replace(/[*%].*$/, '');
1348
+ const queries = new Map();
1349
+ Object.keys(this.storage).forEach(key => {
1350
+ const name = toName(key);
1351
+ const visible = key === this.referenceNamespace || fixedPrefix.substr(0, name.length) === name;
1352
+ queries.set(key, visible ? toRegExp(this.storage[key].separator) : null);
1353
+ });
1354
+ const result = [];
1355
+ // "The special name INBOX is included in the output from LIST, if [...] the uppercase string "INBOX"
1356
+ // matches the interpreted reference and mailbox name arguments", INBOX is case-insensitive
1357
+ if (source.INBOX && toRegExp((this.storage.INBOX && this.storage.INBOX.separator) || '/', 'i').test('INBOX')) {
1298
1358
  result.push(source.INBOX);
1299
1359
  }
1300
1360
  Object.keys(source).forEach(path => {
1301
1361
  const folder = source[path];
1302
- if (folder.namespace !== nsKey) {
1362
+ const query = path !== 'INBOX' && folder.namespace !== false && folder.namespace !== 'INBOX' ? queries.get(folder.namespace) : null;
1363
+ if (!query) {
1303
1364
  return;
1304
1365
  }
1305
1366
  const name = toName(path);
1306
- if (name.match(query) && (folder.flags.indexOf('\\NonExistent') < 0 || name === match)) {
1367
+ if (query.test(name) && (folder.flags.indexOf('\\NonExistent') < 0 || name === lookup)) {
1307
1368
  result.push(folder);
1308
1369
  }
1309
1370
  });
@@ -1393,7 +1454,8 @@ class IMAPConnection {
1393
1454
  this._outputQueue = null;
1394
1455
  this._outputTimer = null;
1395
1456
  this._deferredOutput = null;
1396
- this.secureConnection = !!this.options.secureConnection;
1457
+ // a TLS socket is encrypted, and so can be an in-memory connection of server.connect()
1458
+ this.secureConnection = !!this.options.secureConnection || socket.encrypted === true;
1397
1459
  this._remainder = '';
1398
1460
  this._command = '';
1399
1461
  this._literalRemaining = 0;
@@ -1411,7 +1473,7 @@ class IMAPConnection {
1411
1473
  this.messageGlobal = false;
1412
1474
  this._commandQueue = [];
1413
1475
  this._processing = false;
1414
- if (this.options.debug) {
1476
+ if (this.options.debug && typeof this.socket.pipe === 'function') {
1415
1477
  this.socket.pipe(process.stdout);
1416
1478
  }
1417
1479
  this.socket.on('data', this.receive.bind(this));
@@ -1650,7 +1712,8 @@ class IMAPConnection {
1650
1712
  this.watchQuiet(quiet);
1651
1713
  }
1652
1714
  }, Math.max(0, next - (Date.now() - last)));
1653
- this._quietTimer.unref();
1715
+ // browsers have no unref
1716
+ this._quietTimer.unref?.();
1654
1717
  }
1655
1718
  /**
1656
1719
  * Handles data from the socket, through the transport layer if there is one
@@ -2161,7 +2224,7 @@ class IMAPConnection {
2161
2224
  * @return {Boolean} true if an EXPUNGE response is pending
2162
2225
  */
2163
2226
  hasPendingExpunge() {
2164
- return this.notificationQueue.some(notification => notification.attributes && (notification.attributes[1] || {}).value === 'EXPUNGE');
2227
+ return this.notificationQueue.some(isPendingExpunge);
2165
2228
  }
2166
2229
  /**
2167
2230
  * Tells the other sessions that have the selected mailbox open about changed flags, they get
@@ -2320,6 +2383,16 @@ class IMAPConnection {
2320
2383
  // section 6.2.1). STARTTLS is refused when input is waiting (the noPipelining command option), this only
2321
2384
  // guards against plugins that upgrade the connection otherwise
2322
2385
  this.discardInput();
2386
+ // STARTTLS runs on a live connection
2387
+ const socket = this.socket;
2388
+ if (typeof socket.startTls === 'function') {
2389
+ // a socket without a TLS layer that can count as encrypted (MemorySocket of server.connect())
2390
+ return socket.startTls(() => {
2391
+ this.secureConnection = true;
2392
+ this.upgrading = false;
2393
+ callback();
2394
+ });
2395
+ }
2323
2396
  const secureContext = node_tls_1.default.createSecureContext(this.server.getCredentials());
2324
2397
  const socketOptions = {
2325
2398
  secureContext: secureContext,
@@ -2330,8 +2403,6 @@ class IMAPConnection {
2330
2403
  cb(null, secureContext);
2331
2404
  }
2332
2405
  };
2333
- // STARTTLS runs on a live connection
2334
- const socket = this.socket;
2335
2406
  // the RST of a script rule goes to the TCP socket under the TLS layer, see resetSocket. Node also has it as the
2336
2407
  // _parent of the TLS socket, Bun does not
2337
2408
  this.tcpSocket = socket;
@@ -2368,22 +2439,52 @@ class IMAPConnection {
2368
2439
  prepareNotifications(queue) {
2369
2440
  return queue;
2370
2441
  }
2371
- processNotifications(data) {
2372
- const options = data && this.server.getCommandOptions(data.command);
2373
- if (options && (options.noExpunge || (options.searchCriteria !== false && this.usesSequenceNumbers(data)))) {
2374
- // EXPUNGE responses are not allowed during FETCH, STORE and SEARCH (RFC 3501 section 7.4.1), during
2375
- // the commands that extensions add to this list (see the noExpunge command option), nor during UID
2376
- // SEARCH with message numbers in the search criteria (RFC 7162 section 3.2.10.2 for VANISHED, EXPUNGE
2377
- // may wait as well, RFC 3501 only allows it during UID commands)
2378
- return;
2379
- }
2442
+ /**
2443
+ * Checks if EXPUNGE responses must wait while a command runs: during FETCH, STORE and SEARCH (RFC 3501 section
2444
+ * 7.4.1), during the commands that extensions add to this list (see the noExpunge command option), and during UID
2445
+ * SEARCH with message numbers in the search criteria (RFC 7162 section 3.2.10.2 for VANISHED, EXPUNGE may wait as
2446
+ * well, RFC 3501 only allows it during UID commands)
2447
+ *
2448
+ * @param {Object} data Parsed command
2449
+ * @return {Boolean} true if the command holds back EXPUNGE responses
2450
+ */
2451
+ holdsExpunge(data) {
2452
+ const options = this.server.getCommandOptions(data.command);
2453
+ return options.noExpunge || (options.searchCriteria !== false && this.usesSequenceNumbers(data));
2454
+ }
2455
+ /**
2456
+ * Sends the queued notifications. During a command that holds back EXPUNGE responses (see holdsExpunge), or with
2457
+ * `beforeCommand` before a command that refers to messages by sequence number, only the notifications queued before
2458
+ * the first pending EXPUNGE go out: new messages (EXISTS) and flag changes. RFC 3501 section 5.2: "A server MUST send
2459
+ * mailbox size updates automatically if a mailbox size change is observed during the processing of a command",
2460
+ * section 7.4.1 forbids only EXPUNGE during FETCH, STORE and SEARCH. What was queued after the EXPUNGE waits with it,
2461
+ * an EXISTS sent before it would describe a list that the client can not know yet
2462
+ *
2463
+ * @param {Object} [data] Parsed command that runs, or null between commands
2464
+ * @param {Boolean} [beforeCommand] true when the command has not run yet, then a command with message sequence
2465
+ * numbers (COPY, MOVE) also waits with the EXPUNGE responses, its numbers refer to the messages before them
2466
+ */
2467
+ processNotifications(data, beforeCommand) {
2380
2468
  if (!this.notificationQueue.length) {
2381
2469
  return;
2382
2470
  }
2383
- const queue = this.prepareNotifications(this.notificationQueue);
2384
- this.notificationQueue = [];
2471
+ let queue = this.notificationQueue;
2385
2472
  // Flag updates use the sequence numbers this session knows: before the EXPUNGE responses of
2386
2473
  // the snapshot are sent, the snapshot, afterwards the current message list
2474
+ const snapshot = queue.find(notification => notification.mailboxCopy);
2475
+ let held = [];
2476
+ if (data && (this.holdsExpunge(data) || (beforeCommand && this.usesSequenceNumbers(data)))) {
2477
+ const first = queue.findIndex(isPendingExpunge);
2478
+ if (first >= 0) {
2479
+ held = queue.slice(first);
2480
+ queue = queue.slice(0, first);
2481
+ }
2482
+ if (!queue.length) {
2483
+ return;
2484
+ }
2485
+ }
2486
+ this.notificationQueue = held;
2487
+ queue = this.prepareNotifications(queue);
2387
2488
  const snapshotIndex = queue.findIndex(notification => notification.mailboxCopy);
2388
2489
  const sequenceMaps = new Map();
2389
2490
  const getSequence = (messages) => {
@@ -2397,16 +2498,58 @@ class IMAPConnection {
2397
2498
  const current = this.selectedMailbox ? this.selectedMailbox.messages : [];
2398
2499
  // a message changed several times is reported once, its FETCH response carries the current flags
2399
2500
  const reported = new Set();
2501
+ // before the snapshot (or with all of it still held back, then the split at the first pending expunge leaves
2502
+ // no snapshot in the queue) the session knows the old list
2503
+ const sessionList = (i) => (snapshot && (snapshotIndex < 0 || i < snapshotIndex) ? snapshot.mailboxCopy : current);
2504
+ // the last EXISTS response of a run of EXISTS responses, and if the run announces new messages
2505
+ let lastExists = -1;
2506
+ let newMessages = false;
2400
2507
  queue.forEach((notification, i) => {
2401
2508
  if (notification.flagUpdate) {
2402
- // i < snapshotIndex only when there is a snapshot
2403
- this.sendFlagUpdate(notification.flagUpdate, getSequence(i < snapshotIndex ? queue[snapshotIndex].mailboxCopy : current), reported);
2509
+ this.sendFlagUpdate(notification.flagUpdate, getSequence(sessionList(i)), reported);
2404
2510
  }
2405
2511
  else {
2406
2512
  this.send(notification);
2407
2513
  }
2514
+ if (isExists(notification)) {
2515
+ lastExists = i;
2516
+ // the EXISTS after the EXPUNGE responses of another session carries the snapshot instead of a message
2517
+ newMessages = newMessages || !!notification.message;
2518
+ }
2519
+ const next = queue[i + 1];
2520
+ if (lastExists < 0 || isExists(next) || (next && next.fetchedMessage)) {
2521
+ // not the end of a run of EXISTS responses (and the FETCH responses NOTIFY sends after them)
2522
+ return;
2523
+ }
2524
+ // RFC 3501 section 7.3.2: the RECENT response "occurs as a result of a SELECT or EXAMINE command, and if the
2525
+ // size of the mailbox changes (e.g., new messages)". One for consecutive EXISTS responses that announce new
2526
+ // messages, with the number of \Recent messages among those the client was told about. After expunges the
2527
+ // EXPUNGE responses report the change (RFC 3501 section 7.4.1). It goes after the FETCH responses that NOTIFY
2528
+ // sends for new messages, RFC 5465 section 5.2: "an unsolicited EXISTS response, followed by an unsolicited
2529
+ // FETCH response [...] The server MAY also send a RECENT response"
2530
+ if (newMessages) {
2531
+ this.sendRecent(Number(queue[lastExists].attributes[0]), getSequence(sessionList(lastExists)));
2532
+ }
2533
+ lastExists = -1;
2534
+ newMessages = false;
2408
2535
  });
2409
2536
  }
2537
+ /**
2538
+ * Sends an untagged RECENT response with the number of \Recent messages among the first `count` messages
2539
+ *
2540
+ * @param {Number} count Number of messages the client was told about
2541
+ * @param {Map} sequence Message to the sequence number this session knows it by
2542
+ */
2543
+ sendRecent(count, sequence) {
2544
+ let recent = 0;
2545
+ (this.recent || new Set()).forEach(message => {
2546
+ const seq = sequence.get(message);
2547
+ if (seq && seq <= count) {
2548
+ recent++;
2549
+ }
2550
+ });
2551
+ this.send({ tag: '*', notification: true, attributes: [recent, { type: 'ATOM', value: 'RECENT' }] }, 'RECENT NOTIFICATION');
2552
+ }
2410
2553
  /**
2411
2554
  * Compile a command object to a response string and write it to socket.
2412
2555
  * If the command object has a skipResponse property, the command is
@@ -2449,10 +2592,11 @@ class IMAPConnection {
2449
2592
  response.tag === parsed.tag &&
2450
2593
  response.command === 'OK' &&
2451
2594
  this.hasPendingExpunge() &&
2452
- this.server.getCommandOptions(parsed.command).noExpunge &&
2595
+ this.holdsExpunge(parsed) &&
2453
2596
  !(Array.isArray(response.attributes) && response.attributes.some(attr => attr && attr.type === 'SECTION'))) {
2454
2597
  // After the output handlers, they might add a response code of their own (MODIFIED of CONDSTORE).
2455
- // FETCH, STORE, SEARCH and the like can not report the EXPUNGE of another session (RFC 3501 section 7.4.1),
2598
+ // FETCH, STORE, SEARCH and the like can not report the EXPUNGE of another session (RFC 3501 section 7.4.1), nor
2599
+ // can UID SEARCH with message numbers in the criteria (see holdsExpunge),
2456
2600
  // EXPUNGEISSUED tells the client to issue NOOP soon (RFC 5530 section 3, RFC 9051 section 7.1)
2457
2601
  response.attributes = [{ type: 'SECTION', section: [{ type: 'ATOM', value: 'EXPUNGEISSUED' }] }].concat(response.attributes || []);
2458
2602
  }
@@ -2596,6 +2740,15 @@ class IMAPConnection {
2596
2740
  * @return {Boolean} true if the command uses message sequence numbers
2597
2741
  */
2598
2742
  usesSequenceNumbers(parsed) {
2743
+ // the notification checks ask several times per command, the answer does not change
2744
+ let cached = sequenceNumberUse.get(parsed);
2745
+ if (cached === undefined) {
2746
+ cached = this.findSequenceNumbers(parsed);
2747
+ sequenceNumberUse.set(parsed, cached);
2748
+ }
2749
+ return cached;
2750
+ }
2751
+ findSequenceNumbers(parsed) {
2599
2752
  const { sequenceSet, searchCriteria } = this.server.getCommandOptions(parsed.command);
2600
2753
  if (sequenceSet !== false) {
2601
2754
  // other forms of sequence sets, like "$" of SEARCHRES (RFC 5182 section 2.3), do not use numbers
@@ -2774,12 +2927,14 @@ class IMAPConnection {
2774
2927
  this.refusePipelined(parsed, data);
2775
2928
  return;
2776
2929
  }
2777
- if (this.server.getCommandHandler(parsed.command)) {
2930
+ // an unknown SASL mechanism is answered by its own handler, after the central checks (state, script rules)
2931
+ const fallback = /^AUTHENTICATE /i.test(parsed.command) && !this.server.getCommandHandler(parsed.command) ? unsupportedMechanism : undefined;
2932
+ if (fallback || this.server.getCommandHandler(parsed.command)) {
2778
2933
  if (this.isAmbiguous(parsed)) {
2779
2934
  this.sendStatus(parsed, data, 'BAD', 'Commands with message sequence numbers must wait for the completion of earlier commands');
2780
2935
  return;
2781
2936
  }
2782
- const element = { parsed, data };
2937
+ const element = { parsed, data, handler: fallback };
2783
2938
  if (scripted) {
2784
2939
  // processQueue runs it once the script rule released the queue
2785
2940
  this._commandQueue.unshift(element);
@@ -2789,19 +2944,6 @@ class IMAPConnection {
2789
2944
  }
2790
2945
  this.processQueue();
2791
2946
  }
2792
- else if (/^AUTHENTICATE /i.test(parsed.command)) {
2793
- // an unsupported mechanism is a NO, not a syntax error (RFC 3501 section 6.2.2)
2794
- this.send({
2795
- tag: parsed.tag,
2796
- command: 'NO',
2797
- attributes: [
2798
- {
2799
- type: 'TEXT',
2800
- value: 'Unsupported authentication mechanism'
2801
- }
2802
- ]
2803
- }, 'UNKNOWN COMMAND', parsed, data);
2804
- }
2805
2947
  else {
2806
2948
  this.send({
2807
2949
  tag: parsed.tag,
@@ -2969,19 +3111,25 @@ class IMAPConnection {
2969
3111
  return next();
2970
3112
  }
2971
3113
  }
2972
- if (command.substr(0, 4) === 'UID ' && this.hasPendingExpunge()) {
2973
- // EXPUNGE responses may be sent during UID commands (RFC 3501 section 7.4.1). The expunges of other sessions
2974
- // are reported first, then the command runs on the current mailbox, where the UIDs of the expunged messages
2975
- // do not exist and are ignored (RFC 3501 section 6.4.8), so the ghost handling of STORE, COPY and MOVE (RFC 2180
2976
- // section 4) only applies to their sequence number forms. Not for UID SEARCH with message numbers in the
2977
- // criteria, processNotifications knows when EXPUNGE must wait
2978
- this.processNotifications(element.parsed);
3114
+ if (this.state === 'Selected' &&
3115
+ (command.startsWith('UID ') || options.noExpunge || options.sequenceSet !== false || options.searchCriteria !== false)) {
3116
+ // A command that refers to messages runs on the message list the client was told about. Only these: CLOSE
3117
+ // must not send pending EXPUNGE responses (RFC 3501 section 6.4.2) and SELECT leaves the old mailbox. RFC 3501 section 5.2:
3118
+ // "A server MUST send mailbox size updates automatically if a mailbox size change is observed during the
3119
+ // processing of a command", so the new messages and flag changes of other sessions are reported first,
3120
+ // before the command resolves its sequence set or search criteria.
3121
+ // EXPUNGE responses may be sent during UID commands (RFC 3501 section 7.4.1), so these report the expunges of
3122
+ // other sessions first as well, then the command runs on the current mailbox, where the UIDs of the expunged
3123
+ // messages do not exist and are ignored (RFC 3501 section 6.4.8). The ghost handling of STORE, COPY and MOVE
3124
+ // (RFC 2180 section 4) only applies to their sequence number forms, these and UID SEARCH with message numbers
3125
+ // in the criteria keep the EXPUNGE responses for later, see processNotifications
3126
+ this.processNotifications(element.parsed, true);
2979
3127
  }
2980
3128
  // changes made while the handler runs are attributed to this session (the `origin` of notifications)
2981
3129
  this.server.withOrigin(this, () => {
2982
3130
  try {
2983
3131
  const inputHandler = this.inputHandler;
2984
- this.server.getCommandHandler(element.parsed.command)(this, element.parsed, element.data, next);
3132
+ (element.handler || this.server.getCommandHandler(element.parsed.command))(this, element.parsed, element.data, next);
2985
3133
  if (this.inputHandler && this.inputHandler !== inputHandler) {
2986
3134
  // the command reads the lines that follow (IDLE, AUTHENTICATE), script rules match them with it
2987
3135
  this.inputCommand = { tag: element.parsed.tag, command: element.parsed.command };
@@ -9,6 +9,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.storageSchema = void 0;
10
10
  exports.validateStorage = validateStorage;
11
11
  const numbers_js_1 = require("./numbers.js");
12
+ const dates_js_1 = require("./dates.js");
12
13
  // keys of plugins, an unknown key that looks like a typo of a known key is refused unless a plugin uses it
13
14
  const PLUGIN_KEYS = [
14
15
  'acl',
@@ -151,11 +152,17 @@ function validateStorage(storage) {
151
152
  if (message.flags !== undefined && typeof message.flags !== 'string' && !isFlagList(message.flags)) {
152
153
  fail(path + '.flags', 'must be a flag or a list of flags');
153
154
  }
154
- if (message.internaldate !== undefined &&
155
- message.internaldate !== false &&
156
- typeof message.internaldate !== 'string' &&
157
- !(message.internaldate instanceof Date)) {
158
- fail(path + '.internaldate', 'must be a date-time string or a Date');
155
+ // FETCH sends the internal date as it is and SORT ARRIVAL reads it, so it must be a date-time of RFC 3501
156
+ // section 9 (month names in any case, they are sent as "Jan", "Feb", ...) or a valid Date. The save date
157
+ // of the SAVEDATE plugin is a date-time too (RFC 8514 section 4.2)
158
+ for (const key of ['internaldate', 'SAVEDATE']) {
159
+ const value = message[key];
160
+ if (value === undefined || value === false || (key === 'SAVEDATE' && value === null)) {
161
+ continue;
162
+ }
163
+ if (value instanceof Date ? isNaN(value.getTime()) : !(0, dates_js_1.isDateTime)(value)) {
164
+ fail(path + '.' + key, 'must be a date-time string like "14-Sep-2013 21:22:28 -0300" or a Date, not ' + JSON.stringify(value));
165
+ }
159
166
  }
160
167
  if (message.recent !== undefined && typeof message.recent !== 'boolean') {
161
168
  fail(path + '.recent', 'must be true or false');
@@ -6,7 +6,10 @@
6
6
  * what is specific to a session: argument checks, the selected mailbox, read-only state and the responses.
7
7
  */
8
8
  import type { IMAPConnection, IMAPServer, Mailbox, Message } from './types.js';
9
- /** the XOAUTH2 session timeout of the default user, and of a user the control API adds without one */
9
+ /**
10
+ * the deprecated XOAUTH2 session timeout of the default user, and of a user the control API adds without one. It is
11
+ * stored and listed but never used, access tokens do not expire
12
+ */
10
13
  declare const DEFAULT_SESSION_TIMEOUT: number;
11
14
  /** An error of a failed store operation or control API call, `code` is a RFC 5530 response code or INVALID */
12
15
  declare class ImapKitError extends Error {
@@ -94,7 +97,7 @@ declare function createMailbox(server: IMAPServer, path: string): Mailbox;
94
97
  *
95
98
  * @param {Object} server IMAP server
96
99
  * @param {String} path Storage name of the mailbox
97
- * @throws {Error} with a RFC 5530 `code` (NONEXISTENT, CANNOT, HASCHILDREN ...)
100
+ * @throws {Error} with a response `code` (NONEXISTENT, CANNOT, HASCHILDREN ...)
98
101
  */
99
102
  declare function deleteMailbox(server: IMAPServer, path: string): void;
100
103
  /**
@@ -22,7 +22,10 @@ exports.unsubscribeMailbox = unsubscribeMailbox;
22
22
  const flags_js_1 = require("./commands/handlers/flags.js");
23
23
  const random_js_1 = require("./random.js");
24
24
  const numbers_js_1 = require("./numbers.js");
25
- /** the XOAUTH2 session timeout of the default user, and of a user the control API adds without one */
25
+ /**
26
+ * the deprecated XOAUTH2 session timeout of the default user, and of a user the control API adds without one. It is
27
+ * stored and listed but never used, access tokens do not expire
28
+ */
26
29
  const DEFAULT_SESSION_TIMEOUT = 3600 * 1000;
27
30
  exports.DEFAULT_SESSION_TIMEOUT = DEFAULT_SESSION_TIMEOUT;
28
31
  /** An error of a failed store operation or control API call, `code` is a RFC 5530 response code or INVALID */
@@ -160,7 +163,7 @@ function checkFlags(server, mailbox, flags, stored) {
160
163
  catch (err) {
161
164
  throw storeError(err.message, 'INVALID');
162
165
  }
163
- if (stored && mailbox.permanentFlags.indexOf(flag) < 0 && !mailbox.allowPermanentFlags) {
166
+ if (stored && !server.isPermanentFlag(mailbox, flag)) {
164
167
  throw storeError('Flag ' + flag + ' is not a permanent flag of ' + mailbox.path, 'INVALID');
165
168
  }
166
169
  if (list.indexOf(flag) < 0) {
@@ -234,7 +237,7 @@ function createMailbox(server, path) {
234
237
  *
235
238
  * @param {Object} server IMAP server
236
239
  * @param {String} path Storage name of the mailbox
237
- * @throws {Error} with a RFC 5530 `code` (NONEXISTENT, CANNOT, HASCHILDREN ...)
240
+ * @throws {Error} with a response `code` (NONEXISTENT, CANNOT, HASCHILDREN ...)
238
241
  */
239
242
  function deleteMailbox(server, path) {
240
243
  const mailbox = server.getMailbox(path);
@@ -212,6 +212,10 @@ export interface UserData {
212
212
  password?: string | undefined;
213
213
  xoauth2?: {
214
214
  accessToken?: string | undefined;
215
+ /**
216
+ * @deprecated kept from hoodiecrow and ignored: access tokens never expire. Change the token with
217
+ * `control.updateUser()` to test a client against an expired one
218
+ */
215
219
  sessionTimeout?: number | undefined;
216
220
  } | undefined;
217
221
  [key: string]: any;
@@ -79,7 +79,8 @@ function getSection(data, query, global) {
79
79
  else if (query.section.length > 1) {
80
80
  throw new Error((key || 'Part number') + ' does not take any arguments');
81
81
  }
82
- // RFC 3501 6.4.5: a part that does not exist is returned as an empty string
82
+ // a part that does not exist is returned as an empty string, like Dovecot does: RFC 3501 and RFC 9051 section
83
+ // 6.4.5 define no error for it, only an empty string for a partial range that starts beyond the end of the text
83
84
  const node = path ? resolveNode(data.tree, path, global) : data.tree;
84
85
  if (!node) {
85
86
  return '';