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
@@ -2,7 +2,15 @@
2
2
 
3
3
  const { formatDateTime } = require('../tools.js');
4
4
 
5
- // Sends ID info to server and updates server info data based on response
5
+ /**
6
+ * Sends ID info to the server and updates server info data based on the response.
7
+ *
8
+ * @param {Object} connection - IMAP connection instance
9
+ * @param {Object} clientInfo - Client identification key-value pairs to send to the server
10
+ * @returns {Promise<Object|boolean|undefined>} Server information map, false on failure, or undefined if ID not supported
11
+ */
12
+ // RFC 2971: The ID command exchanges client/server implementation info
13
+ // (name, version, vendor, etc.) for diagnostic and compatibility purposes.
6
14
  module.exports = async (connection, clientInfo) => {
7
15
  if (!connection.capabilities.has('ID')) {
8
16
  // nothing to do here
@@ -13,7 +21,8 @@ module.exports = async (connection, clientInfo) => {
13
21
  try {
14
22
  let map = {};
15
23
 
16
- // convert object into an array of value tuples
24
+ // Convert the clientInfo object into a flat array of alternating key-value strings
25
+ // for the IMAP wire format: ("key1" "value1" "key2" "value2" ...)
17
26
  let formattedClientInfo = !clientInfo
18
27
  ? null
19
28
  : Object.keys(clientInfo)
@@ -28,6 +37,8 @@ module.exports = async (connection, clientInfo) => {
28
37
 
29
38
  response = await connection.exec('ID', [formattedClientInfo], {
30
39
  untagged: {
40
+ // Parse the server's ID response: a flat list of alternating key-value atoms.
41
+ // Even indices (i % 2 === 0) are keys, odd indices are the corresponding values.
31
42
  ID: async untagged => {
32
43
  let params = untagged.attributes && untagged.attributes[0];
33
44
  let key;
@@ -50,10 +61,18 @@ module.exports = async (connection, clientInfo) => {
50
61
  }
51
62
  };
52
63
 
64
+ /**
65
+ * Formats a client info value for the ID command.
66
+ *
67
+ * @param {string} key - The info key name
68
+ * @param {*} value - The value to format
69
+ * @returns {string} Formatted value string
70
+ */
53
71
  function formatValue(key, value) {
54
72
  switch (key.toLowerCase()) {
55
73
  case 'date':
56
- // Date has to be in imap date-time format
74
+ // RFC 2971 requires the "date" field to use IMAP date-time format
75
+ // (e.g., "06-Feb-2026 12:00:00 +0000"), not ISO 8601 or other formats.
57
76
  return formatDateTime(value);
58
77
  default:
59
78
  // Other values are strings without newlines
@@ -2,18 +2,31 @@
2
2
 
3
3
  const NOOP_INTERVAL = 2 * 60 * 1000;
4
4
 
5
+ /**
6
+ * Runs a single IDLE session on the connection.
7
+ *
8
+ * @param {Object} connection - IMAP connection instance
9
+ * @returns {Promise<void|boolean>} Void on success, false on failure
10
+ */
5
11
  async function runIdle(connection) {
6
12
  let response;
7
13
 
14
+ // Queue of promises waiting for IDLE to break. When another command needs to run,
15
+ // it calls connection.preCheck() which queues a promise here and sends DONE to break IDLE.
8
16
  let preCheckWaitQueue = [];
9
17
  try {
10
18
  connection.idling = true;
11
19
 
12
- //let idleSent = false;
20
+ // State flags for the IDLE lifecycle:
21
+ // - doneRequested: someone wants to break IDLE (e.g., to run another command)
22
+ // - doneSent: we've already sent the DONE command to server
23
+ // - canEnd: server has acknowledged IDLE with "+" continuation, so DONE can be sent
13
24
  let doneRequested = false;
14
25
  let doneSent = false;
15
26
  let canEnd = false;
16
27
 
28
+ // preCheck sends DONE to break out of IDLE. Called when another command
29
+ // needs to run on this connection (e.g., a FETCH or STORE from user code).
17
30
  let preCheck = async () => {
18
31
  doneRequested = true;
19
32
  if (canEnd && !doneSent) {
@@ -37,6 +50,8 @@ async function runIdle(connection) {
37
50
  }
38
51
  };
39
52
 
53
+ // Public interface for breaking IDLE. Returns a promise that resolves when
54
+ // IDLE is actually broken and the connection is free for other commands.
40
55
  let connectionPreCheck = () => {
41
56
  let handler = new Promise((resolve, reject) => {
42
57
  preCheckWaitQueue.push({ resolve, reject });
@@ -57,9 +72,13 @@ async function runIdle(connection) {
57
72
  return handler;
58
73
  };
59
74
 
75
+ // Register preCheck on the connection so other code (e.g., getMailboxLock) can break IDLE
60
76
  connection.preCheck = connectionPreCheck;
61
77
 
62
78
  response = await connection.exec('IDLE', false, {
79
+ // Server responds with "+" continuation to acknowledge IDLE mode.
80
+ // After this, the server will push untagged responses for mailbox changes.
81
+ // We can now safely send DONE if a break was already requested.
63
82
  onPlusTag: async () => {
64
83
  connection.log.debug({ msg: `Initiated IDLE, waiting for server input`, lockId: connection.currentLock?.lockId, doneRequested });
65
84
  canEnd = true;
@@ -76,7 +95,8 @@ async function runIdle(connection) {
76
95
  }
77
96
  });
78
97
 
79
- // unset before response.next() if preCheck function is not already cleared (usually is)
98
+ // Clean up: unset preCheck and resolve any remaining waiters before processing the response.
99
+ // Usually preCheck is already cleared by the DONE handler, but this handles edge cases.
80
100
  if (typeof connection.preCheck === 'function' && connection.preCheck === connectionPreCheck) {
81
101
  connection.log.trace({
82
102
  msg: 'Clearing pre-check function',
@@ -109,16 +129,26 @@ async function runIdle(connection) {
109
129
  }
110
130
  }
111
131
 
112
- // Listens for changes in mailbox
132
+ /**
133
+ * Listens for changes in the selected mailbox using IDLE or NOOP polling fallback.
134
+ *
135
+ * @param {Object} connection - IMAP connection instance
136
+ * @param {number} [maxIdleTime] - Maximum time in milliseconds to stay in IDLE before restarting
137
+ * @returns {Promise<void|boolean|undefined>} Void on success, false on failure, or undefined if not in SELECTED state
138
+ */
113
139
  module.exports = async (connection, maxIdleTime) => {
114
140
  if (connection.state !== connection.states.SELECTED) {
115
141
  // nothing to do here
116
142
  return;
117
143
  }
118
144
 
145
+ // If server supports IDLE (RFC 2177), use it for real-time push notifications.
146
+ // Otherwise, fall back to periodic polling with NOOP/STATUS/SELECT.
119
147
  if (connection.capabilities.has('IDLE')) {
120
148
  let idleTimer;
121
149
  let stillIdling = false;
150
+ // IDLE loop: runs IDLE, and if maxIdleTime is reached, breaks and restarts
151
+ // to keep the connection alive (some servers drop long-running IDLEs).
122
152
  let runIdleLoop = async () => {
123
153
  if (maxIdleTime) {
124
154
  idleTimer = setTimeout(() => {
@@ -143,13 +173,15 @@ module.exports = async (connection, maxIdleTime) => {
143
173
  return runIdleLoop();
144
174
  }
145
175
 
176
+ // Fallback for servers without IDLE support: poll at regular intervals using
177
+ // NOOP (default), STATUS, or SELECT depending on missingIdleCommand config.
146
178
  let idleTimer;
147
179
  return new Promise(resolve => {
148
180
  if (!connection.currentSelectCommand) {
149
181
  return resolve();
150
182
  }
151
183
 
152
- // no IDLE support, fallback to NOOP'ing
184
+ // Set up preCheck so other commands can break the polling loop
153
185
  connection.preCheck = async () => {
154
186
  connection.preCheck = false; // unset itself
155
187
  clearTimeout(idleTimer);
@@ -160,6 +192,9 @@ module.exports = async (connection, maxIdleTime) => {
160
192
 
161
193
  let selectCommand = connection.currentSelectCommand;
162
194
 
195
+ // Run one polling check. The method used depends on configuration:
196
+ // SELECT re-selects the mailbox (may detect changes), STATUS queries mailbox counters,
197
+ // NOOP is the simplest but relies on server pushing untagged responses.
163
198
  let idleCheck = async () => {
164
199
  let response;
165
200
  switch (connection.missingIdleCommand) {
@@ -3,23 +3,45 @@
3
3
  const { decodePath, encodePath, normalizePath } = require('../tools.js');
4
4
  const { specialUse } = require('../special-use');
5
5
 
6
- // Lists mailboxes from server
6
+ /**
7
+ * Lists mailboxes from the server, including subscription status and special-use flags.
8
+ *
9
+ * @param {Object} connection - IMAP connection instance
10
+ * @param {string} reference - Reference name (namespace prefix)
11
+ * @param {string} mailbox - Mailbox name pattern with possible wildcards
12
+ * @param {Object} [options] - List options
13
+ * @param {boolean} [options.listOnly] - If true, return entries after LIST without LSUB or status queries
14
+ * @param {Object} [options.statusQuery] - Status data items to query for each listed mailbox
15
+ * @param {Object} [options.specialUseHints] - Hints mapping mailbox paths to special-use types (sent, junk, trash, drafts, archive)
16
+ * @returns {Promise<Object[]>} Array of mailbox entries sorted by special-use flags and name
17
+ * @throws {Error} If the LIST command fails
18
+ */
7
19
  module.exports = async (connection, reference, mailbox, options) => {
8
20
  options = options || {};
9
21
 
22
+ // Special-use flags sorted by display priority (INBOX first, Trash last).
23
+ // Used in the final sort to group special-use mailboxes at the top of the list.
10
24
  const FLAG_SORT_ORDER = ['\\Inbox', '\\Flagged', '\\Sent', '\\Drafts', '\\All', '\\Archive', '\\Junk', '\\Trash'];
25
+ // Priority for how a special-use flag was determined: explicit user hint > server
26
+ // extension flag (SPECIAL-USE/XLIST) > name-based guess. When multiple mailboxes
27
+ // claim the same special-use type, the highest-priority source wins.
11
28
  const SOURCE_SORT_ORDER = ['user', 'extension', 'name'];
12
29
 
30
+ // Prefer XLIST (legacy Gmail extension) only if SPECIAL-USE (RFC 6154) is unavailable.
31
+ // Both provide special-use flags, but SPECIAL-USE is the standardized approach.
13
32
  let listCommand = connection.capabilities.has('XLIST') && !connection.capabilities.has('SPECIAL-USE') ? 'XLIST' : 'LIST';
14
33
 
15
34
  let response;
16
35
  try {
17
36
  let entries = [];
18
37
 
38
+ // statusMap caches STATUS responses received inline via LIST-STATUS extension,
39
+ // keyed by normalized mailbox path. This avoids separate STATUS commands per mailbox.
19
40
  let statusMap = new Map();
20
41
  let returnArgs = [];
21
42
  let statusQueryAttributes = [];
22
43
 
44
+ // Build the list of STATUS data items to request (MESSAGES, UIDNEXT, etc.)
23
45
  if (options.statusQuery) {
24
46
  Object.keys(options.statusQuery || {}).forEach(key => {
25
47
  if (!options.statusQuery[key]) {
@@ -44,6 +66,9 @@ module.exports = async (connection, reference, mailbox, options) => {
44
66
  });
45
67
  }
46
68
 
69
+ // LIST-STATUS (RFC 5819): allows requesting STATUS data inline with LIST,
70
+ // avoiding a separate STATUS command for each mailbox. Adds RETURN (STATUS (...))
71
+ // and optionally SPECIAL-USE to the LIST command arguments.
47
72
  if (listCommand === 'LIST' && connection.capabilities.has('LIST-STATUS') && statusQueryAttributes.length) {
48
73
  returnArgs.push({ type: 'ATOM', value: 'STATUS' }, statusQueryAttributes);
49
74
  if (connection.capabilities.has('SPECIAL-USE')) {
@@ -51,6 +76,9 @@ module.exports = async (connection, reference, mailbox, options) => {
51
76
  }
52
77
  }
53
78
 
79
+ // Tracks all candidate mailboxes for each special-use type (e.g., \\Sent).
80
+ // Multiple mailboxes may claim the same type via different sources (user hint,
81
+ // server extension, name match). After listing, the best match wins.
54
82
  let specialUseMatches = {};
55
83
  let addSpecialUseMatch = (entry, type, source) => {
56
84
  if (!specialUseMatches[type]) {
@@ -59,6 +87,9 @@ module.exports = async (connection, reference, mailbox, options) => {
59
87
  specialUseMatches[type].push({ entry, source });
60
88
  };
61
89
 
90
+ // User-provided hints map mailbox paths to special-use types (e.g., {sent: "Sent Items"}).
91
+ // These override server-reported flags and name-based guesses. Converted to a
92
+ // path-keyed lookup: { "Sent Items" => "\\Sent" }
62
93
  let specialUseHints = {};
63
94
  if (options.specialUseHints && typeof options.specialUseHints === 'object') {
64
95
  for (let type of Object.keys(options.specialUseHints)) {
@@ -67,11 +98,15 @@ module.exports = async (connection, reference, mailbox, options) => {
67
98
  options.specialUseHints[type] &&
68
99
  typeof options.specialUseHints[type] === 'string'
69
100
  ) {
101
+ // Capitalize first letter: "sent" -> "\\Sent"
70
102
  specialUseHints[normalizePath(connection, options.specialUseHints[type])] = `\\${type.replace(/^./, c => c.toUpperCase())}`;
71
103
  }
72
104
  }
73
105
  }
74
106
 
107
+ // Executes a LIST (or XLIST) command and collects mailbox entries.
108
+ // Called once for the main listing and optionally again for INBOX if a
109
+ // namespace prefix was used (INBOX may live outside the namespace).
75
110
  let runList = async (reference, mailbox) => {
76
111
  const cmdArgs = [encodePath(connection, reference), encodePath(connection, mailbox)];
77
112
 
@@ -81,12 +116,15 @@ module.exports = async (connection, reference, mailbox, options) => {
81
116
 
82
117
  response = await connection.exec(listCommand, cmdArgs, {
83
118
  untagged: {
119
+ // Each untagged LIST response: * LIST (<flags>) "<delimiter>" "<mailbox name>"
120
+ // attributes[0] = flags array, attributes[1] = delimiter, attributes[2] = mailbox name
84
121
  [listCommand]: async untagged => {
85
122
  if (!untagged.attributes || !untagged.attributes.length) {
86
123
  return;
87
124
  }
88
125
 
89
126
  let entry = {
127
+ // Decode from modified UTF-7 wire format and normalize the path
90
128
  path: normalizePath(connection, decodePath(connection, (untagged.attributes[2] && untagged.attributes[2].value) || '')),
91
129
  pathAsListed: (untagged.attributes[2] && untagged.attributes[2].value) || '',
92
130
  flags: new Set(untagged.attributes[0].map(entry => entry.value)),
@@ -94,31 +132,39 @@ module.exports = async (connection, reference, mailbox, options) => {
94
132
  listed: true
95
133
  };
96
134
 
135
+ // Check user-provided hints first (highest priority)
97
136
  if (specialUseHints[entry.path]) {
98
137
  addSpecialUseMatch(entry, specialUseHints[entry.path], 'user');
99
138
  }
100
139
 
140
+ // XLIST marks INBOX with a \\Inbox flag. Remove it from flags
141
+ // (it's not a standard flag) and register as special-use match.
142
+ // XLIST may also use a localised name (e.g., "Posteingang" for German INBOX).
101
143
  if (listCommand === 'XLIST' && entry.flags.has('\\Inbox')) {
102
- // XLIST specific flag, ignore
103
144
  entry.flags.delete('\\Inbox');
104
145
  if (entry.path !== 'INBOX') {
105
- // XLIST may use localised inbox name
106
146
  addSpecialUseMatch(entry, '\\Inbox', 'extension');
107
147
  }
108
148
  }
109
149
 
150
+ // Name-based INBOX detection: any mailbox named "INBOX" (case-insensitive)
151
+ // is the inbox per RFC 3501.
110
152
  if (entry.path.toUpperCase() === 'INBOX') {
111
153
  addSpecialUseMatch(entry, '\\Inbox', 'name');
112
154
  }
113
155
 
156
+ // Strip leading delimiter (some servers prepend it to paths)
114
157
  if (entry.delimiter && entry.path.charAt(0) === entry.delimiter) {
115
158
  entry.path = entry.path.slice(1);
116
159
  }
117
160
 
161
+ // Build parent path hierarchy for tree construction and sorting
118
162
  entry.parentPath = entry.delimiter && entry.path ? entry.path.substr(0, entry.path.lastIndexOf(entry.delimiter)) : '';
119
163
  entry.parent = entry.delimiter ? entry.path.split(entry.delimiter) : [entry.path];
120
164
  entry.name = entry.parent.pop();
121
165
 
166
+ // Try to detect special-use from server flags or well-known names
167
+ // (e.g., "Sent", "Drafts", "Junk", "Trash")
122
168
  let { flag: specialUseFlag, source: flagSource } = specialUse(
123
169
  connection.capabilities.has('XLIST') || connection.capabilities.has('SPECIAL-USE'),
124
170
  entry
@@ -131,6 +177,8 @@ module.exports = async (connection, reference, mailbox, options) => {
131
177
  entries.push(entry);
132
178
  },
133
179
 
180
+ // Inline STATUS response from LIST-STATUS extension (RFC 5819).
181
+ // Parses alternating key-value pairs (i % 2 pattern).
134
182
  STATUS: async untagged => {
135
183
  let statusPath = normalizePath(connection, decodePath(connection, (untagged.attributes[0] && untagged.attributes[0].value) || ''));
136
184
  let statusList = untagged.attributes && Array.isArray(untagged.attributes[1]) ? untagged.attributes[1] : false;
@@ -138,8 +186,16 @@ module.exports = async (connection, reference, mailbox, options) => {
138
186
  return;
139
187
  }
140
188
 
141
- let key;
189
+ const STATUS_FIELD_MAP = {
190
+ MESSAGES: { key: 'messages', parser: Number },
191
+ RECENT: { key: 'recent', parser: Number },
192
+ UIDNEXT: { key: 'uidNext', parser: Number },
193
+ UIDVALIDITY: { key: 'uidValidity', parser: BigInt },
194
+ UNSEEN: { key: 'unseen', parser: Number },
195
+ HIGHESTMODSEQ: { key: 'highestModseq', parser: BigInt }
196
+ };
142
197
 
198
+ let key;
143
199
  let map = { path: statusPath };
144
200
 
145
201
  statusList.forEach((entry, i) => {
@@ -150,43 +206,18 @@ module.exports = async (connection, reference, mailbox, options) => {
150
206
  if (!key || !entry || typeof entry.value !== 'string') {
151
207
  return;
152
208
  }
153
- let value = false;
154
- switch (key.toUpperCase()) {
155
- case 'MESSAGES':
156
- key = 'messages';
157
- value = !isNaN(entry.value) ? Number(entry.value) : false;
158
- break;
159
-
160
- case 'RECENT':
161
- key = 'recent';
162
- value = !isNaN(entry.value) ? Number(entry.value) : false;
163
- break;
164
-
165
- case 'UIDNEXT':
166
- key = 'uidNext';
167
- value = !isNaN(entry.value) ? Number(entry.value) : false;
168
- break;
169
-
170
- case 'UIDVALIDITY':
171
- key = 'uidValidity';
172
- value = !isNaN(entry.value) ? BigInt(entry.value) : false;
173
- break;
174
-
175
- case 'UNSEEN':
176
- key = 'unseen';
177
- value = !isNaN(entry.value) ? Number(entry.value) : false;
178
- break;
179
-
180
- case 'HIGHESTMODSEQ':
181
- key = 'highestModseq';
182
- value = !isNaN(entry.value) ? BigInt(entry.value) : false;
183
- break;
209
+
210
+ const fieldConfig = STATUS_FIELD_MAP[key.toUpperCase()];
211
+ if (!fieldConfig) {
212
+ return;
184
213
  }
214
+
215
+ const value = !isNaN(entry.value) ? fieldConfig.parser(entry.value) : false;
185
216
  if (value === false) {
186
217
  return;
187
218
  }
188
219
 
189
- map[key] = value;
220
+ map[fieldConfig.key] = value;
190
221
  });
191
222
 
192
223
  statusMap.set(statusPath, map);
@@ -203,18 +234,22 @@ module.exports = async (connection, reference, mailbox, options) => {
203
234
  return entries;
204
235
  }
205
236
 
237
+ // When listing with a namespace prefix (e.g., "INBOX."), INBOX itself may
238
+ // not appear in results. Run a separate LIST for INBOX to ensure it's included.
206
239
  if (normalizedReference && !specialUseMatches['\\Inbox']) {
207
- // INBOX was most probably not included in the listing if namespace was used
208
240
  await runList('', 'INBOX');
209
241
  }
210
242
 
243
+ // Attach STATUS data to each selectable mailbox. If LIST-STATUS was used,
244
+ // data is already in statusMap; otherwise, fall back to individual STATUS commands.
211
245
  if (options.statusQuery) {
212
246
  for (let entry of entries) {
247
+ // \\Noselect and \\NonExistent mailboxes cannot hold messages
213
248
  if (!entry.flags.has('\\Noselect') && !entry.flags.has('\\NonExistent')) {
214
249
  if (statusMap.has(entry.path)) {
215
250
  entry.status = statusMap.get(entry.path);
216
251
  } else if (!statusMap.size) {
217
- // run STATUS command
252
+ // Server didn't support LIST-STATUS; fall back to per-mailbox STATUS
218
253
  try {
219
254
  entry.status = await connection.run('STATUS', entry.path, options.statusQuery);
220
255
  } catch (err) {
@@ -225,6 +260,10 @@ module.exports = async (connection, reference, mailbox, options) => {
225
260
  }
226
261
  }
227
262
 
263
+ // LSUB (RFC 3501 6.3.9): queries which mailboxes the user is subscribed to.
264
+ // We merge subscription info into the entries already collected from LIST.
265
+ // Subscribed-only mailboxes that weren't in LIST are intentionally ignored
266
+ // (they may be phantom entries from old subscriptions to deleted mailboxes).
228
267
  response = await connection.exec(
229
268
  'LSUB',
230
269
  [encodePath(connection, normalizePath(connection, reference || '')), encodePath(connection, normalizePath(connection, mailbox || '', true))],
@@ -255,26 +294,23 @@ module.exports = async (connection, reference, mailbox, options) => {
255
294
  entry.parent = entry.delimiter ? entry.path.split(entry.delimiter) : [entry.path];
256
295
  entry.name = entry.parent.pop();
257
296
 
297
+ // Merge LSUB data into existing LIST entry if found
258
298
  let existing = entries.find(existing => existing.path === entry.path);
259
299
  if (existing) {
260
300
  existing.subscribed = true;
301
+ // Merge any additional flags from LSUB into the LIST entry
261
302
  entry.flags.forEach(flag => existing.flags.add(flag));
262
- } else {
263
- // ignore non-listed folders
264
- /*
265
- let specialUseFlag = specialUse(connection.capabilities.has('XLIST') || connection.capabilities.has('SPECIAL-USE'), entry);
266
- if (specialUseFlag && !flagsSeen.has(specialUseFlag)) {
267
- entry.specialUse = specialUseFlag;
268
- }
269
- entries.push(entry);
270
- */
271
303
  }
304
+ // Non-listed subscribed folders are intentionally ignored
272
305
  }
273
306
  }
274
307
  }
275
308
  );
276
309
  response.next();
277
310
 
311
+ // Resolve special-use conflicts: for each type, pick the best candidate
312
+ // based on source priority (user > extension > name), then alphabetically.
313
+ // Only the winning entry gets the specialUse property set.
278
314
  for (let type of Object.keys(specialUseMatches)) {
279
315
  let sortedEntries = specialUseMatches[type].sort((a, b) => {
280
316
  let aSource = SOURCE_SORT_ORDER.indexOf(a.source);
@@ -291,12 +327,14 @@ module.exports = async (connection, reference, mailbox, options) => {
291
327
  }
292
328
  }
293
329
 
330
+ // INBOX should always appear as subscribed regardless of LSUB results
294
331
  let inboxEntry = entries.find(entry => entry.specialUse === '\\Inbox');
295
332
  if (inboxEntry && !inboxEntry.subscribed) {
296
- // override server settings and make INBOX always as subscribed
297
333
  inboxEntry.subscribed = true;
298
334
  }
299
335
 
336
+ // Sort: special-use mailboxes first (in FLAG_SORT_ORDER), then alphabetically
337
+ // by path segments for a natural folder hierarchy ordering.
300
338
  return entries.sort((a, b) => {
301
339
  if (a.specialUse && !b.specialUse) {
302
340
  return -1;
@@ -2,7 +2,15 @@
2
2
 
3
3
  const { getStatusCode, getErrorText } = require('../tools.js');
4
4
 
5
- // Authenticates user using LOGIN
5
+ /**
6
+ * Authenticates user using the IMAP LOGIN command.
7
+ *
8
+ * @param {Object} connection - IMAP connection instance
9
+ * @param {string} username - The username to authenticate with
10
+ * @param {string} password - The password to authenticate with
11
+ * @returns {Promise<string|undefined>} The authenticated username, or undefined if already authenticated
12
+ * @throws {Error} If authentication fails, with authenticationFailed and serverResponseCode properties set
13
+ */
6
14
  module.exports = async (connection, username, password) => {
7
15
  if (connection.state !== connection.states.NOT_AUTHENTICATED) {
8
16
  // nothing to do here
@@ -12,10 +20,13 @@ module.exports = async (connection, username, password) => {
12
20
  try {
13
21
  let response = await connection.exec('LOGIN', [
14
22
  { type: 'STRING', value: username },
23
+ // sensitive: true prevents the password from appearing in debug logs
15
24
  { type: 'STRING', value: password, sensitive: true }
16
25
  ]);
17
26
  response.next();
18
27
 
28
+ // Record that LOGIN was the method used, so the connection knows which
29
+ // auth mechanism succeeded (used for reconnection and diagnostics).
19
30
  connection.authCapabilities.set('LOGIN', true);
20
31
 
21
32
  return username;
@@ -1,6 +1,11 @@
1
1
  'use strict';
2
2
 
3
- // Logs out user and closes connection
3
+ /**
4
+ * Logs out the user and closes the connection.
5
+ *
6
+ * @param {Object} connection - IMAP connection instance
7
+ * @returns {Promise<boolean>} True if logout command succeeded, false otherwise
8
+ */
4
9
  module.exports = async connection => {
5
10
  if (connection.state === connection.states.LOGOUT) {
6
11
  // nothing to do here
@@ -8,6 +13,7 @@ module.exports = async connection => {
8
13
  }
9
14
 
10
15
  if (connection.state === connection.states.NOT_AUTHENTICATED) {
16
+ // Not yet authenticated -- no LOGOUT command needed; just close the socket.
11
17
  connection.state = connection.states.LOGOUT;
12
18
  connection.close();
13
19
  return false;
@@ -18,13 +24,16 @@ module.exports = async connection => {
18
24
  response = await connection.exec('LOGOUT');
19
25
  return true;
20
26
  } catch (err) {
27
+ // If the connection is already gone, treat as successful logout
21
28
  if (err.code === 'NoConnection') {
22
29
  return true;
23
30
  }
24
31
  connection.log.warn({ err, cid: connection.id });
25
32
  return false;
26
33
  } finally {
27
- // close even if command failed
34
+ // Set state to LOGOUT before closing to prevent any further commands from
35
+ // being queued. The socket is closed unconditionally in this finally block
36
+ // regardless of whether the LOGOUT command succeeded or failed.
28
37
  connection.state = connection.states.LOGOUT;
29
38
  if (response && typeof response.next === 'function') {
30
39
  response.next();
@@ -2,7 +2,16 @@
2
2
 
3
3
  const { normalizePath, encodePath, expandRange, enhanceCommandError } = require('../tools.js');
4
4
 
5
- // Moves messages from current mailbox to some other mailbox
5
+ /**
6
+ * Moves messages from the current mailbox to another mailbox.
7
+ *
8
+ * @param {Object} connection - IMAP connection instance
9
+ * @param {string} range - Message sequence number or UID range
10
+ * @param {string} destination - Destination mailbox path
11
+ * @param {Object} [options] - Move options
12
+ * @param {boolean} [options.uid] - If true, use UID MOVE instead of MOVE
13
+ * @returns {Promise<{path: string, destination: string, uidValidity?: BigInt, uidMap?: Map}|boolean|undefined>} Move result with UID mapping if available, false on failure, or undefined if preconditions not met
14
+ */
6
15
  module.exports = async (connection, range, destination, options) => {
7
16
  if (connection.state !== connection.states.SELECTED || !range || !destination) {
8
17
  // nothing to do here
@@ -19,12 +28,17 @@ module.exports = async (connection, range, destination, options) => {
19
28
 
20
29
  let map = { path: connection.mailbox.path, destination };
21
30
 
31
+ // Fallback for servers without the MOVE extension (RFC 6851):
32
+ // emulate MOVE using COPY + flag as \Deleted + EXPUNGE.
22
33
  if (!connection.capabilities.has('MOVE')) {
23
34
  let result = await connection.messageCopy(range, destination, options);
24
35
  await connection.messageDelete(range, Object.assign({ silent: true }, options));
25
36
  return result;
26
37
  }
27
38
 
39
+ // Extract COPYUID response code (UIDPLUS, RFC 4315) from either an untagged
40
+ // OK response or the final tagged OK. MOVE uses the same COPYUID format as COPY
41
+ // to report the source-to-destination UID mapping.
28
42
  let checkMoveInfo = response => {
29
43
  let section = response.attributes && response.attributes[0] && response.attributes[0].section;
30
44
  let responseCode = section && section.length && section[0] && typeof section[0].value === 'string' ? section[0].value : '';
@@ -48,6 +62,8 @@ module.exports = async (connection, range, destination, options) => {
48
62
 
49
63
  let response;
50
64
  try {
65
+ // Some servers send COPYUID in an untagged OK before the tagged response,
66
+ // others include it in the tagged OK. We check both to be safe.
51
67
  response = await connection.exec(options.uid ? 'UID MOVE' : 'MOVE', attributes, {
52
68
  untagged: {
53
69
  OK: async untagged => {