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,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 => {
@@ -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
  }