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
@@ -7,11 +7,11 @@ 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
16
  import { DEFAULT_SESSION_TIMEOUT, storeError, expungeMessages, notifyFlagChanges } from './store-operations.js';
17
17
  import { Control } from './control.js';
@@ -19,6 +19,7 @@ import { resolveQuirks } from './quirks.js';
19
19
  import { validateStorage } from './storage-schema.js';
20
20
  import { createRestServer } from './rest.js';
21
21
  import * as bundledCert from './cert.js';
22
+ import { createSocketPair } from './memory-socket.js';
22
23
  import { ServerScript, sendOutput, handleLine, handleQuiet, literalResponse, releaseDeferred } from './script.js';
23
24
  // longest command line (not counting literals) accepted from a client
24
25
  const MAX_LINE_LENGTH = 1024 * 1024;
@@ -64,6 +65,36 @@ function getResponseTag(line) {
64
65
  function stateError(command, state) {
65
66
  return command + ' is not allowed in the ' + state + ' state';
66
67
  }
68
+ /**
69
+ * Checks if a queued notification changes message sequence numbers by removing messages: an EXPUNGE response, or the
70
+ * EXISTS that follows the EXPUNGE responses of another session (it carries the snapshot of the old message list)
71
+ *
72
+ * @param {Object} notification Queued notification
73
+ * @return {Boolean} true if the notification must wait while EXPUNGE responses are not allowed
74
+ */
75
+ function isPendingExpunge(notification) {
76
+ return !!notification.mailboxCopy || isAtom(notification.attributes && notification.attributes[1], 'EXPUNGE');
77
+ }
78
+ /**
79
+ * Checks if a notification is an untagged EXISTS response
80
+ *
81
+ * @param {Object} [notification] Notification to send
82
+ * @return {Boolean} true for an EXISTS response
83
+ */
84
+ function isExists(notification) {
85
+ return !!notification && !notification.command && isAtom(notification.attributes && notification.attributes[1], 'EXISTS');
86
+ }
87
+ /**
88
+ * Answers AUTHENTICATE with a mechanism no plugin supports: NO, not a syntax error (RFC 3501 section 6.2.2). It runs
89
+ * through the command queue, so the state check comes first (AUTHENTICATE is only valid in the Not Authenticated
90
+ * state, RFC 3501 section 6.2)
91
+ */
92
+ // usesSequenceNumbers() of each running command
93
+ const sequenceNumberUse = new WeakMap();
94
+ const unsupportedMechanism = (connection, parsed, data, callback) => {
95
+ connection.sendStatus(parsed, data, 'NO', 'Unsupported authentication mechanism', false, 'UNKNOWN COMMAND');
96
+ callback();
97
+ };
67
98
  /**
68
99
  * Creates a new IMAP server, call `listen()` on it to start accepting connections
69
100
  *
@@ -163,7 +194,7 @@ class IMAPServer extends Stream {
163
194
  // plugins register their control operations
164
195
  this.control = new Control(this);
165
196
  // a quirk preset can leave plugins out (no-move, no-uidplus)
166
- loadPlugins(this, this.options.plugins, [...quirks.removePlugins]);
197
+ loadPlugins(this, this.options.plugins, quirks.removePlugins);
167
198
  if (this.options.storage) {
168
199
  // a typo in a fixture fails here with the path of the problem
169
200
  validateStorage(this.options.storage);
@@ -292,6 +323,8 @@ class IMAPServer extends Stream {
292
323
  this.smtpServer = null;
293
324
  }
294
325
  if (this.restServer) {
326
+ // event streams never end on their own, a graceful shutdown would wait for them forever
327
+ this.restServer.endStreams?.();
295
328
  this.restServer.close();
296
329
  // keep-alive connections would hold the server open
297
330
  if (force) {
@@ -327,6 +360,21 @@ class IMAPServer extends Stream {
327
360
  handler(connection);
328
361
  });
329
362
  }
363
+ /**
364
+ * Opens a connection to the server in memory, without a network socket (no `listen()` needed). This is how the
365
+ * server runs in a browser, see the README "Run in a browser"
366
+ *
367
+ * @param {Object} [options] `{ secure }`: false makes the connection a plaintext one, where STARTTLS turns on the
368
+ * encryption (nothing is encrypted, the session only counts as encrypted). By default the connection counts as
369
+ * one over TLS
370
+ * @return {MemorySocket} the client end, write commands to it and read the responses from its `data` events
371
+ */
372
+ connect(options) {
373
+ const [serverSocket, clientSocket] = createSocketPair();
374
+ serverSocket.encrypted = clientSocket.encrypted = !options || options.secure !== false;
375
+ this.createClient(serverSocket);
376
+ return clientSocket;
377
+ }
330
378
  registerCapability(keyword, handler) {
331
379
  this.capabilities[keyword] =
332
380
  handler ||
@@ -470,8 +518,7 @@ class IMAPServer extends Stream {
470
518
  let seen = 0;
471
519
  let unseen = 0;
472
520
  // flags stay defined in the mailbox once a message had them, see rememberFlags
473
- const permanentFlags = [].concat(mailbox.permanentFlags || []);
474
- (mailbox.knownFlags || []).forEach(flag => this.ensureFlag(permanentFlags, flag));
521
+ const permanentFlags = this.permanentFlagList(mailbox);
475
522
  let recent = 0;
476
523
  // \Recent sets of the sessions that have this mailbox selected
477
524
  const recentSets = [];
@@ -517,20 +564,8 @@ class IMAPServer extends Stream {
517
564
  * @return {Boolean} Returns true if the date string is in IMAP date-time format
518
565
  */
519
566
  validateInternalDate(date) {
520
- if (!date || typeof date !== 'string') {
521
- return false;
522
- }
523
- // date-time from RFC 3501 section 9, month names are case-insensitive like all ABNF strings
524
- 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);
525
- if (!match) {
526
- return false;
527
- }
528
- // the values must also make a real date and time
529
- return (isRealDate(match[1], monthIndex(match[2]), match[3]) &&
530
- Number(match[4]) < 24 &&
531
- Number(match[5]) < 60 &&
532
- Number(match[6]) < 61 &&
533
- Number(match[8]) < 60);
567
+ // date-time from RFC 3501 section 9
568
+ return isDateTime(date);
534
569
  }
535
570
  /**
536
571
  * Converts a date object to a valid date-time string format
@@ -961,6 +996,32 @@ class IMAPServer extends Stream {
961
996
  const knownFlags = (mailbox.knownFlags = mailbox.knownFlags || []);
962
997
  flags.forEach(flag => this.ensureFlag(knownFlags, flag));
963
998
  }
999
+ /**
1000
+ * Checks if a flag can be stored on messages of a mailbox: any flag when the mailbox allows new keywords
1001
+ * (PERMANENTFLAGS has `\*`), otherwise only a flag in the PERMANENTFLAGS list that getStatus() builds, the
1002
+ * `permanentFlags` of the mailbox and every flag its messages had. RFC 3501 section 7.1 (RFC 9051 section 7.1):
1003
+ * PERMANENTFLAGS "indicates which of the known flags the client can change permanently", STORE ignores the others
1004
+ *
1005
+ * @param {Object} mailbox Mailbox object
1006
+ * @param {String} flag Normalized flag
1007
+ * @return {Boolean} true if the flag is a permanent flag of the mailbox
1008
+ */
1009
+ isPermanentFlag(mailbox, flag) {
1010
+ // the lists of permanentFlagList(), without building it: this runs for every flag STORE and APPEND set
1011
+ return (mailbox.allowPermanentFlags || (mailbox.permanentFlags || []).indexOf(flag) >= 0 || (!!mailbox.knownFlags && mailbox.knownFlags.indexOf(flag) >= 0));
1012
+ }
1013
+ /**
1014
+ * The flags of the PERMANENTFLAGS list (without `\*`): the `permanentFlags` of the mailbox and every flag its
1015
+ * messages have or had (`knownFlags`, see rememberFlags)
1016
+ *
1017
+ * @param {Object} mailbox Mailbox object
1018
+ * @return {Array} flags, a new list
1019
+ */
1020
+ permanentFlagList(mailbox) {
1021
+ const list = [].concat(mailbox.permanentFlags || []);
1022
+ (mailbox.knownFlags || []).forEach(flag => this.ensureFlag(list, flag));
1023
+ return list;
1024
+ }
964
1025
  /**
965
1026
  * The current time for dates the server sets itself (INTERNALDATE of a message without one, SAVEDATE). The `now`
966
1027
  * option fixes it for repeatable tests: a Date, a timestamp, or a function that returns one
@@ -1032,9 +1093,10 @@ class IMAPServer extends Stream {
1032
1093
  */
1033
1094
  appendMessage(path, flags, internaldate, raw, ignoreConnection, properties) {
1034
1095
  const mailbox = typeof path === 'string' ? this.getMailbox(path) : path;
1035
- // processMessage() below sets the UID
1096
+ // processMessage() below sets the UID. Flags the mailbox can not store are left out (APPEND, COPY: the flags
1097
+ // SHOULD be set, RFC 3501 sections 6.3.11 and 6.4.7), as STORE ignores them (RFC 3501 section 7.1, PERMANENTFLAGS)
1036
1098
  const message = Object.assign({}, properties, {
1037
- flags: flags,
1099
+ flags: flags.filter(flag => this.isPermanentFlag(mailbox, flag)),
1038
1100
  internaldate: internaldate,
1039
1101
  raw: raw,
1040
1102
  recent: true
@@ -1220,49 +1282,48 @@ class IMAPServer extends Stream {
1220
1282
  return flags;
1221
1283
  }
1222
1284
  matchFolders(referenceName, match, exportName, folders) {
1223
- let includeINBOX = false;
1224
1285
  const source = folders || this.folderCache;
1225
1286
  const toName = exportName || ((name) => name);
1226
- let reference = referenceName || '';
1227
- if (reference === '' && this.referenceNamespace !== false) {
1228
- reference = toName(this.referenceNamespace);
1229
- includeINBOX = true;
1230
- }
1231
- // the reference does not have to be a namespace, use the namespace it belongs to
1232
- let nsKey = false;
1233
- let nsName = '';
1234
- for (const key of Object.keys(this.storage)) {
1235
- const name = toName(key);
1236
- if (key !== 'INBOX' && reference.substr(0, name.length) === name && (nsKey === false || name.length > nsName.length)) {
1237
- nsKey = key;
1238
- nsName = name;
1239
- }
1240
- }
1241
- if (nsKey === false) {
1242
- return [];
1243
- }
1244
- const namespace = this.storage[nsKey];
1245
- const lookup = reference + match;
1246
- const result = [];
1247
- const pattern = '^' +
1287
+ // RFC 3501 section 6.3.8 (RFC 9051 section 6.3.9): "An empty ("" string) reference name argument
1288
+ // indicates that the mailbox name is interpreted as by SELECT", and without break out characters
1289
+ // "the canonical form is normally the reference name appended with the mailbox name". The pattern
1290
+ // matches full mailbox names, also in a personal namespace with a prefix such as "INBOX.". With the
1291
+ // namespace convention "#" is a break out character "and must be treated as such" (RFC 3501 section 6.3.8,
1292
+ // RFC 9051 section 6.3.9): a pattern that starts with it is a name of its own, the reference is ignored
1293
+ const lookup = match.charAt(0) === '#' ? match : (referenceName || '') + match;
1294
+ // "%" does not match the hierarchy delimiter, which is the one of the namespace a name belongs to
1295
+ const toRegExp = (separator, flags = '') => new RegExp('^' +
1248
1296
  lookup
1249
1297
  // escape regex symbols
1250
1298
  .replace(/([\\^$+?!.():=[\]{}|,-])/g, '\\$1')
1251
1299
  .replace(/[*]/g, '.*')
1252
- .replace(/[%]/g, '[^' + namespace.separator.replace(/([\\^$+*?!.():=[\]{}|,-])/g, '\\$1') + ']*') +
1253
- '$';
1254
- const query = new RegExp(pattern, '');
1255
- // INBOX is case-insensitive
1256
- if (includeINBOX && source.INBOX && ((reference ? reference + namespace.separator : '') + 'INBOX').match(new RegExp(pattern, 'i'))) {
1300
+ .replace(/[%]/g, '[^' + separator.replace(/([\\^$+*?!.():=[\]{}|,-])/g, '\\$1') + ']*') +
1301
+ '$', flags);
1302
+ // RFC 3501 section 6.3.8 allows to "hide" otherwise accessible mailboxes from the wildcards: the
1303
+ // mailboxes of namespaces other than the personal one are only matched when the pattern names
1304
+ // the prefix of the namespace before any wildcard (LIST "" "user.%", LIST "#news." "*"). The pattern
1305
+ // of every namespace that is matched, null for a hidden one
1306
+ const fixedPrefix = lookup.replace(/[*%].*$/, '');
1307
+ const queries = new Map();
1308
+ Object.keys(this.storage).forEach(key => {
1309
+ const name = toName(key);
1310
+ const visible = key === this.referenceNamespace || fixedPrefix.substr(0, name.length) === name;
1311
+ queries.set(key, visible ? toRegExp(this.storage[key].separator) : null);
1312
+ });
1313
+ const result = [];
1314
+ // "The special name INBOX is included in the output from LIST, if [...] the uppercase string "INBOX"
1315
+ // matches the interpreted reference and mailbox name arguments", INBOX is case-insensitive
1316
+ if (source.INBOX && toRegExp((this.storage.INBOX && this.storage.INBOX.separator) || '/', 'i').test('INBOX')) {
1257
1317
  result.push(source.INBOX);
1258
1318
  }
1259
1319
  Object.keys(source).forEach(path => {
1260
1320
  const folder = source[path];
1261
- if (folder.namespace !== nsKey) {
1321
+ const query = path !== 'INBOX' && folder.namespace !== false && folder.namespace !== 'INBOX' ? queries.get(folder.namespace) : null;
1322
+ if (!query) {
1262
1323
  return;
1263
1324
  }
1264
1325
  const name = toName(path);
1265
- if (name.match(query) && (folder.flags.indexOf('\\NonExistent') < 0 || name === match)) {
1326
+ if (query.test(name) && (folder.flags.indexOf('\\NonExistent') < 0 || name === lookup)) {
1266
1327
  result.push(folder);
1267
1328
  }
1268
1329
  });
@@ -1351,7 +1412,8 @@ class IMAPConnection {
1351
1412
  this._outputQueue = null;
1352
1413
  this._outputTimer = null;
1353
1414
  this._deferredOutput = null;
1354
- this.secureConnection = !!this.options.secureConnection;
1415
+ // a TLS socket is encrypted, and so can be an in-memory connection of server.connect()
1416
+ this.secureConnection = !!this.options.secureConnection || socket.encrypted === true;
1355
1417
  this._remainder = '';
1356
1418
  this._command = '';
1357
1419
  this._literalRemaining = 0;
@@ -1369,7 +1431,7 @@ class IMAPConnection {
1369
1431
  this.messageGlobal = false;
1370
1432
  this._commandQueue = [];
1371
1433
  this._processing = false;
1372
- if (this.options.debug) {
1434
+ if (this.options.debug && typeof this.socket.pipe === 'function') {
1373
1435
  this.socket.pipe(process.stdout);
1374
1436
  }
1375
1437
  this.socket.on('data', this.receive.bind(this));
@@ -1608,7 +1670,8 @@ class IMAPConnection {
1608
1670
  this.watchQuiet(quiet);
1609
1671
  }
1610
1672
  }, Math.max(0, next - (Date.now() - last)));
1611
- this._quietTimer.unref();
1673
+ // browsers have no unref
1674
+ this._quietTimer.unref?.();
1612
1675
  }
1613
1676
  /**
1614
1677
  * Handles data from the socket, through the transport layer if there is one
@@ -2119,7 +2182,7 @@ class IMAPConnection {
2119
2182
  * @return {Boolean} true if an EXPUNGE response is pending
2120
2183
  */
2121
2184
  hasPendingExpunge() {
2122
- return this.notificationQueue.some(notification => notification.attributes && (notification.attributes[1] || {}).value === 'EXPUNGE');
2185
+ return this.notificationQueue.some(isPendingExpunge);
2123
2186
  }
2124
2187
  /**
2125
2188
  * Tells the other sessions that have the selected mailbox open about changed flags, they get
@@ -2278,6 +2341,16 @@ class IMAPConnection {
2278
2341
  // section 6.2.1). STARTTLS is refused when input is waiting (the noPipelining command option), this only
2279
2342
  // guards against plugins that upgrade the connection otherwise
2280
2343
  this.discardInput();
2344
+ // STARTTLS runs on a live connection
2345
+ const socket = this.socket;
2346
+ if (typeof socket.startTls === 'function') {
2347
+ // a socket without a TLS layer that can count as encrypted (MemorySocket of server.connect())
2348
+ return socket.startTls(() => {
2349
+ this.secureConnection = true;
2350
+ this.upgrading = false;
2351
+ callback();
2352
+ });
2353
+ }
2281
2354
  const secureContext = tls.createSecureContext(this.server.getCredentials());
2282
2355
  const socketOptions = {
2283
2356
  secureContext: secureContext,
@@ -2288,8 +2361,6 @@ class IMAPConnection {
2288
2361
  cb(null, secureContext);
2289
2362
  }
2290
2363
  };
2291
- // STARTTLS runs on a live connection
2292
- const socket = this.socket;
2293
2364
  // the RST of a script rule goes to the TCP socket under the TLS layer, see resetSocket. Node also has it as the
2294
2365
  // _parent of the TLS socket, Bun does not
2295
2366
  this.tcpSocket = socket;
@@ -2326,22 +2397,52 @@ class IMAPConnection {
2326
2397
  prepareNotifications(queue) {
2327
2398
  return queue;
2328
2399
  }
2329
- processNotifications(data) {
2330
- const options = data && this.server.getCommandOptions(data.command);
2331
- if (options && (options.noExpunge || (options.searchCriteria !== false && this.usesSequenceNumbers(data)))) {
2332
- // EXPUNGE responses are not allowed during FETCH, STORE and SEARCH (RFC 3501 section 7.4.1), during
2333
- // the commands that extensions add to this list (see the noExpunge command option), nor during UID
2334
- // SEARCH with message numbers in the search criteria (RFC 7162 section 3.2.10.2 for VANISHED, EXPUNGE
2335
- // may wait as well, RFC 3501 only allows it during UID commands)
2336
- return;
2337
- }
2400
+ /**
2401
+ * Checks if EXPUNGE responses must wait while a command runs: during FETCH, STORE and SEARCH (RFC 3501 section
2402
+ * 7.4.1), during the commands that extensions add to this list (see the noExpunge command option), and during UID
2403
+ * SEARCH with message numbers in the search criteria (RFC 7162 section 3.2.10.2 for VANISHED, EXPUNGE may wait as
2404
+ * well, RFC 3501 only allows it during UID commands)
2405
+ *
2406
+ * @param {Object} data Parsed command
2407
+ * @return {Boolean} true if the command holds back EXPUNGE responses
2408
+ */
2409
+ holdsExpunge(data) {
2410
+ const options = this.server.getCommandOptions(data.command);
2411
+ return options.noExpunge || (options.searchCriteria !== false && this.usesSequenceNumbers(data));
2412
+ }
2413
+ /**
2414
+ * Sends the queued notifications. During a command that holds back EXPUNGE responses (see holdsExpunge), or with
2415
+ * `beforeCommand` before a command that refers to messages by sequence number, only the notifications queued before
2416
+ * the first pending EXPUNGE go out: new messages (EXISTS) and flag changes. RFC 3501 section 5.2: "A server MUST send
2417
+ * mailbox size updates automatically if a mailbox size change is observed during the processing of a command",
2418
+ * section 7.4.1 forbids only EXPUNGE during FETCH, STORE and SEARCH. What was queued after the EXPUNGE waits with it,
2419
+ * an EXISTS sent before it would describe a list that the client can not know yet
2420
+ *
2421
+ * @param {Object} [data] Parsed command that runs, or null between commands
2422
+ * @param {Boolean} [beforeCommand] true when the command has not run yet, then a command with message sequence
2423
+ * numbers (COPY, MOVE) also waits with the EXPUNGE responses, its numbers refer to the messages before them
2424
+ */
2425
+ processNotifications(data, beforeCommand) {
2338
2426
  if (!this.notificationQueue.length) {
2339
2427
  return;
2340
2428
  }
2341
- const queue = this.prepareNotifications(this.notificationQueue);
2342
- this.notificationQueue = [];
2429
+ let queue = this.notificationQueue;
2343
2430
  // Flag updates use the sequence numbers this session knows: before the EXPUNGE responses of
2344
2431
  // the snapshot are sent, the snapshot, afterwards the current message list
2432
+ const snapshot = queue.find(notification => notification.mailboxCopy);
2433
+ let held = [];
2434
+ if (data && (this.holdsExpunge(data) || (beforeCommand && this.usesSequenceNumbers(data)))) {
2435
+ const first = queue.findIndex(isPendingExpunge);
2436
+ if (first >= 0) {
2437
+ held = queue.slice(first);
2438
+ queue = queue.slice(0, first);
2439
+ }
2440
+ if (!queue.length) {
2441
+ return;
2442
+ }
2443
+ }
2444
+ this.notificationQueue = held;
2445
+ queue = this.prepareNotifications(queue);
2345
2446
  const snapshotIndex = queue.findIndex(notification => notification.mailboxCopy);
2346
2447
  const sequenceMaps = new Map();
2347
2448
  const getSequence = (messages) => {
@@ -2355,16 +2456,58 @@ class IMAPConnection {
2355
2456
  const current = this.selectedMailbox ? this.selectedMailbox.messages : [];
2356
2457
  // a message changed several times is reported once, its FETCH response carries the current flags
2357
2458
  const reported = new Set();
2459
+ // before the snapshot (or with all of it still held back, then the split at the first pending expunge leaves
2460
+ // no snapshot in the queue) the session knows the old list
2461
+ const sessionList = (i) => (snapshot && (snapshotIndex < 0 || i < snapshotIndex) ? snapshot.mailboxCopy : current);
2462
+ // the last EXISTS response of a run of EXISTS responses, and if the run announces new messages
2463
+ let lastExists = -1;
2464
+ let newMessages = false;
2358
2465
  queue.forEach((notification, i) => {
2359
2466
  if (notification.flagUpdate) {
2360
- // i < snapshotIndex only when there is a snapshot
2361
- this.sendFlagUpdate(notification.flagUpdate, getSequence(i < snapshotIndex ? queue[snapshotIndex].mailboxCopy : current), reported);
2467
+ this.sendFlagUpdate(notification.flagUpdate, getSequence(sessionList(i)), reported);
2362
2468
  }
2363
2469
  else {
2364
2470
  this.send(notification);
2365
2471
  }
2472
+ if (isExists(notification)) {
2473
+ lastExists = i;
2474
+ // the EXISTS after the EXPUNGE responses of another session carries the snapshot instead of a message
2475
+ newMessages = newMessages || !!notification.message;
2476
+ }
2477
+ const next = queue[i + 1];
2478
+ if (lastExists < 0 || isExists(next) || (next && next.fetchedMessage)) {
2479
+ // not the end of a run of EXISTS responses (and the FETCH responses NOTIFY sends after them)
2480
+ return;
2481
+ }
2482
+ // RFC 3501 section 7.3.2: the RECENT response "occurs as a result of a SELECT or EXAMINE command, and if the
2483
+ // size of the mailbox changes (e.g., new messages)". One for consecutive EXISTS responses that announce new
2484
+ // messages, with the number of \Recent messages among those the client was told about. After expunges the
2485
+ // EXPUNGE responses report the change (RFC 3501 section 7.4.1). It goes after the FETCH responses that NOTIFY
2486
+ // sends for new messages, RFC 5465 section 5.2: "an unsolicited EXISTS response, followed by an unsolicited
2487
+ // FETCH response [...] The server MAY also send a RECENT response"
2488
+ if (newMessages) {
2489
+ this.sendRecent(Number(queue[lastExists].attributes[0]), getSequence(sessionList(lastExists)));
2490
+ }
2491
+ lastExists = -1;
2492
+ newMessages = false;
2366
2493
  });
2367
2494
  }
2495
+ /**
2496
+ * Sends an untagged RECENT response with the number of \Recent messages among the first `count` messages
2497
+ *
2498
+ * @param {Number} count Number of messages the client was told about
2499
+ * @param {Map} sequence Message to the sequence number this session knows it by
2500
+ */
2501
+ sendRecent(count, sequence) {
2502
+ let recent = 0;
2503
+ (this.recent || new Set()).forEach(message => {
2504
+ const seq = sequence.get(message);
2505
+ if (seq && seq <= count) {
2506
+ recent++;
2507
+ }
2508
+ });
2509
+ this.send({ tag: '*', notification: true, attributes: [recent, { type: 'ATOM', value: 'RECENT' }] }, 'RECENT NOTIFICATION');
2510
+ }
2368
2511
  /**
2369
2512
  * Compile a command object to a response string and write it to socket.
2370
2513
  * If the command object has a skipResponse property, the command is
@@ -2407,10 +2550,11 @@ class IMAPConnection {
2407
2550
  response.tag === parsed.tag &&
2408
2551
  response.command === 'OK' &&
2409
2552
  this.hasPendingExpunge() &&
2410
- this.server.getCommandOptions(parsed.command).noExpunge &&
2553
+ this.holdsExpunge(parsed) &&
2411
2554
  !(Array.isArray(response.attributes) && response.attributes.some(attr => attr && attr.type === 'SECTION'))) {
2412
2555
  // After the output handlers, they might add a response code of their own (MODIFIED of CONDSTORE).
2413
- // FETCH, STORE, SEARCH and the like can not report the EXPUNGE of another session (RFC 3501 section 7.4.1),
2556
+ // FETCH, STORE, SEARCH and the like can not report the EXPUNGE of another session (RFC 3501 section 7.4.1), nor
2557
+ // can UID SEARCH with message numbers in the criteria (see holdsExpunge),
2414
2558
  // EXPUNGEISSUED tells the client to issue NOOP soon (RFC 5530 section 3, RFC 9051 section 7.1)
2415
2559
  response.attributes = [{ type: 'SECTION', section: [{ type: 'ATOM', value: 'EXPUNGEISSUED' }] }].concat(response.attributes || []);
2416
2560
  }
@@ -2554,6 +2698,15 @@ class IMAPConnection {
2554
2698
  * @return {Boolean} true if the command uses message sequence numbers
2555
2699
  */
2556
2700
  usesSequenceNumbers(parsed) {
2701
+ // the notification checks ask several times per command, the answer does not change
2702
+ let cached = sequenceNumberUse.get(parsed);
2703
+ if (cached === undefined) {
2704
+ cached = this.findSequenceNumbers(parsed);
2705
+ sequenceNumberUse.set(parsed, cached);
2706
+ }
2707
+ return cached;
2708
+ }
2709
+ findSequenceNumbers(parsed) {
2557
2710
  const { sequenceSet, searchCriteria } = this.server.getCommandOptions(parsed.command);
2558
2711
  if (sequenceSet !== false) {
2559
2712
  // other forms of sequence sets, like "$" of SEARCHRES (RFC 5182 section 2.3), do not use numbers
@@ -2732,12 +2885,14 @@ class IMAPConnection {
2732
2885
  this.refusePipelined(parsed, data);
2733
2886
  return;
2734
2887
  }
2735
- if (this.server.getCommandHandler(parsed.command)) {
2888
+ // an unknown SASL mechanism is answered by its own handler, after the central checks (state, script rules)
2889
+ const fallback = /^AUTHENTICATE /i.test(parsed.command) && !this.server.getCommandHandler(parsed.command) ? unsupportedMechanism : undefined;
2890
+ if (fallback || this.server.getCommandHandler(parsed.command)) {
2736
2891
  if (this.isAmbiguous(parsed)) {
2737
2892
  this.sendStatus(parsed, data, 'BAD', 'Commands with message sequence numbers must wait for the completion of earlier commands');
2738
2893
  return;
2739
2894
  }
2740
- const element = { parsed, data };
2895
+ const element = { parsed, data, handler: fallback };
2741
2896
  if (scripted) {
2742
2897
  // processQueue runs it once the script rule released the queue
2743
2898
  this._commandQueue.unshift(element);
@@ -2747,19 +2902,6 @@ class IMAPConnection {
2747
2902
  }
2748
2903
  this.processQueue();
2749
2904
  }
2750
- else if (/^AUTHENTICATE /i.test(parsed.command)) {
2751
- // an unsupported mechanism is a NO, not a syntax error (RFC 3501 section 6.2.2)
2752
- this.send({
2753
- tag: parsed.tag,
2754
- command: 'NO',
2755
- attributes: [
2756
- {
2757
- type: 'TEXT',
2758
- value: 'Unsupported authentication mechanism'
2759
- }
2760
- ]
2761
- }, 'UNKNOWN COMMAND', parsed, data);
2762
- }
2763
2905
  else {
2764
2906
  this.send({
2765
2907
  tag: parsed.tag,
@@ -2927,19 +3069,25 @@ class IMAPConnection {
2927
3069
  return next();
2928
3070
  }
2929
3071
  }
2930
- if (command.substr(0, 4) === 'UID ' && this.hasPendingExpunge()) {
2931
- // EXPUNGE responses may be sent during UID commands (RFC 3501 section 7.4.1). The expunges of other sessions
2932
- // are reported first, then the command runs on the current mailbox, where the UIDs of the expunged messages
2933
- // do not exist and are ignored (RFC 3501 section 6.4.8), so the ghost handling of STORE, COPY and MOVE (RFC 2180
2934
- // section 4) only applies to their sequence number forms. Not for UID SEARCH with message numbers in the
2935
- // criteria, processNotifications knows when EXPUNGE must wait
2936
- this.processNotifications(element.parsed);
3072
+ if (this.state === 'Selected' &&
3073
+ (command.startsWith('UID ') || options.noExpunge || options.sequenceSet !== false || options.searchCriteria !== false)) {
3074
+ // A command that refers to messages runs on the message list the client was told about. Only these: CLOSE
3075
+ // must not send pending EXPUNGE responses (RFC 3501 section 6.4.2) and SELECT leaves the old mailbox. RFC 3501 section 5.2:
3076
+ // "A server MUST send mailbox size updates automatically if a mailbox size change is observed during the
3077
+ // processing of a command", so the new messages and flag changes of other sessions are reported first,
3078
+ // before the command resolves its sequence set or search criteria.
3079
+ // EXPUNGE responses may be sent during UID commands (RFC 3501 section 7.4.1), so these report the expunges of
3080
+ // other sessions first as well, then the command runs on the current mailbox, where the UIDs of the expunged
3081
+ // messages do not exist and are ignored (RFC 3501 section 6.4.8). The ghost handling of STORE, COPY and MOVE
3082
+ // (RFC 2180 section 4) only applies to their sequence number forms, these and UID SEARCH with message numbers
3083
+ // in the criteria keep the EXPUNGE responses for later, see processNotifications
3084
+ this.processNotifications(element.parsed, true);
2937
3085
  }
2938
3086
  // changes made while the handler runs are attributed to this session (the `origin` of notifications)
2939
3087
  this.server.withOrigin(this, () => {
2940
3088
  try {
2941
3089
  const inputHandler = this.inputHandler;
2942
- this.server.getCommandHandler(element.parsed.command)(this, element.parsed, element.data, next);
3090
+ (element.handler || this.server.getCommandHandler(element.parsed.command))(this, element.parsed, element.data, next);
2943
3091
  if (this.inputHandler && this.inputHandler !== inputHandler) {
2944
3092
  // the command reads the lines that follow (IDLE, AUTHENTICATE), script rules match them with it
2945
3093
  this.inputCommand = { tag: element.parsed.tag, command: element.parsed.command };
@@ -5,6 +5,7 @@
5
5
  * messages (acl, metadata, MODSEQ ...), so other keys are allowed, only the types of the known keys are checked
6
6
  */
7
7
  import { MAX_NUMBER } from './numbers.js';
8
+ import { isDateTime } from './dates.js';
8
9
  // keys of plugins, an unknown key that looks like a typo of a known key is refused unless a plugin uses it
9
10
  const PLUGIN_KEYS = [
10
11
  'acl',
@@ -147,11 +148,17 @@ export function validateStorage(storage) {
147
148
  if (message.flags !== undefined && typeof message.flags !== 'string' && !isFlagList(message.flags)) {
148
149
  fail(path + '.flags', 'must be a flag or a list of flags');
149
150
  }
150
- if (message.internaldate !== undefined &&
151
- message.internaldate !== false &&
152
- typeof message.internaldate !== 'string' &&
153
- !(message.internaldate instanceof Date)) {
154
- fail(path + '.internaldate', 'must be a date-time string or a Date');
151
+ // FETCH sends the internal date as it is and SORT ARRIVAL reads it, so it must be a date-time of RFC 3501
152
+ // section 9 (month names in any case, they are sent as "Jan", "Feb", ...) or a valid Date. The save date
153
+ // of the SAVEDATE plugin is a date-time too (RFC 8514 section 4.2)
154
+ for (const key of ['internaldate', 'SAVEDATE']) {
155
+ const value = message[key];
156
+ if (value === undefined || value === false || (key === 'SAVEDATE' && value === null)) {
157
+ continue;
158
+ }
159
+ if (value instanceof Date ? isNaN(value.getTime()) : !isDateTime(value)) {
160
+ fail(path + '.' + key, 'must be a date-time string like "14-Sep-2013 21:22:28 -0300" or a Date, not ' + JSON.stringify(value));
161
+ }
155
162
  }
156
163
  if (message.recent !== undefined && typeof message.recent !== 'boolean') {
157
164
  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
  /**
@@ -8,7 +8,10 @@
8
8
  import { normalizeSystemFlag, checkSystemFlags } from './commands/handlers/flags.js';
9
9
  import { seededRandom } from './random.js';
10
10
  import { MAX_NUMBER } from './numbers.js';
11
- /** the XOAUTH2 session timeout of the default user, and of a user the control API adds without one */
11
+ /**
12
+ * the deprecated XOAUTH2 session timeout of the default user, and of a user the control API adds without one. It is
13
+ * stored and listed but never used, access tokens do not expire
14
+ */
12
15
  const DEFAULT_SESSION_TIMEOUT = 3600 * 1000;
13
16
  /** An error of a failed store operation or control API call, `code` is a RFC 5530 response code or INVALID */
14
17
  class ImapKitError extends Error {
@@ -144,7 +147,7 @@ function checkFlags(server, mailbox, flags, stored) {
144
147
  catch (err) {
145
148
  throw storeError(err.message, 'INVALID');
146
149
  }
147
- if (stored && mailbox.permanentFlags.indexOf(flag) < 0 && !mailbox.allowPermanentFlags) {
150
+ if (stored && !server.isPermanentFlag(mailbox, flag)) {
148
151
  throw storeError('Flag ' + flag + ' is not a permanent flag of ' + mailbox.path, 'INVALID');
149
152
  }
150
153
  if (list.indexOf(flag) < 0) {
@@ -218,7 +221,7 @@ function createMailbox(server, path) {
218
221
  *
219
222
  * @param {Object} server IMAP server
220
223
  * @param {String} path Storage name of the mailbox
221
- * @throws {Error} with a RFC 5530 `code` (NONEXISTENT, CANNOT, HASCHILDREN ...)
224
+ * @throws {Error} with a response `code` (NONEXISTENT, CANNOT, HASCHILDREN ...)
222
225
  */
223
226
  function deleteMailbox(server, path) {
224
227
  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;