imapkit 5.0.0 → 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 (67) hide show
  1. package/README.md +33 -29
  2. package/bin/help.txt +3 -2
  3. package/bin/imapkit.js +15 -5
  4. package/dist/cjs/commands/handlers/fetch.js +2 -1
  5. package/dist/cjs/commands/handlers/store.js +4 -4
  6. package/dist/cjs/commands/list.js +17 -3
  7. package/dist/cjs/control.d.ts +4 -9
  8. package/dist/cjs/control.js +1 -1
  9. package/dist/cjs/dates.d.ts +9 -1
  10. package/dist/cjs/dates.js +20 -0
  11. package/dist/cjs/envelope.d.ts +1 -1
  12. package/dist/cjs/envelope.js +26 -1
  13. package/dist/cjs/index.d.ts +2 -1
  14. package/dist/cjs/load-plugins.d.ts +3 -1
  15. package/dist/cjs/load-plugins.js +25 -6
  16. package/dist/cjs/plugins/binary.js +2 -1
  17. package/dist/cjs/plugins/context-search.js +2 -2
  18. package/dist/cjs/plugins/create-special-use.d.ts +0 -5
  19. package/dist/cjs/plugins/create-special-use.js +11 -1
  20. package/dist/cjs/plugins/imap4rev2.js +5 -0
  21. package/dist/cjs/plugins/metadata-server.d.ts +6 -2
  22. package/dist/cjs/plugins/metadata-server.js +3 -1
  23. package/dist/cjs/plugins/metadata.d.ts +5 -1
  24. package/dist/cjs/plugins/metadata.js +3 -0
  25. package/dist/cjs/quirks.d.ts +2 -2
  26. package/dist/cjs/quirks.js +5 -4
  27. package/dist/cjs/rest.d.ts +7 -3
  28. package/dist/cjs/rest.js +11 -5
  29. package/dist/cjs/server.d.ts +54 -3
  30. package/dist/cjs/server.js +210 -88
  31. package/dist/cjs/storage-schema.js +12 -5
  32. package/dist/cjs/store-operations.d.ts +5 -2
  33. package/dist/cjs/store-operations.js +6 -3
  34. package/dist/cjs/types.d.ts +4 -0
  35. package/dist/esm/commands/handlers/fetch.js +2 -1
  36. package/dist/esm/commands/handlers/store.js +4 -4
  37. package/dist/esm/commands/list.js +17 -3
  38. package/dist/esm/control.d.ts +4 -9
  39. package/dist/esm/control.js +1 -1
  40. package/dist/esm/dates.d.ts +9 -1
  41. package/dist/esm/dates.js +20 -1
  42. package/dist/esm/envelope.d.ts +1 -1
  43. package/dist/esm/envelope.js +26 -1
  44. package/dist/esm/index.d.ts +2 -1
  45. package/dist/esm/load-plugins.d.ts +3 -1
  46. package/dist/esm/load-plugins.js +25 -6
  47. package/dist/esm/plugins/binary.js +2 -1
  48. package/dist/esm/plugins/context-search.js +2 -2
  49. package/dist/esm/plugins/create-special-use.d.ts +0 -5
  50. package/dist/esm/plugins/create-special-use.js +8 -1
  51. package/dist/esm/plugins/imap4rev2.js +5 -0
  52. package/dist/esm/plugins/metadata-server.d.ts +6 -2
  53. package/dist/esm/plugins/metadata-server.js +3 -1
  54. package/dist/esm/plugins/metadata.d.ts +5 -1
  55. package/dist/esm/plugins/metadata.js +3 -0
  56. package/dist/esm/quirks.d.ts +2 -2
  57. package/dist/esm/quirks.js +5 -4
  58. package/dist/esm/rest.d.ts +7 -3
  59. package/dist/esm/rest.js +11 -5
  60. package/dist/esm/server.d.ts +54 -3
  61. package/dist/esm/server.js +212 -90
  62. package/dist/esm/storage-schema.js +12 -5
  63. package/dist/esm/store-operations.d.ts +5 -2
  64. package/dist/esm/store-operations.js +6 -3
  65. package/dist/esm/types.d.ts +4 -0
  66. package/dist/plugin-help.json +3 -2
  67. package/package.json +1 -1
@@ -105,6 +105,36 @@ function getResponseTag(line) {
105
105
  function stateError(command, state) {
106
106
  return command + ' is not allowed in the ' + state + ' state';
107
107
  }
108
+ /**
109
+ * Checks if a queued notification changes message sequence numbers by removing messages: an EXPUNGE response, or the
110
+ * EXISTS that follows the EXPUNGE responses of another session (it carries the snapshot of the old message list)
111
+ *
112
+ * @param {Object} notification Queued notification
113
+ * @return {Boolean} true if the notification must wait while EXPUNGE responses are not allowed
114
+ */
115
+ function isPendingExpunge(notification) {
116
+ return !!notification.mailboxCopy || (0, arguments_js_1.isAtom)(notification.attributes && notification.attributes[1], 'EXPUNGE');
117
+ }
118
+ /**
119
+ * Checks if a notification is an untagged EXISTS response
120
+ *
121
+ * @param {Object} [notification] Notification to send
122
+ * @return {Boolean} true for an EXISTS response
123
+ */
124
+ function isExists(notification) {
125
+ return !!notification && !notification.command && (0, arguments_js_1.isAtom)(notification.attributes && notification.attributes[1], 'EXISTS');
126
+ }
127
+ /**
128
+ * Answers AUTHENTICATE with a mechanism no plugin supports: NO, not a syntax error (RFC 3501 section 6.2.2). It runs
129
+ * through the command queue, so the state check comes first (AUTHENTICATE is only valid in the Not Authenticated
130
+ * state, RFC 3501 section 6.2)
131
+ */
132
+ // usesSequenceNumbers() of each running command
133
+ const sequenceNumberUse = new WeakMap();
134
+ const unsupportedMechanism = (connection, parsed, data, callback) => {
135
+ connection.sendStatus(parsed, data, 'NO', 'Unsupported authentication mechanism', false, 'UNKNOWN COMMAND');
136
+ callback();
137
+ };
108
138
  /**
109
139
  * Creates a new IMAP server, call `listen()` on it to start accepting connections
110
140
  *
@@ -204,7 +234,7 @@ class IMAPServer extends node_stream_1.Stream {
204
234
  // plugins register their control operations
205
235
  this.control = new control_js_1.Control(this);
206
236
  // a quirk preset can leave plugins out (no-move, no-uidplus)
207
- (0, load_plugins_js_1.default)(this, this.options.plugins, [...quirks.removePlugins]);
237
+ (0, load_plugins_js_1.default)(this, this.options.plugins, quirks.removePlugins);
208
238
  if (this.options.storage) {
209
239
  // a typo in a fixture fails here with the path of the problem
210
240
  (0, storage_schema_js_1.validateStorage)(this.options.storage);
@@ -333,6 +363,8 @@ class IMAPServer extends node_stream_1.Stream {
333
363
  this.smtpServer = null;
334
364
  }
335
365
  if (this.restServer) {
366
+ // event streams never end on their own, a graceful shutdown would wait for them forever
367
+ this.restServer.endStreams?.();
336
368
  this.restServer.close();
337
369
  // keep-alive connections would hold the server open
338
370
  if (force) {
@@ -511,8 +543,7 @@ class IMAPServer extends node_stream_1.Stream {
511
543
  let seen = 0;
512
544
  let unseen = 0;
513
545
  // 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));
546
+ const permanentFlags = this.permanentFlagList(mailbox);
516
547
  let recent = 0;
517
548
  // \Recent sets of the sessions that have this mailbox selected
518
549
  const recentSets = [];
@@ -558,20 +589,8 @@ class IMAPServer extends node_stream_1.Stream {
558
589
  * @return {Boolean} Returns true if the date string is in IMAP date-time format
559
590
  */
560
591
  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);
592
+ // date-time from RFC 3501 section 9
593
+ return (0, dates_js_1.isDateTime)(date);
575
594
  }
576
595
  /**
577
596
  * Converts a date object to a valid date-time string format
@@ -1002,6 +1021,32 @@ class IMAPServer extends node_stream_1.Stream {
1002
1021
  const knownFlags = (mailbox.knownFlags = mailbox.knownFlags || []);
1003
1022
  flags.forEach(flag => this.ensureFlag(knownFlags, flag));
1004
1023
  }
1024
+ /**
1025
+ * Checks if a flag can be stored on messages of a mailbox: any flag when the mailbox allows new keywords
1026
+ * (PERMANENTFLAGS has `\*`), otherwise only a flag in the PERMANENTFLAGS list that getStatus() builds, the
1027
+ * `permanentFlags` of the mailbox and every flag its messages had. RFC 3501 section 7.1 (RFC 9051 section 7.1):
1028
+ * PERMANENTFLAGS "indicates which of the known flags the client can change permanently", STORE ignores the others
1029
+ *
1030
+ * @param {Object} mailbox Mailbox object
1031
+ * @param {String} flag Normalized flag
1032
+ * @return {Boolean} true if the flag is a permanent flag of the mailbox
1033
+ */
1034
+ isPermanentFlag(mailbox, flag) {
1035
+ // the lists of permanentFlagList(), without building it: this runs for every flag STORE and APPEND set
1036
+ return (mailbox.allowPermanentFlags || (mailbox.permanentFlags || []).indexOf(flag) >= 0 || (!!mailbox.knownFlags && mailbox.knownFlags.indexOf(flag) >= 0));
1037
+ }
1038
+ /**
1039
+ * The flags of the PERMANENTFLAGS list (without `\*`): the `permanentFlags` of the mailbox and every flag its
1040
+ * messages have or had (`knownFlags`, see rememberFlags)
1041
+ *
1042
+ * @param {Object} mailbox Mailbox object
1043
+ * @return {Array} flags, a new list
1044
+ */
1045
+ permanentFlagList(mailbox) {
1046
+ const list = [].concat(mailbox.permanentFlags || []);
1047
+ (mailbox.knownFlags || []).forEach(flag => this.ensureFlag(list, flag));
1048
+ return list;
1049
+ }
1005
1050
  /**
1006
1051
  * The current time for dates the server sets itself (INTERNALDATE of a message without one, SAVEDATE). The `now`
1007
1052
  * option fixes it for repeatable tests: a Date, a timestamp, or a function that returns one
@@ -1073,9 +1118,10 @@ class IMAPServer extends node_stream_1.Stream {
1073
1118
  */
1074
1119
  appendMessage(path, flags, internaldate, raw, ignoreConnection, properties) {
1075
1120
  const mailbox = typeof path === 'string' ? this.getMailbox(path) : path;
1076
- // processMessage() below sets the UID
1121
+ // processMessage() below sets the UID. Flags the mailbox can not store are left out (APPEND, COPY: the flags
1122
+ // SHOULD be set, RFC 3501 sections 6.3.11 and 6.4.7), as STORE ignores them (RFC 3501 section 7.1, PERMANENTFLAGS)
1077
1123
  const message = Object.assign({}, properties, {
1078
- flags: flags,
1124
+ flags: flags.filter(flag => this.isPermanentFlag(mailbox, flag)),
1079
1125
  internaldate: internaldate,
1080
1126
  raw: raw,
1081
1127
  recent: true
@@ -1261,49 +1307,48 @@ class IMAPServer extends node_stream_1.Stream {
1261
1307
  return flags;
1262
1308
  }
1263
1309
  matchFolders(referenceName, match, exportName, folders) {
1264
- let includeINBOX = false;
1265
1310
  const source = folders || this.folderCache;
1266
1311
  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 = '^' +
1312
+ // RFC 3501 section 6.3.8 (RFC 9051 section 6.3.9): "An empty ("" string) reference name argument
1313
+ // indicates that the mailbox name is interpreted as by SELECT", and without break out characters
1314
+ // "the canonical form is normally the reference name appended with the mailbox name". The pattern
1315
+ // matches full mailbox names, also in a personal namespace with a prefix such as "INBOX.". With the
1316
+ // namespace convention "#" is a break out character "and must be treated as such" (RFC 3501 section 6.3.8,
1317
+ // RFC 9051 section 6.3.9): a pattern that starts with it is a name of its own, the reference is ignored
1318
+ const lookup = match.charAt(0) === '#' ? match : (referenceName || '') + match;
1319
+ // "%" does not match the hierarchy delimiter, which is the one of the namespace a name belongs to
1320
+ const toRegExp = (separator, flags = '') => new RegExp('^' +
1289
1321
  lookup
1290
1322
  // escape regex symbols
1291
1323
  .replace(/([\\^$+?!.():=[\]{}|,-])/g, '\\$1')
1292
1324
  .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'))) {
1325
+ .replace(/[%]/g, '[^' + separator.replace(/([\\^$+*?!.():=[\]{}|,-])/g, '\\$1') + ']*') +
1326
+ '$', flags);
1327
+ // RFC 3501 section 6.3.8 allows to "hide" otherwise accessible mailboxes from the wildcards: the
1328
+ // mailboxes of namespaces other than the personal one are only matched when the pattern names
1329
+ // the prefix of the namespace before any wildcard (LIST "" "user.%", LIST "#news." "*"). The pattern
1330
+ // of every namespace that is matched, null for a hidden one
1331
+ const fixedPrefix = lookup.replace(/[*%].*$/, '');
1332
+ const queries = new Map();
1333
+ Object.keys(this.storage).forEach(key => {
1334
+ const name = toName(key);
1335
+ const visible = key === this.referenceNamespace || fixedPrefix.substr(0, name.length) === name;
1336
+ queries.set(key, visible ? toRegExp(this.storage[key].separator) : null);
1337
+ });
1338
+ const result = [];
1339
+ // "The special name INBOX is included in the output from LIST, if [...] the uppercase string "INBOX"
1340
+ // matches the interpreted reference and mailbox name arguments", INBOX is case-insensitive
1341
+ if (source.INBOX && toRegExp((this.storage.INBOX && this.storage.INBOX.separator) || '/', 'i').test('INBOX')) {
1298
1342
  result.push(source.INBOX);
1299
1343
  }
1300
1344
  Object.keys(source).forEach(path => {
1301
1345
  const folder = source[path];
1302
- if (folder.namespace !== nsKey) {
1346
+ const query = path !== 'INBOX' && folder.namespace !== false && folder.namespace !== 'INBOX' ? queries.get(folder.namespace) : null;
1347
+ if (!query) {
1303
1348
  return;
1304
1349
  }
1305
1350
  const name = toName(path);
1306
- if (name.match(query) && (folder.flags.indexOf('\\NonExistent') < 0 || name === match)) {
1351
+ if (query.test(name) && (folder.flags.indexOf('\\NonExistent') < 0 || name === lookup)) {
1307
1352
  result.push(folder);
1308
1353
  }
1309
1354
  });
@@ -2161,7 +2206,7 @@ class IMAPConnection {
2161
2206
  * @return {Boolean} true if an EXPUNGE response is pending
2162
2207
  */
2163
2208
  hasPendingExpunge() {
2164
- return this.notificationQueue.some(notification => notification.attributes && (notification.attributes[1] || {}).value === 'EXPUNGE');
2209
+ return this.notificationQueue.some(isPendingExpunge);
2165
2210
  }
2166
2211
  /**
2167
2212
  * Tells the other sessions that have the selected mailbox open about changed flags, they get
@@ -2368,22 +2413,52 @@ class IMAPConnection {
2368
2413
  prepareNotifications(queue) {
2369
2414
  return queue;
2370
2415
  }
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
- }
2416
+ /**
2417
+ * Checks if EXPUNGE responses must wait while a command runs: during FETCH, STORE and SEARCH (RFC 3501 section
2418
+ * 7.4.1), during the commands that extensions add to this list (see the noExpunge command option), and during UID
2419
+ * SEARCH with message numbers in the search criteria (RFC 7162 section 3.2.10.2 for VANISHED, EXPUNGE may wait as
2420
+ * well, RFC 3501 only allows it during UID commands)
2421
+ *
2422
+ * @param {Object} data Parsed command
2423
+ * @return {Boolean} true if the command holds back EXPUNGE responses
2424
+ */
2425
+ holdsExpunge(data) {
2426
+ const options = this.server.getCommandOptions(data.command);
2427
+ return options.noExpunge || (options.searchCriteria !== false && this.usesSequenceNumbers(data));
2428
+ }
2429
+ /**
2430
+ * Sends the queued notifications. During a command that holds back EXPUNGE responses (see holdsExpunge), or with
2431
+ * `beforeCommand` before a command that refers to messages by sequence number, only the notifications queued before
2432
+ * the first pending EXPUNGE go out: new messages (EXISTS) and flag changes. RFC 3501 section 5.2: "A server MUST send
2433
+ * mailbox size updates automatically if a mailbox size change is observed during the processing of a command",
2434
+ * section 7.4.1 forbids only EXPUNGE during FETCH, STORE and SEARCH. What was queued after the EXPUNGE waits with it,
2435
+ * an EXISTS sent before it would describe a list that the client can not know yet
2436
+ *
2437
+ * @param {Object} [data] Parsed command that runs, or null between commands
2438
+ * @param {Boolean} [beforeCommand] true when the command has not run yet, then a command with message sequence
2439
+ * numbers (COPY, MOVE) also waits with the EXPUNGE responses, its numbers refer to the messages before them
2440
+ */
2441
+ processNotifications(data, beforeCommand) {
2380
2442
  if (!this.notificationQueue.length) {
2381
2443
  return;
2382
2444
  }
2383
- const queue = this.prepareNotifications(this.notificationQueue);
2384
- this.notificationQueue = [];
2445
+ let queue = this.notificationQueue;
2385
2446
  // Flag updates use the sequence numbers this session knows: before the EXPUNGE responses of
2386
2447
  // the snapshot are sent, the snapshot, afterwards the current message list
2448
+ const snapshot = queue.find(notification => notification.mailboxCopy);
2449
+ let held = [];
2450
+ if (data && (this.holdsExpunge(data) || (beforeCommand && this.usesSequenceNumbers(data)))) {
2451
+ const first = queue.findIndex(isPendingExpunge);
2452
+ if (first >= 0) {
2453
+ held = queue.slice(first);
2454
+ queue = queue.slice(0, first);
2455
+ }
2456
+ if (!queue.length) {
2457
+ return;
2458
+ }
2459
+ }
2460
+ this.notificationQueue = held;
2461
+ queue = this.prepareNotifications(queue);
2387
2462
  const snapshotIndex = queue.findIndex(notification => notification.mailboxCopy);
2388
2463
  const sequenceMaps = new Map();
2389
2464
  const getSequence = (messages) => {
@@ -2397,15 +2472,57 @@ class IMAPConnection {
2397
2472
  const current = this.selectedMailbox ? this.selectedMailbox.messages : [];
2398
2473
  // a message changed several times is reported once, its FETCH response carries the current flags
2399
2474
  const reported = new Set();
2475
+ // before the snapshot (or with all of it still held back, then the split at the first pending expunge leaves
2476
+ // no snapshot in the queue) the session knows the old list
2477
+ const sessionList = (i) => (snapshot && (snapshotIndex < 0 || i < snapshotIndex) ? snapshot.mailboxCopy : current);
2478
+ // the last EXISTS response of a run of EXISTS responses, and if the run announces new messages
2479
+ let lastExists = -1;
2480
+ let newMessages = false;
2400
2481
  queue.forEach((notification, i) => {
2401
2482
  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);
2483
+ this.sendFlagUpdate(notification.flagUpdate, getSequence(sessionList(i)), reported);
2404
2484
  }
2405
2485
  else {
2406
2486
  this.send(notification);
2407
2487
  }
2488
+ if (isExists(notification)) {
2489
+ lastExists = i;
2490
+ // the EXISTS after the EXPUNGE responses of another session carries the snapshot instead of a message
2491
+ newMessages = newMessages || !!notification.message;
2492
+ }
2493
+ const next = queue[i + 1];
2494
+ if (lastExists < 0 || isExists(next) || (next && next.fetchedMessage)) {
2495
+ // not the end of a run of EXISTS responses (and the FETCH responses NOTIFY sends after them)
2496
+ return;
2497
+ }
2498
+ // RFC 3501 section 7.3.2: the RECENT response "occurs as a result of a SELECT or EXAMINE command, and if the
2499
+ // size of the mailbox changes (e.g., new messages)". One for consecutive EXISTS responses that announce new
2500
+ // messages, with the number of \Recent messages among those the client was told about. After expunges the
2501
+ // EXPUNGE responses report the change (RFC 3501 section 7.4.1). It goes after the FETCH responses that NOTIFY
2502
+ // sends for new messages, RFC 5465 section 5.2: "an unsolicited EXISTS response, followed by an unsolicited
2503
+ // FETCH response [...] The server MAY also send a RECENT response"
2504
+ if (newMessages) {
2505
+ this.sendRecent(Number(queue[lastExists].attributes[0]), getSequence(sessionList(lastExists)));
2506
+ }
2507
+ lastExists = -1;
2508
+ newMessages = false;
2509
+ });
2510
+ }
2511
+ /**
2512
+ * Sends an untagged RECENT response with the number of \Recent messages among the first `count` messages
2513
+ *
2514
+ * @param {Number} count Number of messages the client was told about
2515
+ * @param {Map} sequence Message to the sequence number this session knows it by
2516
+ */
2517
+ sendRecent(count, sequence) {
2518
+ let recent = 0;
2519
+ (this.recent || new Set()).forEach(message => {
2520
+ const seq = sequence.get(message);
2521
+ if (seq && seq <= count) {
2522
+ recent++;
2523
+ }
2408
2524
  });
2525
+ this.send({ tag: '*', notification: true, attributes: [recent, { type: 'ATOM', value: 'RECENT' }] }, 'RECENT NOTIFICATION');
2409
2526
  }
2410
2527
  /**
2411
2528
  * Compile a command object to a response string and write it to socket.
@@ -2449,10 +2566,11 @@ class IMAPConnection {
2449
2566
  response.tag === parsed.tag &&
2450
2567
  response.command === 'OK' &&
2451
2568
  this.hasPendingExpunge() &&
2452
- this.server.getCommandOptions(parsed.command).noExpunge &&
2569
+ this.holdsExpunge(parsed) &&
2453
2570
  !(Array.isArray(response.attributes) && response.attributes.some(attr => attr && attr.type === 'SECTION'))) {
2454
2571
  // 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),
2572
+ // FETCH, STORE, SEARCH and the like can not report the EXPUNGE of another session (RFC 3501 section 7.4.1), nor
2573
+ // can UID SEARCH with message numbers in the criteria (see holdsExpunge),
2456
2574
  // EXPUNGEISSUED tells the client to issue NOOP soon (RFC 5530 section 3, RFC 9051 section 7.1)
2457
2575
  response.attributes = [{ type: 'SECTION', section: [{ type: 'ATOM', value: 'EXPUNGEISSUED' }] }].concat(response.attributes || []);
2458
2576
  }
@@ -2596,6 +2714,15 @@ class IMAPConnection {
2596
2714
  * @return {Boolean} true if the command uses message sequence numbers
2597
2715
  */
2598
2716
  usesSequenceNumbers(parsed) {
2717
+ // the notification checks ask several times per command, the answer does not change
2718
+ let cached = sequenceNumberUse.get(parsed);
2719
+ if (cached === undefined) {
2720
+ cached = this.findSequenceNumbers(parsed);
2721
+ sequenceNumberUse.set(parsed, cached);
2722
+ }
2723
+ return cached;
2724
+ }
2725
+ findSequenceNumbers(parsed) {
2599
2726
  const { sequenceSet, searchCriteria } = this.server.getCommandOptions(parsed.command);
2600
2727
  if (sequenceSet !== false) {
2601
2728
  // other forms of sequence sets, like "$" of SEARCHRES (RFC 5182 section 2.3), do not use numbers
@@ -2774,12 +2901,14 @@ class IMAPConnection {
2774
2901
  this.refusePipelined(parsed, data);
2775
2902
  return;
2776
2903
  }
2777
- if (this.server.getCommandHandler(parsed.command)) {
2904
+ // an unknown SASL mechanism is answered by its own handler, after the central checks (state, script rules)
2905
+ const fallback = /^AUTHENTICATE /i.test(parsed.command) && !this.server.getCommandHandler(parsed.command) ? unsupportedMechanism : undefined;
2906
+ if (fallback || this.server.getCommandHandler(parsed.command)) {
2778
2907
  if (this.isAmbiguous(parsed)) {
2779
2908
  this.sendStatus(parsed, data, 'BAD', 'Commands with message sequence numbers must wait for the completion of earlier commands');
2780
2909
  return;
2781
2910
  }
2782
- const element = { parsed, data };
2911
+ const element = { parsed, data, handler: fallback };
2783
2912
  if (scripted) {
2784
2913
  // processQueue runs it once the script rule released the queue
2785
2914
  this._commandQueue.unshift(element);
@@ -2789,19 +2918,6 @@ class IMAPConnection {
2789
2918
  }
2790
2919
  this.processQueue();
2791
2920
  }
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
2921
  else {
2806
2922
  this.send({
2807
2923
  tag: parsed.tag,
@@ -2969,19 +3085,25 @@ class IMAPConnection {
2969
3085
  return next();
2970
3086
  }
2971
3087
  }
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);
3088
+ if (this.state === 'Selected' &&
3089
+ (command.startsWith('UID ') || options.noExpunge || options.sequenceSet !== false || options.searchCriteria !== false)) {
3090
+ // A command that refers to messages runs on the message list the client was told about. Only these: CLOSE
3091
+ // must not send pending EXPUNGE responses (RFC 3501 section 6.4.2) and SELECT leaves the old mailbox. RFC 3501 section 5.2:
3092
+ // "A server MUST send mailbox size updates automatically if a mailbox size change is observed during the
3093
+ // processing of a command", so the new messages and flag changes of other sessions are reported first,
3094
+ // before the command resolves its sequence set or search criteria.
3095
+ // EXPUNGE responses may be sent during UID commands (RFC 3501 section 7.4.1), so these report the expunges of
3096
+ // other sessions first as well, then the command runs on the current mailbox, where the UIDs of the expunged
3097
+ // messages do not exist and are ignored (RFC 3501 section 6.4.8). The ghost handling of STORE, COPY and MOVE
3098
+ // (RFC 2180 section 4) only applies to their sequence number forms, these and UID SEARCH with message numbers
3099
+ // in the criteria keep the EXPUNGE responses for later, see processNotifications
3100
+ this.processNotifications(element.parsed, true);
2979
3101
  }
2980
3102
  // changes made while the handler runs are attributed to this session (the `origin` of notifications)
2981
3103
  this.server.withOrigin(this, () => {
2982
3104
  try {
2983
3105
  const inputHandler = this.inputHandler;
2984
- this.server.getCommandHandler(element.parsed.command)(this, element.parsed, element.data, next);
3106
+ (element.handler || this.server.getCommandHandler(element.parsed.command))(this, element.parsed, element.data, next);
2985
3107
  if (this.inputHandler && this.inputHandler !== inputHandler) {
2986
3108
  // the command reads the lines that follow (IDLE, AUTHENTICATE), script rules match them with it
2987
3109
  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 '';
@@ -8,8 +8,8 @@ function setFlags(connection, message, flags) {
8
8
  [].concat(flags).forEach(flag => {
9
9
  flag = normalizeSystemFlag(typeof flag === 'string' ? flag : String(flag.value || ''));
10
10
  checkSystemFlags(connection.server, flag);
11
- // Ignore if it is not in allowed list and only permament flags are allowed to use
12
- if (mailbox.permanentFlags.indexOf(flag) < 0 && !mailbox.allowPermanentFlags) {
11
+ // a flag that is not in PERMANENTFLAGS is ignored (RFC 3501 section 7.1, RFC 9051 section 7.1)
12
+ if (!connection.server.isPermanentFlag(mailbox, flag)) {
13
13
  return;
14
14
  }
15
15
  if (messageFlags.indexOf(flag) < 0) {
@@ -24,8 +24,8 @@ function addFlags(connection, message, flags) {
24
24
  [].concat(flags).forEach(flag => {
25
25
  flag = normalizeSystemFlag(typeof flag === 'string' ? flag : String(flag.value || ''));
26
26
  checkSystemFlags(connection.server, flag);
27
- // Ignore if it is not in allowed list and only permament flags are allowed to use
28
- if (mailbox.permanentFlags.indexOf(flag) < 0 && !mailbox.allowPermanentFlags) {
27
+ // a flag that is not in PERMANENTFLAGS is ignored (RFC 3501 section 7.1, RFC 9051 section 7.1)
28
+ if (!connection.server.isPermanentFlag(mailbox, flag)) {
29
29
  return;
30
30
  }
31
31
  if (message.flags.indexOf(flag) < 0) {
@@ -19,8 +19,22 @@ export default function listCommand(connection, parsed, data, callback) {
19
19
  return callback();
20
20
  }
21
21
  if (!parsed.attributes[1].value) {
22
- // empty reference lists separator only
23
- const namespace = connection.server.storage[parsed.attributes[1].value || connection.server.referenceNamespace];
22
+ // RFC 3501 section 6.3.8: "An empty ("" string) mailbox name argument is a special request to return the
23
+ // hierarchy delimiter and the root name of the name given in the reference. The value returned as the root
24
+ // MAY be the empty string if the reference is non-rooted or is an empty string."
25
+ const server = connection.server;
26
+ const reference = parsed.attributes[0].value || '';
27
+ // the namespace of the reference, the personal one for a reference that is not a valid name
28
+ let key = server.referenceNamespace;
29
+ try {
30
+ key = server.getNamespace(connection.importMailboxName(reference)) || key;
31
+ }
32
+ catch {
33
+ // not a mailbox name, so not in another namespace
34
+ }
35
+ const namespace = key !== false ? server.storage[key] : undefined;
36
+ // the root of a name in another namespace is the prefix of that namespace, like "#news." in the RFC example
37
+ const root = key !== false && key !== server.referenceNamespace ? connection.exportMailboxName(key) : '';
24
38
  if (namespace) {
25
39
  connection.send({
26
40
  tag: '*',
@@ -33,7 +47,7 @@ export default function listCommand(connection, parsed, data, callback) {
33
47
  }
34
48
  ],
35
49
  namespace.separator,
36
- ''
50
+ root
37
51
  ]
38
52
  }, 'LIST ITEM', parsed, data);
39
53
  }