imapflow 1.2.7 → 1.2.9

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 (54) 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 +3 -3
  5. package/lib/charsets.js +15 -0
  6. package/lib/commands/append.js +62 -54
  7. package/lib/commands/authenticate.js +99 -52
  8. package/lib/commands/capability.js +12 -2
  9. package/lib/commands/close.js +11 -1
  10. package/lib/commands/compress.js +10 -1
  11. package/lib/commands/copy.js +18 -1
  12. package/lib/commands/create.js +15 -2
  13. package/lib/commands/delete.js +10 -1
  14. package/lib/commands/enable.js +12 -1
  15. package/lib/commands/expunge.js +18 -2
  16. package/lib/commands/fetch.js +39 -4
  17. package/lib/commands/id.js +22 -3
  18. package/lib/commands/idle.js +39 -4
  19. package/lib/commands/list.js +86 -48
  20. package/lib/commands/login.js +12 -1
  21. package/lib/commands/logout.js +11 -2
  22. package/lib/commands/move.js +17 -1
  23. package/lib/commands/namespace.js +32 -2
  24. package/lib/commands/noop.js +6 -1
  25. package/lib/commands/quota.js +33 -14
  26. package/lib/commands/rename.js +13 -1
  27. package/lib/commands/search.js +16 -1
  28. package/lib/commands/select.js +76 -33
  29. package/lib/commands/starttls.js +6 -1
  30. package/lib/commands/status.js +64 -52
  31. package/lib/commands/store.js +27 -4
  32. package/lib/commands/subscribe.js +7 -1
  33. package/lib/commands/unsubscribe.js +7 -1
  34. package/lib/handler/imap-compiler.js +44 -2
  35. package/lib/handler/imap-formal-syntax.js +51 -3
  36. package/lib/handler/imap-handler.js +8 -0
  37. package/lib/handler/imap-parser.js +23 -2
  38. package/lib/handler/imap-stream.js +84 -31
  39. package/lib/handler/parser-instance.js +61 -1
  40. package/lib/handler/token-parser.js +66 -9
  41. package/lib/imap-commands.js +11 -0
  42. package/lib/imap-flow.d.ts +6 -0
  43. package/lib/imap-flow.js +164 -42
  44. package/lib/jp-decoder.js +10 -0
  45. package/lib/limited-passthrough.js +12 -5
  46. package/lib/proxy-connection.js +18 -12
  47. package/lib/search-compiler.js +3 -11
  48. package/lib/special-use.js +23 -16
  49. package/lib/tools.js +218 -13
  50. package/package.json +4 -11
  51. package/test/commands-integration-test.js +33 -0
  52. package/test/special-use-test.js +32 -0
  53. package/assets/favicon.ico +0 -0
  54. package/jsdoc.json +0 -28
@@ -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
@@ -2,7 +2,15 @@
2
2
 
3
3
  const { encodePath, normalizePath } = require('../tools.js');
4
4
 
5
- // Requests info about a mailbox
5
+ /**
6
+ * Requests status information about a mailbox.
7
+ *
8
+ * @param {Object} connection - IMAP connection instance
9
+ * @param {string} path - Mailbox path to query
10
+ * @param {Object} query - Status data items to request (e.g., {messages: true, uidNext: true, unseen: true})
11
+ * @returns {Promise<{path: string, messages?: number, recent?: number, uidNext?: number, uidValidity?: BigInt, unseen?: number, highestModseq?: BigInt}|boolean>} Status information object, or false if preconditions not met or on failure
12
+ * @throws {Error} If the mailbox does not exist
13
+ */
6
14
  module.exports = async (connection, path, query) => {
7
15
  if (![connection.states.AUTHENTICATED, connection.states.SELECTED].includes(connection.state) || !path) {
8
16
  // nothing to do here
@@ -12,8 +20,12 @@ module.exports = async (connection, path, query) => {
12
20
  path = normalizePath(connection, path);
13
21
  let encodedPath = encodePath(connection, path);
14
22
 
23
+ // Use quoted STRING if the encoded path contains '&' (modified UTF-7 marker),
24
+ // otherwise use unquoted ATOM. Same approach as in SELECT.
15
25
  let attributes = [{ type: encodedPath.indexOf('&') >= 0 ? 'STRING' : 'ATOM', value: encodedPath }];
16
26
 
27
+ // Build the list of STATUS data items the caller wants.
28
+ // HIGHESTMODSEQ requires the CONDSTORE extension to be available.
17
29
  let queryAttributes = [];
18
30
  Object.keys(query || {}).forEach(key => {
19
31
  if (!query[key]) {
@@ -48,14 +60,50 @@ module.exports = async (connection, path, query) => {
48
60
  let map = { path };
49
61
  response = await connection.exec('STATUS', attributes, {
50
62
  untagged: {
63
+ // STATUS response: * STATUS <mailbox> (<key> <value> <key> <value> ...)
64
+ // Parsed as alternating key-value pairs (i % 2 pattern).
51
65
  STATUS: async untagged => {
52
- // If STATUS is for current mailbox then update mailbox values
66
+ // If querying the currently selected mailbox, also update the
67
+ // connection's live mailbox state and emit events for changes.
53
68
  let updateCurrent = connection.state === connection.states.SELECTED && path === connection.mailbox.path;
54
69
 
55
70
  let list = untagged.attributes && Array.isArray(untagged.attributes[1]) ? untagged.attributes[1] : false;
56
71
  if (!list) {
57
72
  return;
58
73
  }
74
+ // Maps IMAP STATUS field names to their output key names, type parsers,
75
+ // and optional callbacks to update the live mailbox state.
76
+ const STATUS_FIELD_MAP = {
77
+ MESSAGES: {
78
+ key: 'messages',
79
+ parser: Number,
80
+ updateMailbox: (val, conn) => {
81
+ let prevCount = conn.mailbox.exists;
82
+ if (prevCount !== val) {
83
+ conn.mailbox.exists = val;
84
+ conn.emit('exists', { path, count: val, prevCount });
85
+ }
86
+ }
87
+ },
88
+ RECENT: { key: 'recent', parser: Number },
89
+ UIDNEXT: {
90
+ key: 'uidNext',
91
+ parser: Number,
92
+ updateMailbox: (val, conn) => {
93
+ conn.mailbox.uidNext = val;
94
+ }
95
+ },
96
+ UIDVALIDITY: { key: 'uidValidity', parser: BigInt },
97
+ UNSEEN: { key: 'unseen', parser: Number },
98
+ HIGHESTMODSEQ: {
99
+ key: 'highestModseq',
100
+ parser: BigInt,
101
+ updateMailbox: (val, conn) => {
102
+ conn.mailbox.highestModseq = val;
103
+ }
104
+ }
105
+ };
106
+
59
107
  let key;
60
108
  list.forEach((entry, i) => {
61
109
  if (i % 2 === 0) {
@@ -65,61 +113,22 @@ module.exports = async (connection, path, query) => {
65
113
  if (!key || !entry || typeof entry.value !== 'string') {
66
114
  return;
67
115
  }
68
- let value = false;
69
- switch (key.toUpperCase()) {
70
- case 'MESSAGES':
71
- key = 'messages';
72
- value = !isNaN(entry.value) ? Number(entry.value) : false;
73
- if (updateCurrent) {
74
- let prevCount = connection.mailbox.exists;
75
- if (prevCount !== value) {
76
- // somehow message count in current folder has changed?
77
- connection.mailbox.exists = value;
78
- connection.emit('exists', {
79
- path,
80
- count: value,
81
- prevCount
82
- });
83
- }
84
- }
85
- break;
86
-
87
- case 'RECENT':
88
- key = 'recent';
89
- value = !isNaN(entry.value) ? Number(entry.value) : false;
90
- break;
91
-
92
- case 'UIDNEXT':
93
- key = 'uidNext';
94
- value = !isNaN(entry.value) ? Number(entry.value) : false;
95
- if (updateCurrent) {
96
- connection.mailbox.uidNext = value;
97
- }
98
- break;
99
-
100
- case 'UIDVALIDITY':
101
- key = 'uidValidity';
102
- value = !isNaN(entry.value) ? BigInt(entry.value) : false;
103
- break;
104
-
105
- case 'UNSEEN':
106
- key = 'unseen';
107
- value = !isNaN(entry.value) ? Number(entry.value) : false;
108
- break;
109
-
110
- case 'HIGHESTMODSEQ':
111
- key = 'highestModseq';
112
- value = !isNaN(entry.value) ? BigInt(entry.value) : false;
113
- if (updateCurrent) {
114
- connection.mailbox.highestModseq = value;
115
- }
116
- break;
116
+
117
+ const fieldConfig = STATUS_FIELD_MAP[key.toUpperCase()];
118
+ if (!fieldConfig) {
119
+ return;
117
120
  }
121
+
122
+ const value = !isNaN(entry.value) ? fieldConfig.parser(entry.value) : false;
118
123
  if (value === false) {
119
124
  return;
120
125
  }
121
126
 
122
- map[key] = value;
127
+ map[fieldConfig.key] = value;
128
+
129
+ if (updateCurrent && fieldConfig.updateMailbox) {
130
+ fieldConfig.updateMailbox(value, connection);
131
+ }
123
132
  });
124
133
  }
125
134
  }
@@ -127,6 +136,9 @@ module.exports = async (connection, path, query) => {
127
136
  response.next();
128
137
  return map;
129
138
  } catch (err) {
139
+ // A NO response usually means the mailbox doesn't exist. Verify by
140
+ // running LIST -- if no results, throw a clear NotFound error instead
141
+ // of the generic IMAP error.
130
142
  if (err.responseStatus === 'NO') {
131
143
  let folders = await connection.run('LIST', '', path, { listOnly: true });
132
144
  if (folders && !folders.length) {
@@ -2,7 +2,20 @@
2
2
 
3
3
  const { formatFlag, canUseFlag, enhanceCommandError } = require('../tools.js');
4
4
 
5
- // Updates flags for a message
5
+ /**
6
+ * Updates flags or labels for messages in the selected mailbox.
7
+ *
8
+ * @param {Object} connection - IMAP connection instance
9
+ * @param {string} range - Message sequence number or UID range
10
+ * @param {string|string[]} flags - Flag(s) to set, add, or remove
11
+ * @param {Object} options - Store options
12
+ * @param {boolean} [options.uid] - If true, use UID STORE instead of STORE
13
+ * @param {boolean} [options.useLabels] - If true, operate on Gmail labels instead of flags
14
+ * @param {boolean} [options.silent] - If true, use .SILENT variant to suppress server response
15
+ * @param {string} [options.operation] - Operation type: 'set', 'add', or 'remove'
16
+ * @param {string} [options.unchangedSince] - Only update messages not changed since this modseq value
17
+ * @returns {Promise<boolean>} True on success, false on failure or if nothing to do
18
+ */
6
19
  module.exports = async (connection, range, flags, options) => {
7
20
  if (connection.state !== connection.states.SELECTED || !range || (options.useLabels && !connection.capabilities.has('X-GM-EXT-1'))) {
8
21
  // nothing to do here
@@ -10,19 +23,25 @@ module.exports = async (connection, range, flags, options) => {
10
23
  }
11
24
 
12
25
  options = options || {};
26
+
27
+ // Build the IMAP STORE operation name. The format is:
28
+ // [+|-]FLAGS[.SILENT] or [+|-]X-GM-LABELS
29
+ // Where: no prefix = replace all, + = add, - = remove
30
+ // .SILENT suppresses the server from sending back updated flags (saves bandwidth).
13
31
  let operation;
14
32
 
15
33
  operation = 'FLAGS';
16
34
 
17
35
  if (options.useLabels) {
36
+ // Gmail labels (X-GM-EXT-1 extension): operates on labels instead of IMAP flags
18
37
  operation = 'X-GM-LABELS';
19
38
  } else if (options.silent) {
20
39
  operation = `${operation}.SILENT`;
21
40
  }
22
41
 
42
+ // Prefix determines the operation: none = set (replace), + = add, - = remove
23
43
  switch ((options.operation || '').toLowerCase()) {
24
44
  case 'set':
25
- // do nothing, keep operation value as is
26
45
  break;
27
46
  case 'remove':
28
47
  operation = `-${operation}`;
@@ -33,12 +52,14 @@ module.exports = async (connection, range, flags, options) => {
33
52
  break;
34
53
  }
35
54
 
55
+ // Validate each flag: format it (normalize backslash prefix for system flags),
56
+ // then check if the mailbox's permanentFlags allow it. Removal is always allowed
57
+ // since it doesn't require the flag to be in permanentFlags.
36
58
  flags = (Array.isArray(flags) ? flags : [].concat(flags || []))
37
59
  .map(flag => {
38
60
  flag = formatFlag(flag);
39
61
 
40
62
  if (!canUseFlag(connection.mailbox, flag) && operation !== 'remove') {
41
- // it does not seem that we can set this flag
42
63
  return false;
43
64
  }
44
65
 
@@ -46,13 +67,15 @@ module.exports = async (connection, range, flags, options) => {
46
67
  })
47
68
  .filter(flag => flag);
48
69
 
70
+ // Allow empty flags only for 'set' operation (which clears all flags)
49
71
  if (!flags.length && options.operation !== 'set') {
50
- // nothing to do here
51
72
  return false;
52
73
  }
53
74
 
54
75
  let attributes = [{ type: 'SEQUENCE', value: range }, { type: 'ATOM', value: operation }, flags.map(flag => ({ type: 'ATOM', value: flag }))];
55
76
 
77
+ // CONDSTORE (RFC 7162): UNCHANGEDSINCE modifier prevents updating messages whose
78
+ // mod-sequence is higher than the specified value, avoiding overwriting concurrent changes.
56
79
  if (options.unchangedSince && connection.enabled.has('CONDSTORE') && !connection.mailbox.noModseq) {
57
80
  attributes.push([
58
81
  {
@@ -2,7 +2,13 @@
2
2
 
3
3
  const { encodePath, normalizePath, enhanceCommandError } = require('../tools.js');
4
4
 
5
- // Subscribes to a mailbox
5
+ /**
6
+ * Subscribes to a mailbox.
7
+ *
8
+ * @param {Object} connection - IMAP connection instance
9
+ * @param {string} path - Mailbox path to subscribe to
10
+ * @returns {Promise<boolean|undefined>} True on success, false 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)) {
8
14
  // nothing to do here
@@ -2,7 +2,13 @@
2
2
 
3
3
  const { encodePath, normalizePath, enhanceCommandError } = require('../tools.js');
4
4
 
5
- // Unsubscribes from a mailbox
5
+ /**
6
+ * Unsubscribes from a mailbox.
7
+ *
8
+ * @param {Object} connection - IMAP connection instance
9
+ * @param {string} path - Mailbox path to unsubscribe from
10
+ * @returns {Promise<boolean|undefined>} True on success, false 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)) {
8
14
  // nothing to do here