imapflow 1.2.8 → 1.2.10

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 (58) hide show
  1. package/.ncurc.js +1 -1
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +36 -58
  5. package/eslint.config.js +18 -16
  6. package/lib/charsets.js +15 -0
  7. package/lib/commands/append.js +62 -54
  8. package/lib/commands/authenticate.js +99 -52
  9. package/lib/commands/capability.js +12 -2
  10. package/lib/commands/close.js +11 -1
  11. package/lib/commands/compress.js +10 -1
  12. package/lib/commands/copy.js +18 -1
  13. package/lib/commands/create.js +15 -2
  14. package/lib/commands/delete.js +10 -1
  15. package/lib/commands/enable.js +12 -1
  16. package/lib/commands/expunge.js +18 -2
  17. package/lib/commands/fetch.js +39 -4
  18. package/lib/commands/id.js +22 -3
  19. package/lib/commands/idle.js +39 -4
  20. package/lib/commands/list.js +86 -48
  21. package/lib/commands/login.js +12 -1
  22. package/lib/commands/logout.js +11 -2
  23. package/lib/commands/move.js +17 -1
  24. package/lib/commands/namespace.js +32 -2
  25. package/lib/commands/noop.js +6 -1
  26. package/lib/commands/quota.js +33 -14
  27. package/lib/commands/rename.js +13 -1
  28. package/lib/commands/search.js +16 -1
  29. package/lib/commands/select.js +76 -33
  30. package/lib/commands/starttls.js +6 -1
  31. package/lib/commands/status.js +64 -52
  32. package/lib/commands/store.js +27 -4
  33. package/lib/commands/subscribe.js +7 -1
  34. package/lib/commands/unsubscribe.js +7 -1
  35. package/lib/handler/imap-compiler.js +44 -2
  36. package/lib/handler/imap-formal-syntax.js +51 -3
  37. package/lib/handler/imap-handler.js +8 -0
  38. package/lib/handler/imap-parser.js +23 -2
  39. package/lib/handler/imap-stream.js +84 -31
  40. package/lib/handler/parser-instance.js +61 -1
  41. package/lib/handler/token-parser.js +66 -9
  42. package/lib/imap-commands.js +11 -0
  43. package/lib/imap-flow.d.ts +6 -0
  44. package/lib/imap-flow.js +175 -46
  45. package/lib/jp-decoder.js +10 -0
  46. package/lib/limited-passthrough.js +12 -5
  47. package/lib/proxy-connection.js +18 -12
  48. package/lib/search-compiler.js +3 -11
  49. package/lib/special-use.js +23 -16
  50. package/lib/tools.js +218 -13
  51. package/package.json +5 -17
  52. package/test/commands-integration-test.js +33 -0
  53. package/test/connection-edge-cases-test.js +105 -0
  54. package/test/special-use-test.js +32 -0
  55. package/.babelrc +0 -6
  56. package/.eslintrc +0 -16
  57. package/assets/favicon.ico +0 -0
  58. package/jsdoc.json +0 -28
@@ -1,6 +1,11 @@
1
1
  'use strict';
2
2
 
3
- // Requests NAMESPACE info from server
3
+ /**
4
+ * Requests NAMESPACE info from the server.
5
+ *
6
+ * @param {Object} connection - IMAP connection instance
7
+ * @returns {Promise<{prefix: string, delimiter: string}|{error: boolean, status: string, text: string}>} The primary personal namespace, or an error object on failure
8
+ */
4
9
  module.exports = async connection => {
5
10
  if (![connection.states.AUTHENTICATED, connection.states.SELECTED].includes(connection.state)) {
6
11
  // nothing to do here
@@ -8,8 +13,12 @@ module.exports = async connection => {
8
13
  }
9
14
 
10
15
  if (!connection.capabilities.has('NAMESPACE')) {
11
- // try to derive from listing
16
+ // Fallback: when the server does not support the NAMESPACE extension (RFC 2342),
17
+ // derive the prefix and delimiter from a LIST "" "" command, which returns
18
+ // the hierarchy delimiter and root name for the default mailbox hierarchy.
12
19
  let { prefix, delimiter } = await getListPrefix(connection);
20
+ // Ensure the prefix ends with the delimiter so that appending a mailbox name
21
+ // produces a valid path (e.g., "INBOX." + "Sent" = "INBOX.Sent").
13
22
  if (delimiter && prefix && prefix.charAt(prefix.length - 1) !== delimiter) {
14
23
  prefix += delimiter;
15
24
  }
@@ -28,6 +37,11 @@ module.exports = async connection => {
28
37
  let map = {};
29
38
  response = await connection.exec('NAMESPACE', false, {
30
39
  untagged: {
40
+ // The NAMESPACE response (RFC 2342) contains exactly three sections:
41
+ // [0] = personal namespaces (user's own mailboxes)
42
+ // [1] = other users' namespaces (shared by other users)
43
+ // [2] = shared namespaces (public/organizational folders)
44
+ // Each section is either NIL or a list of (prefix, delimiter) pairs.
31
45
  NAMESPACE: async untagged => {
32
46
  if (!untagged.attributes || !untagged.attributes.length) {
33
47
  return;
@@ -60,10 +74,18 @@ module.exports = async connection => {
60
74
  }
61
75
  };
62
76
 
77
+ /**
78
+ * Derives namespace prefix and delimiter from a LIST command when NAMESPACE is not supported.
79
+ *
80
+ * @param {Object} connection - IMAP connection instance
81
+ * @returns {Promise<{prefix?: string, delimiter?: string, flags?: Set}>} Object with prefix, delimiter, and flags, or empty object on failure
82
+ */
63
83
  async function getListPrefix(connection) {
64
84
  let response;
65
85
  try {
66
86
  let map = {};
87
+ // LIST "" "" is a special form that returns only the hierarchy delimiter
88
+ // and the root name, without listing any actual mailboxes.
67
89
  response = await connection.exec('LIST', ['', ''], {
68
90
  untagged: {
69
91
  LIST: async untagged => {
@@ -88,6 +110,12 @@ async function getListPrefix(connection) {
88
110
  }
89
111
  }
90
112
 
113
+ /**
114
+ * Parses namespace information from an IMAP NAMESPACE response attribute.
115
+ *
116
+ * @param {Array} attribute - Namespace attribute array from the server response
117
+ * @returns {Array<{prefix: string, delimiter: string}>|boolean} Array of namespace entries, or false if empty
118
+ */
91
119
  function getNamsepaceInfo(attribute) {
92
120
  if (!attribute || !attribute.length) {
93
121
  return false;
@@ -99,6 +127,8 @@ function getNamsepaceInfo(attribute) {
99
127
  let prefix = entry[0].value;
100
128
  let delimiter = entry[1].value;
101
129
 
130
+ // Append the delimiter to the prefix if it doesn't already end with one,
131
+ // so callers can construct full paths by simply concatenating prefix + name.
102
132
  if (delimiter && prefix && prefix.charAt(prefix.length - 1) !== delimiter) {
103
133
  prefix += delimiter;
104
134
  }
@@ -1,6 +1,11 @@
1
1
  'use strict';
2
2
 
3
- // Sends a NO-OP command
3
+ /**
4
+ * Sends a NOOP command to the server.
5
+ *
6
+ * @param {Object} connection - IMAP connection instance
7
+ * @returns {Promise<boolean>} True on success, false on failure
8
+ */
4
9
  module.exports = async connection => {
5
10
  try {
6
11
  let response = await connection.exec('NOOP', false, { comment: 'Requested by command' });
@@ -2,7 +2,13 @@
2
2
 
3
3
  const { encodePath, normalizePath, enhanceCommandError } = require('../tools.js');
4
4
 
5
- // Requests quota information for a mailbox
5
+ /**
6
+ * Requests quota information for a mailbox.
7
+ *
8
+ * @param {Object} connection - IMAP connection instance
9
+ * @param {string} path - Mailbox path to query quota for
10
+ * @returns {Promise<{path: string, quotaRoot?: string, storage?: {usage: number, limit: number, status: string}, message?: {usage: number, limit: number, status: string}}|boolean|undefined>} Quota information object, false if QUOTA not supported or on failure, or undefined if preconditions not met
11
+ */
6
12
  module.exports = async (connection, path) => {
7
13
  if (![connection.states.AUTHENTICATED, connection.states.SELECTED].includes(connection.state) || !path) {
8
14
  // nothing to do here
@@ -17,6 +23,11 @@ module.exports = async (connection, path) => {
17
23
 
18
24
  let map = { path };
19
25
 
26
+ // Parse a QUOTA response. The resource list uses a repeating triplet pattern (i % 3):
27
+ // position 0: resource name (e.g., "STORAGE", "MESSAGE")
28
+ // position 1: current usage
29
+ // position 2: limit
30
+ // Storage values are in KB on the wire; multiply by 1024 to report bytes.
20
31
  let processQuotaResponse = untagged => {
21
32
  let attributes = untagged.attributes && untagged.attributes[1];
22
33
  if (!attributes || !attributes.length) {
@@ -25,10 +36,13 @@ module.exports = async (connection, path) => {
25
36
 
26
37
  let key = false;
27
38
  attributes.forEach((attribute, i) => {
28
- if (i % 3 === 0) {
39
+ const position = i % 3;
40
+
41
+ if (position === 0) {
29
42
  key = attribute && typeof attribute.value === 'string' ? attribute.value.toLowerCase() : false;
30
43
  return;
31
44
  }
45
+
32
46
  if (!key) {
33
47
  return;
34
48
  }
@@ -38,21 +52,18 @@ module.exports = async (connection, path) => {
38
52
  return;
39
53
  }
40
54
 
41
- if (i % 3 === 1) {
42
- // usage
43
- if (!map[key]) {
44
- map[key] = {};
45
- }
46
- map[key].usage = value * (key === 'storage' ? 1024 : 1);
55
+ if (!map[key]) {
56
+ map[key] = {};
47
57
  }
48
58
 
49
- if (i % 3 === 2) {
50
- // limit
51
- if (!map[key]) {
52
- map[key] = {};
53
- }
54
- map[key].limit = value * (key === 'storage' ? 1024 : 1);
59
+ // Storage quota is reported in KB by IMAP; convert to bytes for consistency
60
+ const multiplier = key === 'storage' ? 1024 : 1;
55
61
 
62
+ if (position === 1) {
63
+ map[key].usage = value * multiplier;
64
+ } else if (position === 2) {
65
+ map[key].limit = value * multiplier;
66
+ // Calculate usage percentage for convenient display
56
67
  if (map[key].limit) {
57
68
  map[key].status = Math.round(((map[key].usage || 0) / map[key].limit) * 100) + '%';
58
69
  }
@@ -63,8 +74,13 @@ module.exports = async (connection, path) => {
63
74
  let quotaFound = false;
64
75
  let response;
65
76
  try {
77
+ // Two-step quota lookup: GETQUOTAROOT identifies the quota root for a mailbox,
78
+ // and the server usually sends the QUOTA response inline. Some servers only
79
+ // send the root name and require a separate GETQUOTA command.
66
80
  response = await connection.exec('GETQUOTAROOT', [{ type: 'ATOM', value: encodePath(connection, path) }], {
67
81
  untagged: {
82
+ // QUOTAROOT response tells us which quota root applies to this mailbox.
83
+ // A mailbox may have zero or one quota root.
68
84
  QUOTAROOT: async untagged => {
69
85
  let quotaRoot =
70
86
  untagged.attributes && untagged.attributes[1] && typeof untagged.attributes[1].value === 'string'
@@ -74,6 +90,7 @@ module.exports = async (connection, path) => {
74
90
  map.quotaRoot = quotaRoot;
75
91
  }
76
92
  },
93
+ // QUOTA response provides the actual resource usage and limits
77
94
  QUOTA: async untagged => {
78
95
  quotaFound = true;
79
96
  processQuotaResponse(untagged);
@@ -83,6 +100,8 @@ module.exports = async (connection, path) => {
83
100
 
84
101
  response.next();
85
102
 
103
+ // Fallback: if we got a quota root but no QUOTA response inline,
104
+ // explicitly request quota for that root.
86
105
  if (map.quotaRoot && !quotaFound) {
87
106
  response = await connection.exec('GETQUOTA', [{ type: 'ATOM', value: map.quotaRoot }], {
88
107
  untagged: {
@@ -2,16 +2,28 @@
2
2
 
3
3
  const { encodePath, normalizePath, enhanceCommandError } = require('../tools.js');
4
4
 
5
- // Renames existing mailbox
5
+ /**
6
+ * Renames an existing mailbox.
7
+ *
8
+ * @param {Object} connection - IMAP connection instance
9
+ * @param {string} path - Current mailbox path
10
+ * @param {string} newPath - New mailbox path
11
+ * @returns {Promise<{path: string, newPath: string}|undefined>} Object with old and new paths, or undefined if preconditions not met
12
+ * @throws {Error} If the RENAME command fails
13
+ */
6
14
  module.exports = async (connection, path, newPath) => {
7
15
  if (![connection.states.AUTHENTICATED, connection.states.SELECTED].includes(connection.state)) {
8
16
  // nothing to do here
9
17
  return;
10
18
  }
11
19
 
20
+ // Normalize both paths (resolve special names, apply namespace prefix) and encode
21
+ // them for the IMAP wire format (modified UTF-7 for non-ASCII characters).
12
22
  path = normalizePath(connection, path);
13
23
  newPath = normalizePath(connection, newPath);
14
24
 
25
+ // Must close/deselect the mailbox before renaming if it's currently selected,
26
+ // as IMAP servers will not rename an active mailbox.
15
27
  if (connection.state === connection.states.SELECTED && connection.mailbox.path === path) {
16
28
  await connection.run('CLOSE');
17
29
  }
@@ -3,7 +3,15 @@
3
3
  const { enhanceCommandError } = require('../tools.js');
4
4
  const { searchCompiler } = require('../search-compiler.js');
5
5
 
6
- // Updates flags for a message
6
+ /**
7
+ * Searches for messages matching the specified criteria.
8
+ *
9
+ * @param {Object} connection - IMAP connection instance
10
+ * @param {Object|boolean} query - Search query object, or true/empty object to match all messages
11
+ * @param {Object} [options] - Search options
12
+ * @param {boolean} [options.uid] - If true, use UID SEARCH instead of SEARCH
13
+ * @returns {Promise<number[]|boolean>} Sorted array of matching sequence numbers or UIDs, or false on failure
14
+ */
7
15
  module.exports = async (connection, query, options) => {
8
16
  if (connection.state !== connection.states.SELECTED) {
9
17
  // nothing to do here
@@ -14,6 +22,10 @@ module.exports = async (connection, query, options) => {
14
22
 
15
23
  let attributes;
16
24
 
25
+ // Three query branches:
26
+ // 1. Empty/truthy/all-only query -> use IMAP "SEARCH ALL" to match every message
27
+ // 2. Non-empty object -> compile into IMAP SEARCH criteria via searchCompiler
28
+ // 3. Anything else (unexpected type) -> bail out with false
17
29
  if (!query || query === true || (typeof query === 'object' && (!Object.keys(query).length || (Object.keys(query).length === 1 && query.all)))) {
18
30
  // search for all messages
19
31
  attributes = [{ type: 'ATOM', value: 'ALL' }];
@@ -24,6 +36,8 @@ module.exports = async (connection, query, options) => {
24
36
  return false;
25
37
  }
26
38
 
39
+ // Use a Set to deduplicate sequence numbers/UIDs -- servers may return
40
+ // duplicates across multiple untagged SEARCH responses.
27
41
  let results = new Set();
28
42
  let response;
29
43
  try {
@@ -41,6 +55,7 @@ module.exports = async (connection, query, options) => {
41
55
  }
42
56
  });
43
57
  response.next();
58
+ // Sort numerically for consistent, predictable output order
44
59
  return Array.from(results).sort((a, b) => a - b);
45
60
  } catch (err) {
46
61
  await enhanceCommandError(err);
@@ -2,7 +2,18 @@
2
2
 
3
3
  const { encodePath, normalizePath, enhanceCommandError } = require('../tools.js');
4
4
 
5
- // Selects a mailbox
5
+ /**
6
+ * Selects or examines a mailbox, making it the current mailbox for subsequent operations.
7
+ *
8
+ * @param {Object} connection - IMAP connection instance
9
+ * @param {string} path - Mailbox path to select
10
+ * @param {Object} [options] - Select options
11
+ * @param {boolean} [options.readOnly] - If true, use EXAMINE instead of SELECT (read-only access)
12
+ * @param {string} [options.changedSince] - QRESYNC modseq value to fetch changes since
13
+ * @param {BigInt} [options.uidValidity] - QRESYNC UID validity value
14
+ * @returns {Promise<Object|undefined>} Mailbox info object with path, flags, exists, uidNext, uidValidity, highestModseq, etc., or undefined if preconditions not met
15
+ * @throws {Error} If the SELECT/EXAMINE command fails
16
+ */
6
17
  module.exports = async (connection, path, options) => {
7
18
  if (![connection.states.AUTHENTICATED, connection.states.SELECTED].includes(connection.state)) {
8
19
  // nothing to do here
@@ -12,6 +23,8 @@ module.exports = async (connection, path, options) => {
12
23
 
13
24
  path = normalizePath(connection, path);
14
25
 
26
+ // Ensure we have folder metadata (delimiter, flags, specialUse) by running LIST if needed.
27
+ // This is cached in connection.folders to avoid repeated LIST calls.
15
28
  if (!connection.folders.has(path)) {
16
29
  let folders = await connection.run('LIST', '', path);
17
30
  if (!folders) {
@@ -35,6 +48,9 @@ module.exports = async (connection, path, options) => {
35
48
  });
36
49
  }
37
50
 
51
+ // QRESYNC (RFC 7162): allows efficient mailbox resynchronization by sending
52
+ // the last known UIDVALIDITY and HIGHESTMODSEQ. Server responds with only
53
+ // the changes (new flags, expunged UIDs) since that point.
38
54
  let extraArgs = [];
39
55
  if (connection.enabled.has('QRESYNC') && options.changedSince && options.uidValidity) {
40
56
  extraArgs.push([
@@ -49,6 +65,9 @@ module.exports = async (connection, path, options) => {
49
65
 
50
66
  let encodedPath = encodePath(connection, path);
51
67
 
68
+ // SELECT opens the mailbox read-write; EXAMINE opens it read-only.
69
+ // Path encoding: if the encoded path contains '&' (UTF-7 encoding marker),
70
+ // send as quoted STRING to avoid parser issues with the ampersand.
52
71
  let selectCommand = {
53
72
  command: !options.readOnly ? 'SELECT' : 'EXAMINE',
54
73
  arguments: [{ type: encodedPath.indexOf('&') >= 0 ? 'STRING' : 'ATOM', value: encodedPath }].concat(extraArgs || [])
@@ -56,15 +75,22 @@ module.exports = async (connection, path, options) => {
56
75
 
57
76
  response = await connection.exec(selectCommand.command, selectCommand.arguments, {
58
77
  untagged: {
78
+ // Untagged OK responses carry response codes in brackets, e.g.:
79
+ // * OK [UIDVALIDITY 1234] UIDs valid
80
+ // * OK [PERMANENTFLAGS (\Seen \Answered \*)] Flags permitted
81
+ // The section array holds the parsed bracket contents: section[0] is the
82
+ // key (e.g., "UIDVALIDITY"), section[1] is the value or list.
59
83
  OK: async untagged => {
60
84
  if (!untagged.attributes || !untagged.attributes.length) {
61
85
  return;
62
86
  }
63
87
  let section = !untagged.attributes[0].value && untagged.attributes[0].section;
88
+ // Handle response codes with a key-value pair (section has 2+ elements)
64
89
  if (section && section.length > 1 && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
65
90
  let key = section[0].value.toLowerCase();
66
91
  let value;
67
92
 
93
+ // Value can be a single string or a list of strings (e.g., PERMANENTFLAGS)
68
94
  if (typeof section[1].value === 'string') {
69
95
  value = section[1].value;
70
96
  } else if (Array.isArray(section[1])) {
@@ -72,6 +98,10 @@ module.exports = async (connection, path, options) => {
72
98
  }
73
99
 
74
100
  switch (key) {
101
+ // CONDSTORE (RFC 7162): highest mod-sequence value for the mailbox.
102
+ // Used for incremental sync -- clients compare against their cached
103
+ // value to detect changes. Stored as BigInt since modseq values
104
+ // can exceed Number.MAX_SAFE_INTEGER.
75
105
  case 'highestmodseq':
76
106
  key = 'highestModseq';
77
107
  if (/^[0-9]+$/.test(value)) {
@@ -79,6 +109,9 @@ module.exports = async (connection, path, options) => {
79
109
  }
80
110
  break;
81
111
 
112
+ // OBJECTID (RFC 8474): server-assigned unique mailbox identifier.
113
+ // Unlike path, this ID survives renames. Value comes as a
114
+ // parenthesized list, so extract the first (only) element.
82
115
  case 'mailboxid':
83
116
  key = 'mailboxId';
84
117
  if (Array.isArray(value) && value.length) {
@@ -86,16 +119,23 @@ module.exports = async (connection, path, options) => {
86
119
  }
87
120
  break;
88
121
 
122
+ // Flags that the client can change permanently on messages in
123
+ // this mailbox. Includes \* if the server allows custom flags.
89
124
  case 'permanentflags':
90
125
  key = 'permanentFlags';
91
126
  value = new Set(value);
92
127
  break;
93
128
 
129
+ // The next UID that will be assigned to a new message in this
130
+ // mailbox. Useful for detecting new arrivals.
94
131
  case 'uidnext':
95
132
  key = 'uidNext';
96
133
  value = Number(value);
97
134
  break;
98
135
 
136
+ // Unique identifier validity value. If this changes between
137
+ // sessions, all previously cached UIDs are invalid and the
138
+ // client must re-sync from scratch.
99
139
  case 'uidvalidity':
100
140
  key = 'uidValidity';
101
141
  if (/^[0-9]+$/.test(value)) {
@@ -107,9 +147,12 @@ module.exports = async (connection, path, options) => {
107
147
  map[key] = value;
108
148
  }
109
149
 
150
+ // Handle response codes with only a keyword (no value), e.g., [NOMODSEQ]
110
151
  if (section && section.length === 1 && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
111
152
  let key = section[0].value.toLowerCase();
112
153
  switch (key) {
154
+ // NOMODSEQ means the mailbox does not support mod-sequences.
155
+ // CONDSTORE/QRESYNC features are unavailable for this mailbox.
113
156
  case 'nomodseq':
114
157
  key = 'noModseq';
115
158
  map[key] = true;
@@ -117,6 +160,9 @@ module.exports = async (connection, path, options) => {
117
160
  }
118
161
  }
119
162
  },
163
+
164
+ // Untagged FLAGS response lists all flags defined for this mailbox
165
+ // (both system flags and custom flags). Example: * FLAGS (\Seen \Answered \Flagged)
120
166
  FLAGS: async untagged => {
121
167
  if (!untagged.attributes || (!untagged.attributes.length && Array.isArray(untagged.attributes[0]))) {
122
168
  return;
@@ -124,6 +170,9 @@ module.exports = async (connection, path, options) => {
124
170
  let flags = untagged.attributes[0].map(flag => (typeof flag.value === 'string' ? flag.value : false)).filter(flag => flag);
125
171
  map.flags = new Set(flags);
126
172
  },
173
+
174
+ // Untagged EXISTS response: "* <count> EXISTS" tells us the total number
175
+ // of messages in the mailbox. The count is in the command field (numeric prefix).
127
176
  EXISTS: async untagged => {
128
177
  let num = Number(untagged.command);
129
178
  if (isNaN(num)) {
@@ -132,58 +181,51 @@ module.exports = async (connection, path, options) => {
132
181
 
133
182
  map.exists = num;
134
183
  },
184
+
185
+ // VANISHED responses (QRESYNC): server reports UIDs that have been expunged
186
+ // since the client's last known state. Only received when QRESYNC was requested.
187
+ // A dummy mailbox object is passed because the mailbox isn't officially open yet.
135
188
  VANISHED: async untagged => {
136
- await connection.untaggedVanished(
137
- untagged,
138
- // mailbox is not yet open, so use a dummy mailbox object
139
- { path, uidNext: false, uidValidity: false }
140
- );
189
+ await connection.untaggedVanished(untagged, { path, uidNext: false, uidValidity: false });
141
190
  },
142
- // we should only get an untagged FETCH for a SELECT/EXAMINE if QRESYNC was asked for
191
+
192
+ // Untagged FETCH during SELECT/EXAMINE: only occurs with QRESYNC, delivering
193
+ // updated flags for messages that changed since the client's last modseq.
143
194
  FETCH: async untagged => {
144
- await connection.untaggedFetch(
145
- untagged,
146
- // mailbox is not yet open, so use a dummy mailbox object
147
- { path, uidNext: false, uidValidity: false }
148
- );
195
+ await connection.untaggedFetch(untagged, { path, uidNext: false, uidValidity: false });
149
196
  }
150
197
  }
151
198
  });
152
199
 
200
+ // The tagged OK response to SELECT/EXAMINE includes [READ-ONLY] or [READ-WRITE]
201
+ // in its response code, indicating the access mode the server granted.
153
202
  let section = !response.response.attributes[0].value && response.response.attributes[0].section;
154
203
  if (section && section.length && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
155
- switch (section[0].value.toUpperCase()) {
156
- case 'READ-ONLY':
157
- map.readOnly = true;
158
- break;
159
- case 'READ-WRITE':
160
- default:
161
- map.readOnly = false;
162
- break;
163
- }
204
+ map.readOnly = section[0].value.toUpperCase() === 'READ-ONLY';
164
205
  }
165
206
 
166
- if (
167
- map.qresync &&
168
- // UIDVALIDITY must be the same
169
- (options.uidValidity !== map.uidValidity ||
170
- // HIGHESTMODSEQ response must be present
171
- !map.highestModseq ||
172
- // NOMODSEQ is not allowed
173
- map.noModseq)
174
- ) {
175
- // QRESYNC does not apply here, so unset it
207
+ // Validate QRESYNC preconditions (RFC 7162 Section 3.2.5):
208
+ // QRESYNC results are only valid if UIDVALIDITY matches, HIGHESTMODSEQ is
209
+ // present, and the mailbox supports mod-sequences. If any condition fails,
210
+ // the client cannot trust the incremental updates and must do a full resync.
211
+ if (map.qresync && (options.uidValidity !== map.uidValidity || !map.highestModseq || map.noModseq)) {
176
212
  map.qresync = false;
177
213
  }
178
214
 
215
+ // Transition mailbox state: save previous mailbox reference, temporarily
216
+ // clear it, then emit events and set the new mailbox.
179
217
  let currentMailbox = connection.mailbox;
180
218
  connection.mailbox = false;
181
219
 
220
+ // Emit mailboxClose if we're switching from a different mailbox.
221
+ // Re-selecting the same mailbox (e.g., for resync) does not trigger close/open.
182
222
  if (currentMailbox && currentMailbox.path !== path) {
183
223
  connection.emit('mailboxClose', currentMailbox);
184
224
  }
185
225
 
186
226
  connection.mailbox = map;
227
+ // Save the SELECT command for potential re-use (e.g., NOOP fallback polling
228
+ // re-issues the SELECT to detect changes on servers without IDLE support).
187
229
  connection.currentSelectCommand = selectCommand;
188
230
  connection.state = connection.states.SELECTED;
189
231
 
@@ -196,9 +238,10 @@ module.exports = async (connection, path, options) => {
196
238
  } catch (err) {
197
239
  await enhanceCommandError(err);
198
240
 
241
+ // If SELECT/EXAMINE fails while a mailbox was already selected, we must
242
+ // reset to AUTHENTICATED state since the server has implicitly deselected
243
+ // the previous mailbox on failure (RFC 3501 Section 6.3.1).
199
244
  if (connection.state === connection.states.SELECTED) {
200
- // reset selected state
201
-
202
245
  let currentMailbox = connection.mailbox;
203
246
 
204
247
  connection.mailbox = false;
@@ -1,6 +1,11 @@
1
1
  'use strict';
2
2
 
3
- // Requests STARTTLS info from server
3
+ /**
4
+ * Initiates STARTTLS connection upgrade.
5
+ *
6
+ * @param {Object} connection - IMAP connection instance
7
+ * @returns {Promise<boolean>} True if STARTTLS was initiated, false if not supported or already secure
8
+ */
4
9
  module.exports = async connection => {
5
10
  if (!connection.capabilities.has('STARTTLS') || connection.secureConnection) {
6
11
  // nothing to do here