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,18 +2,54 @@
2
2
 
3
3
  const { getStatusCode, getErrorText } = require('../tools.js');
4
4
 
5
+ /**
6
+ * Handles authentication errors by enriching the error object with server response details.
7
+ *
8
+ * @param {Error} err - The original authentication error
9
+ * @param {Object} [errorResponse] - Optional OAuth error response from the server
10
+ * @throws {Error} Always throws the enriched error
11
+ */
12
+ async function handleAuthError(err, errorResponse) {
13
+ let errorCode = getStatusCode(err.response);
14
+ if (errorCode) {
15
+ err.serverResponseCode = errorCode;
16
+ }
17
+ err.authenticationFailed = true;
18
+ err.response = await getErrorText(err.response);
19
+ if (errorResponse) {
20
+ err.oauthError = errorResponse;
21
+ }
22
+ throw err;
23
+ }
24
+
25
+ /**
26
+ * Authenticates using OAuth (OAUTHBEARER or XOAUTH2).
27
+ *
28
+ * @param {Object} connection - IMAP connection instance
29
+ * @param {string} username - The username to authenticate with
30
+ * @param {string} accessToken - The OAuth2 access token
31
+ * @returns {Promise<string>} The authenticated username
32
+ * @throws {Error} If authentication fails
33
+ */
5
34
  async function authOauth(connection, username, accessToken) {
6
35
  let oauthbearer;
7
36
  let command;
8
37
  let breaker;
9
38
 
10
39
  if (connection.capabilities.has('AUTH=OAUTHBEARER')) {
40
+ // OAUTHBEARER payload per RFC 7628: fields separated by \x01 (SASL GS2 framing).
41
+ // Format: "n,a=<user>," \x01 "host=..." \x01 "port=..." \x01 "auth=Bearer <token>" \x01 \x01
42
+ // The trailing empty strings produce the required double-\x01 terminator.
11
43
  oauthbearer = [`n,a=${username},`, `host=${connection.servername}`, `port=993`, `auth=Bearer ${accessToken}`, '', ''].join('\x01');
12
44
  command = 'OAUTHBEARER';
45
+ // "AQ==" is base64 for \x01 -- sent as the error continuation to abort the SASL exchange
13
46
  breaker = 'AQ==';
14
47
  } else if (connection.capabilities.has('AUTH=XOAUTH') || connection.capabilities.has('AUTH=XOAUTH2')) {
48
+ // XOAUTH2 payload (Google-specific): simpler format, also \x01-delimited.
49
+ // Format: "user=<user>" \x01 "auth=Bearer <token>" \x01 \x01
15
50
  oauthbearer = [`user=${username}`, `auth=Bearer ${accessToken}`, '', ''].join('\x01');
16
51
  command = 'XOAUTH2';
52
+ // Empty breaker: XOAUTH2 expects an empty response to abort the SASL exchange
17
53
  breaker = '';
18
54
  }
19
55
 
@@ -26,6 +62,8 @@ async function authOauth(connection, username, accessToken) {
26
62
  { type: 'ATOM', value: Buffer.from(oauthbearer).toString('base64'), sensitive: true }
27
63
  ],
28
64
  {
65
+ // Server sends a "+" continuation if auth fails, with a base64 JSON error payload.
66
+ // We decode it for diagnostics, then send the breaker to terminate the exchange.
29
67
  onPlusTag: async resp => {
30
68
  if (resp.attributes && resp.attributes[0] && resp.attributes[0].type === 'TEXT') {
31
69
  try {
@@ -46,46 +84,43 @@ async function authOauth(connection, username, accessToken) {
46
84
 
47
85
  return username;
48
86
  } catch (err) {
49
- let errorCode = getStatusCode(err.response);
50
- if (errorCode) {
51
- err.serverResponseCode = errorCode;
52
- }
53
- err.authenticationFailed = true;
54
- err.response = await getErrorText(err.response);
55
- if (errorResponse) {
56
- err.oauthError = errorResponse;
57
- }
58
- throw err;
87
+ await handleAuthError(err, errorResponse);
59
88
  }
60
89
  }
61
90
 
91
+ /**
92
+ * Authenticates using the SASL LOGIN mechanism.
93
+ *
94
+ * @param {Object} connection - IMAP connection instance
95
+ * @param {string} username - The username to authenticate with
96
+ * @param {string} password - The password to authenticate with
97
+ * @returns {Promise<string>} The authenticated username
98
+ * @throws {Error} If authentication fails
99
+ */
62
100
  async function authLogin(connection, username, password) {
63
101
  let errorResponse = false;
64
102
  try {
103
+ // SASL LOGIN is a challenge-response mechanism: the server sends base64-encoded
104
+ // prompts ("Username:" and "Password:") and the client responds with base64-encoded values.
65
105
  let response = await connection.exec('AUTHENTICATE', [{ type: 'ATOM', value: 'LOGIN' }], {
66
106
  onPlusTag: async resp => {
67
107
  if (resp.attributes && resp.attributes[0] && resp.attributes[0].type === 'TEXT') {
68
- let question = Buffer.from(resp.attributes[0].value, 'base64').toString();
69
- switch (
70
- question.toLowerCase().replace(/[:\x00]*$/, '') // eslint-disable-line no-control-regex
71
- ) {
72
- case 'username':
73
- case 'user name': {
74
- let encodedUsername = Buffer.from(username).toString('base64');
75
- connection.log.debug({ src: 'c', msg: encodedUsername, comment: `Encoded username for AUTH=LOGIN` });
76
- connection.write(encodedUsername);
77
- break;
78
- }
79
-
80
- case 'password':
81
- connection.log.debug({ src: 'c', msg: '(* value hidden *)', comment: `Encoded password for AUTH=LOGIN` });
82
- connection.write(Buffer.from(password).toString('base64'));
83
- break;
84
-
85
- default: {
86
- let error = new Error(`Unknown LOGIN question "${question}"`);
87
- throw error;
88
- }
108
+ // Decode the server's base64 challenge to determine what it's asking for.
109
+ // Strip trailing colons and null bytes (\x00) that some servers append to the prompt.
110
+ let question = Buffer.from(resp.attributes[0].value, 'base64')
111
+ .toString()
112
+ .toLowerCase()
113
+ .replace(/[:\x00]*$/, ''); // eslint-disable-line no-control-regex
114
+
115
+ if (question === 'username' || question === 'user name') {
116
+ let encodedUsername = Buffer.from(username).toString('base64');
117
+ connection.log.debug({ src: 'c', msg: encodedUsername, comment: `Encoded username for AUTH=LOGIN` });
118
+ connection.write(encodedUsername);
119
+ } else if (question === 'password') {
120
+ connection.log.debug({ src: 'c', msg: '(* value hidden *)', comment: `Encoded password for AUTH=LOGIN` });
121
+ connection.write(Buffer.from(password).toString('base64'));
122
+ } else {
123
+ throw new Error(`Unknown LOGIN question "${question}"`);
89
124
  }
90
125
  }
91
126
  }
@@ -97,19 +132,20 @@ async function authLogin(connection, username, password) {
97
132
 
98
133
  return username;
99
134
  } catch (err) {
100
- let errorCode = getStatusCode(err.response);
101
- if (errorCode) {
102
- err.serverResponseCode = errorCode;
103
- }
104
- err.authenticationFailed = true;
105
- err.response = await getErrorText(err.response);
106
- if (errorResponse) {
107
- err.oauthError = errorResponse;
108
- }
109
- throw err;
135
+ await handleAuthError(err, errorResponse);
110
136
  }
111
137
  }
112
138
 
139
+ /**
140
+ * Authenticates using the SASL PLAIN mechanism.
141
+ *
142
+ * @param {Object} connection - IMAP connection instance
143
+ * @param {string} username - The authentication identity (authcid)
144
+ * @param {string} password - The password to authenticate with
145
+ * @param {string} [authzid] - Optional authorization identity to impersonate
146
+ * @returns {Promise<string>} The authorized identity (authzid if provided, otherwise username)
147
+ * @throws {Error} If authentication fails
148
+ */
113
149
  async function authPlain(connection, username, password, authzid) {
114
150
  let errorResponse = false;
115
151
  try {
@@ -133,26 +169,37 @@ async function authPlain(connection, username, password, authzid) {
133
169
  // Return the identity we're authorized as (authzid if provided, otherwise username)
134
170
  return authzid || username;
135
171
  } catch (err) {
136
- let errorCode = getStatusCode(err.response);
137
- if (errorCode) {
138
- err.serverResponseCode = errorCode;
139
- }
140
- err.authenticationFailed = true;
141
- err.response = await getErrorText(err.response);
142
- if (errorResponse) {
143
- err.oauthError = errorResponse;
144
- }
145
- throw err;
172
+ await handleAuthError(err, errorResponse);
146
173
  }
147
174
  }
148
175
 
149
- // Authenticates user using LOGIN
176
+ /**
177
+ * Authenticates user using the best available method.
178
+ *
179
+ * @param {Object} connection - IMAP connection instance
180
+ * @param {string} username - The username to authenticate with
181
+ * @param {Object} credentials - Authentication credentials
182
+ * @param {string} [credentials.accessToken] - OAuth2 access token for OAUTHBEARER/XOAUTH2 authentication
183
+ * @param {string} [credentials.password] - Password for PLAIN or LOGIN authentication
184
+ * @param {string} [credentials.loginMethod] - Force a specific login method (e.g., 'AUTH=PLAIN', 'AUTH=LOGIN')
185
+ * @param {string} [credentials.authzid] - Authorization identity for PLAIN authentication
186
+ * @returns {Promise<string|undefined>} The authenticated username, or undefined if already authenticated
187
+ * @throws {Error} If no supported authentication mechanism is available or if authentication fails
188
+ */
150
189
  module.exports = async (connection, username, { accessToken, password, loginMethod, authzid }) => {
151
190
  if (connection.state !== connection.states.NOT_AUTHENTICATED) {
152
191
  // nothing to do here
153
192
  return;
154
193
  }
155
194
 
195
+ // Authentication method selection order:
196
+ // 1. OAuth (OAUTHBEARER > XOAUTH2) -- preferred when an accessToken is provided,
197
+ // as it avoids transmitting passwords entirely.
198
+ // 2. SASL PLAIN -- preferred over LOGIN because it supports authzid (impersonation)
199
+ // and sends credentials in a single round trip.
200
+ // 3. SASL LOGIN -- fallback; an older challenge-response mechanism (two round trips).
201
+ // If loginMethod is explicitly set, it overrides the automatic capability-based selection.
202
+
156
203
  if (accessToken) {
157
204
  // AUTH=OAUTHBEARER and AUTH=XOAUTH in the context of OAuth2 or very similar so we can handle these together
158
205
  if (connection.capabilities.has('AUTH=OAUTHBEARER') || connection.capabilities.has('AUTH=XOAUTH') || connection.capabilities.has('AUTH=XOAUTH2')) {
@@ -1,6 +1,15 @@
1
1
  'use strict';
2
2
 
3
- // Refresh capabilities from server
3
+ /**
4
+ * Refreshes capabilities from server.
5
+ *
6
+ * @param {Object} connection - IMAP connection instance
7
+ * @returns {Promise<Map|boolean>} Server capabilities map, or false on failure
8
+ */
9
+ // Capabilities are normally received and updated by the global response handler
10
+ // (e.g., from the server greeting or after authentication). This explicit CAPABILITY
11
+ // command is only needed when capabilities must be refreshed on demand, such as
12
+ // after STARTTLS or when the server signals a capability change.
4
13
  module.exports = async connection => {
5
14
  if (connection.capabilities.size && !connection.expectCapabilityUpdate) {
6
15
  return connection.capabilities;
@@ -8,7 +17,8 @@ module.exports = async connection => {
8
17
 
9
18
  let response;
10
19
  try {
11
- // untagged capability response is processed by global handler
20
+ // The actual parsing of the untagged CAPABILITY response is handled by the
21
+ // global handler, not here. We just trigger the server to send it.
12
22
  response = await connection.exec('CAPABILITY');
13
23
 
14
24
  response.next();
@@ -1,6 +1,11 @@
1
1
  'use strict';
2
2
 
3
- // Closes a mailbox
3
+ /**
4
+ * Closes the currently selected mailbox.
5
+ *
6
+ * @param {Object} connection - IMAP connection instance
7
+ * @returns {Promise<boolean|undefined>} True on success, false on failure, or undefined if not in SELECTED state
8
+ */
4
9
  module.exports = async connection => {
5
10
  if (connection.state !== connection.states.SELECTED) {
6
11
  // nothing to do here
@@ -9,9 +14,14 @@ module.exports = async connection => {
9
14
 
10
15
  let response;
11
16
  try {
17
+ // IMAP CLOSE (RFC 3501 6.4.2): permanently removes all messages flagged \Deleted
18
+ // from the currently selected mailbox (implicit expunge) and deselects it.
19
+ // Unlike EXPUNGE, CLOSE does not send individual untagged EXPUNGE responses.
12
20
  response = await connection.exec('CLOSE');
13
21
  response.next();
14
22
 
23
+ // Transition from SELECTED back to AUTHENTICATED state.
24
+ // Clear mailbox metadata so subsequent operations know no mailbox is selected.
15
25
  let currentMailbox = connection.mailbox;
16
26
  connection.mailbox = false;
17
27
  connection.currentSelectCommand = false;
@@ -1,7 +1,16 @@
1
1
  'use strict';
2
2
 
3
- // Requests compression from server
3
+ /**
4
+ * Requests DEFLATE compression from the server.
5
+ *
6
+ * @param {Object} connection - IMAP connection instance
7
+ * @returns {Promise<boolean>} True if compression was enabled, false otherwise
8
+ */
9
+ // COMPRESS=DEFLATE (RFC 4978): enables zlib compression on the IMAP connection
10
+ // to reduce bandwidth. Once enabled, all subsequent data in both directions is compressed.
4
11
  module.exports = async connection => {
12
+ // Skip if the server doesn't support COMPRESS=DEFLATE, or if compression
13
+ // is already active (connection._inflate exists) to avoid double-compression.
5
14
  if (!connection.capabilities.has('COMPRESS=DEFLATE') || connection._inflate) {
6
15
  // nothing to do here
7
16
  return false;
@@ -2,7 +2,16 @@
2
2
 
3
3
  const { normalizePath, encodePath, expandRange, enhanceCommandError } = require('../tools.js');
4
4
 
5
- // Copies messages from current mailbox to some other mailbox
5
+ /**
6
+ * Copies 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] - Copy options
12
+ * @param {boolean} [options.uid] - If true, use UID COPY instead of COPY
13
+ * @returns {Promise<{path: string, destination: string, uidValidity?: BigInt, uidMap?: Map}|boolean|undefined>} Copy 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
@@ -23,18 +32,26 @@ module.exports = async (connection, range, destination, options) => {
23
32
  response.next();
24
33
 
25
34
  let map = { path: connection.mailbox.path, destination };
35
+
36
+ // UIDPLUS (RFC 4315): the server may include a COPYUID response code in the
37
+ // tagged OK response, providing a mapping from source UIDs to destination UIDs.
38
+ // Format: [COPYUID <uidValidity> <sourceUids> <destUids>]
26
39
  let section = response.response.attributes && response.response.attributes[0] && response.response.attributes[0].section;
27
40
  let responseCode = section && section.length && section[0] && typeof section[0].value === 'string' ? section[0].value : '';
28
41
  switch (responseCode) {
29
42
  case 'COPYUID':
30
43
  {
44
+ // section[1] = destination mailbox UIDVALIDITY
31
45
  let uidValidity = section[1] && typeof section[1].value === 'string' && !isNaN(section[1].value) ? BigInt(section[1].value) : false;
32
46
  if (uidValidity) {
33
47
  map.uidValidity = uidValidity;
34
48
  }
35
49
 
50
+ // section[2] = source UID set, section[3] = destination UID set
51
+ // Both can be ranges (e.g., "1:3") which expandRange() converts to arrays
36
52
  let sourceUids = section[2] && typeof section[2].value === 'string' ? expandRange(section[2].value) : false;
37
53
  let destinationUids = section[3] && typeof section[3].value === 'string' ? expandRange(section[3].value) : false;
54
+ // Build a source->destination UID map for the caller to track where messages went
38
55
  if (sourceUids && destinationUids && sourceUids.length === destinationUids.length) {
39
56
  map.uidMap = new Map(sourceUids.map((uid, i) => [uid, destinationUids[i]]));
40
57
  }
@@ -2,7 +2,14 @@
2
2
 
3
3
  const { encodePath, normalizePath, getStatusCode, enhanceCommandError } = require('../tools.js');
4
4
 
5
- // Creates a new mailbox
5
+ /**
6
+ * Creates a new mailbox and subscribes to it.
7
+ *
8
+ * @param {Object} connection - IMAP connection instance
9
+ * @param {string} path - Mailbox path to create
10
+ * @returns {Promise<{path: string, created: boolean, mailboxId?: string}|undefined>} Object with path and creation status, or undefined if preconditions not met
11
+ * @throws {Error} If the CREATE command fails (except when mailbox already exists)
12
+ */
6
13
  module.exports = async (connection, path) => {
7
14
  if (![connection.states.AUTHENTICATED, connection.states.SELECTED].includes(connection.state)) {
8
15
  // nothing to do here
@@ -18,6 +25,8 @@ module.exports = async (connection, path) => {
18
25
  };
19
26
  response = await connection.exec('CREATE', [{ type: 'ATOM', value: encodePath(connection, path) }]);
20
27
 
28
+ // Parse the response code section (e.g., [MAILBOXID (<id>)]) from the tagged OK response.
29
+ // IMAP response code attributes are structured as alternating key-value pairs.
21
30
  let section =
22
31
  response.response.attributes &&
23
32
  response.response.attributes[0] &&
@@ -29,6 +38,7 @@ module.exports = async (connection, path) => {
29
38
  if (section) {
30
39
  let key;
31
40
  section.forEach((attribute, i) => {
41
+ // IMAP key-value pairs: even indices (i % 2 === 0) are keys, odd indices are values
32
42
  if (i % 2 === 0) {
33
43
  key = attribute && typeof attribute.value === 'string' ? attribute.value : false;
34
44
  return;
@@ -55,12 +65,15 @@ module.exports = async (connection, path) => {
55
65
  map.created = true;
56
66
  response.next();
57
67
 
58
- //make sure we are subscribed to the new folder as well
68
+ // Auto-subscribe after creation so the new mailbox appears in LSUB listings
69
+ // and is visible to clients that only show subscribed folders.
59
70
  await connection.run('SUBSCRIBE', path);
60
71
 
61
72
  return map;
62
73
  } catch (err) {
63
74
  let errorCode = getStatusCode(err.response);
75
+ // ALREADYEXISTS (RFC 5530) means the mailbox already exists on the server.
76
+ // This is not a true error -- we return created:false to indicate nothing was created.
64
77
  if (errorCode === 'ALREADYEXISTS') {
65
78
  // no need to do anything, mailbox already exists
66
79
  return {
@@ -2,7 +2,14 @@
2
2
 
3
3
  const { encodePath, normalizePath, enhanceCommandError } = require('../tools.js');
4
4
 
5
- // Deletes an existing mailbox
5
+ /**
6
+ * Deletes an existing mailbox.
7
+ *
8
+ * @param {Object} connection - IMAP connection instance
9
+ * @param {string} path - Mailbox path to delete
10
+ * @returns {Promise<{path: string}|undefined>} Object with the deleted path, or undefined if preconditions not met
11
+ * @throws {Error} If the DELETE command fails
12
+ */
6
13
  module.exports = async (connection, path) => {
7
14
  if (![connection.states.AUTHENTICATED, connection.states.SELECTED].includes(connection.state)) {
8
15
  // nothing to do here
@@ -11,6 +18,8 @@ module.exports = async (connection, path) => {
11
18
 
12
19
  path = normalizePath(connection, path);
13
20
 
21
+ // If the mailbox to delete is currently selected, we must close/deselect it first.
22
+ // IMAP servers reject DELETE on the currently selected mailbox (RFC 3501 6.3.4).
14
23
  if (connection.state === connection.states.SELECTED && connection.mailbox.path === path) {
15
24
  await connection.run('CLOSE');
16
25
  }
@@ -1,12 +1,20 @@
1
1
  'use strict';
2
2
 
3
- // Enables extensions
3
+ /**
4
+ * Enables IMAP extensions on the server.
5
+ *
6
+ * @param {Object} connection - IMAP connection instance
7
+ * @param {string[]} extensionList - List of extension names to enable
8
+ * @returns {Promise<Set|boolean|undefined>} Set of enabled extensions, false on failure, or undefined if not applicable
9
+ */
4
10
  module.exports = async (connection, extensionList) => {
5
11
  if (!connection.capabilities.has('ENABLE') || connection.state !== connection.states.AUTHENTICATED) {
6
12
  // nothing to do here
7
13
  return;
8
14
  }
9
15
 
16
+ // Pre-filter: only request extensions the server actually advertised in its
17
+ // CAPABILITY response. Requesting unsupported extensions would cause an error.
10
18
  extensionList = extensionList.filter(extension => connection.capabilities.has(extension.toUpperCase()));
11
19
  if (!extensionList.length) {
12
20
  return;
@@ -20,6 +28,9 @@ module.exports = async (connection, extensionList) => {
20
28
  extensionList.map(extension => ({ type: 'ATOM', value: extension.toUpperCase() })),
21
29
  {
22
30
  untagged: {
31
+ // The untagged ENABLED response is a flat list of extension names
32
+ // (e.g., "* ENABLED CONDSTORE UTF8=ACCEPT"), NOT key-value pairs.
33
+ // Each attribute is a single extension identifier.
23
34
  ENABLED: async untagged => {
24
35
  if (!untagged.attributes || !untagged.attributes.length) {
25
36
  return;
@@ -2,7 +2,15 @@
2
2
 
3
3
  const { enhanceCommandError } = require('../tools.js');
4
4
 
5
- // Deletes specified messages
5
+ /**
6
+ * Deletes specified messages by flagging them as Deleted and expunging.
7
+ *
8
+ * @param {Object} connection - IMAP connection instance
9
+ * @param {string} range - Message sequence number or UID range
10
+ * @param {Object} [options] - Expunge options
11
+ * @param {boolean} [options.uid] - If true, use UID EXPUNGE when UIDPLUS is available
12
+ * @returns {Promise<boolean|undefined>} True on success, false on failure, or undefined if preconditions not met
13
+ */
6
14
  module.exports = async (connection, range, options) => {
7
15
  if (connection.state !== connection.states.SELECTED || !range) {
8
16
  // nothing to do here
@@ -11,8 +19,14 @@ module.exports = async (connection, range, options) => {
11
19
 
12
20
  options = options || {};
13
21
 
22
+ // Two-step deletion process per IMAP protocol:
23
+ // Step 1: Mark the target messages with the \Deleted flag.
14
24
  await connection.messageFlagsAdd(range, ['\\Deleted'], options);
15
25
 
26
+ // Step 2: Issue EXPUNGE to permanently remove \Deleted messages.
27
+ // With UIDPLUS (RFC 4315): "UID EXPUNGE <uids>" removes only the specified UIDs,
28
+ // leaving other \Deleted messages untouched -- important for concurrent access.
29
+ // Without UIDPLUS: plain "EXPUNGE" removes ALL messages flagged \Deleted in the mailbox.
16
30
  let byUid = options.uid && connection.capabilities.has('UIDPLUS');
17
31
  let command = byUid ? 'UID EXPUNGE' : 'EXPUNGE';
18
32
  let attributes = byUid ? [{ type: 'SEQUENCE', value: range }] : false;
@@ -21,7 +35,9 @@ module.exports = async (connection, range, options) => {
21
35
  try {
22
36
  response = await connection.exec(command, attributes);
23
37
 
24
- // A OK [HIGHESTMODSEQ 9122] Expunge completed (0.010 + 0.000 + 0.012 secs).
38
+ // CONDSTORE (RFC 7162): the server may return HIGHESTMODSEQ in the response code
39
+ // (e.g., "A OK [HIGHESTMODSEQ 9122] Expunge completed").
40
+ // Track this so the client can detect concurrent mailbox changes via mod-sequences.
25
41
  let section = response.response.attributes && response.response.attributes[0] && response.response.attributes[0].section;
26
42
  let responseCode = section && section.length && section[0] && typeof section[0].value === 'string' ? section[0].value : '';
27
43
  if (responseCode.toUpperCase() === 'HIGHESTMODSEQ') {
@@ -2,7 +2,19 @@
2
2
 
3
3
  const { formatMessageResponse } = require('../tools');
4
4
 
5
- // Fetches emails from server
5
+ /**
6
+ * Fetches emails from the server.
7
+ *
8
+ * @param {Object} connection - IMAP connection instance
9
+ * @param {string} range - Message sequence number or UID range
10
+ * @param {Object} query - Fetch query specifying which data to retrieve (e.g., flags, envelope, bodyStructure, headers, source, bodyParts)
11
+ * @param {Object} [options] - Fetch options
12
+ * @param {boolean} [options.uid] - If true, use UID FETCH instead of FETCH
13
+ * @param {boolean} [options.binary] - If true, use BINARY fetch when available
14
+ * @param {string} [options.changedSince] - Only fetch messages changed since this modseq value
15
+ * @param {Function} [options.onUntaggedFetch] - Callback for processing each fetched message individually
16
+ * @returns {Promise<{count: number, list: Object[]}|undefined>} Object with message count and list, or undefined if not in SELECTED state
17
+ */
6
18
  module.exports = async (connection, range, query, options) => {
7
19
  if (connection.state !== connection.states.SELECTED || !range) {
8
20
  // nothing to do here
@@ -13,8 +25,10 @@ module.exports = async (connection, range, query, options) => {
13
25
 
14
26
  let mailbox = connection.mailbox;
15
27
 
28
+ // Use BINARY extension for fetching if supported and requested, otherwise fall back to BODY
16
29
  const commandKey = connection.capabilities.has('BINARY') && options.binary && !connection.disableBinary ? 'BINARY' : 'BODY';
17
30
 
31
+ // Retry logic for ETHROTTLE errors (server rate limiting) with exponential backoff
18
32
  let retryCount = 0;
19
33
  const maxRetries = 4;
20
34
  const baseDelay = 1000; // Start with 1 second delay
@@ -31,6 +45,9 @@ module.exports = async (connection, range, query, options) => {
31
45
 
32
46
  let queryStructure = [];
33
47
 
48
+ // Helper to build BODY.PEEK[section]<partial> or BINARY.PEEK[section]<partial> atoms.
49
+ // PEEK avoids marking messages as \Seen. Section identifies what to fetch (HEADER, specific part, etc.)
50
+ // Partial is an optional byte range [start, maxLength].
34
51
  let setBodyPeek = (attributes, partial) => {
35
52
  let bodyPeek = {
36
53
  type: 'ATOM',
@@ -50,6 +67,7 @@ module.exports = async (connection, range, query, options) => {
50
67
  queryStructure.push(bodyPeek);
51
68
  };
52
69
 
70
+ // IMAP fetch macros (ALL, FAST, FULL) and standard data items map directly to IMAP atoms
53
71
  ['all', 'fast', 'full', 'uid', 'flags', 'bodyStructure', 'envelope', 'internalDate'].forEach(key => {
54
72
  if (query[key]) {
55
73
  queryStructure.push({ type: 'ATOM', value: key.toUpperCase() });
@@ -60,6 +78,7 @@ module.exports = async (connection, range, query, options) => {
60
78
  queryStructure.push({ type: 'ATOM', value: 'RFC822.SIZE' });
61
79
  }
62
80
 
81
+ // Fetch full message source, optionally with byte range (start/maxLength)
63
82
  if (query.source) {
64
83
  let partial;
65
84
  if (typeof query.source === 'object' && (query.source.start || query.source.maxLength)) {
@@ -71,13 +90,15 @@ module.exports = async (connection, range, query, options) => {
71
90
  queryStructure.push({ type: 'ATOM', value: `${commandKey}.PEEK`, section: [], partial });
72
91
  }
73
92
 
74
- // if possible, always request for unique email id
93
+ // Always request a unique email ID for message deduplication.
94
+ // Prefer OBJECTID (RFC 8474) over Gmail's X-GM-MSGID extension.
75
95
  if (connection.capabilities.has('OBJECTID')) {
76
96
  queryStructure.push({ type: 'ATOM', value: 'EMAILID' });
77
97
  } else if (connection.capabilities.has('X-GM-EXT-1')) {
78
98
  queryStructure.push({ type: 'ATOM', value: 'X-GM-MSGID' });
79
99
  }
80
100
 
101
+ // Thread ID: OBJECTID's THREADID or Gmail's X-GM-THRID
81
102
  if (query.threadId) {
82
103
  if (connection.capabilities.has('OBJECTID')) {
83
104
  queryStructure.push({ type: 'ATOM', value: 'THREADID' });
@@ -86,6 +107,7 @@ module.exports = async (connection, range, query, options) => {
86
107
  }
87
108
  }
88
109
 
110
+ // Gmail labels are only available with X-GM-EXT-1 extension
89
111
  if (query.labels) {
90
112
  if (connection.capabilities.has('X-GM-EXT-1')) {
91
113
  queryStructure.push({ type: 'ATOM', value: 'X-GM-LABELS' });
@@ -97,11 +119,13 @@ module.exports = async (connection, range, query, options) => {
97
119
  queryStructure.push({ type: 'ATOM', value: 'MODSEQ' });
98
120
  }
99
121
 
100
- // always make sure to include UID in the request as well even though server might auto-add it itself
122
+ // Always include UID in the response even if not explicitly requested,
123
+ // since we use it internally for message identification and tracking
101
124
  if (!query.uid) {
102
125
  queryStructure.push({ type: 'ATOM', value: 'UID' });
103
126
  }
104
127
 
128
+ // Headers: fetch all headers or only specific ones via HEADER.FIELDS
105
129
  if (query.headers) {
106
130
  if (Array.isArray(query.headers)) {
107
131
  setBodyPeek([{ type: 'ATOM', value: 'HEADER.FIELDS' }, query.headers.map(header => ({ type: 'ATOM', value: header }))]);
@@ -110,6 +134,8 @@ module.exports = async (connection, range, query, options) => {
110
134
  }
111
135
  }
112
136
 
137
+ // Fetch specific body parts by MIME part number (e.g., "1", "1.2", "2.MIME")
138
+ // Each part can optionally include a byte range (start/maxLength)
113
139
  if (query.bodyParts && query.bodyParts.length) {
114
140
  query.bodyParts.forEach(part => {
115
141
  if (!part) {
@@ -138,12 +164,16 @@ module.exports = async (connection, range, query, options) => {
138
164
  });
139
165
  }
140
166
 
167
+ // IMAP requires a single item to not be wrapped in parentheses, but
168
+ // multiple items must be in a list. If only one item, unwrap the array.
141
169
  if (queryStructure.length === 1) {
142
170
  queryStructure = queryStructure.pop();
143
171
  }
144
172
 
145
173
  attributes.push(queryStructure);
146
174
 
175
+ // CONDSTORE extension: only fetch messages with modseq higher than the given value.
176
+ // QRESYNC adds VANISHED to also get expunged UIDs since last sync.
147
177
  if (options.changedSince && connection.enabled.has('CONDSTORE') && !mailbox.noModseq) {
148
178
  let changedSinceArgs = [
149
179
  {
@@ -168,6 +198,9 @@ module.exports = async (connection, range, query, options) => {
168
198
 
169
199
  response = await connection.exec(options.uid ? 'UID FETCH' : 'FETCH', attributes, {
170
200
  untagged: {
201
+ // Each matching message triggers an untagged FETCH response.
202
+ // If onUntaggedFetch callback is provided, stream messages to it one by one
203
+ // (useful for large result sets). Otherwise, collect all into messages.list.
171
204
  FETCH: async untagged => {
172
205
  messages.count++;
173
206
  let formatted = await formatMessageResponse(untagged, mailbox);
@@ -192,7 +225,9 @@ module.exports = async (connection, range, query, options) => {
192
225
  return messages;
193
226
  } catch (err) {
194
227
  if (err.code === 'ETHROTTLE') {
195
- // Calculate exponential backoff delay
228
+ // Server returned a throttle error (rate limiting). Retry with exponential backoff.
229
+ // Delay doubles each retry: 1s, 2s, 4s, 8s (capped at 30s).
230
+ // If server provides a throttleReset hint, use that if longer.
196
231
  const backoffDelay = Math.min(baseDelay * Math.pow(2, retryCount), 30000); // Cap at 30 seconds
197
232
 
198
233
  // Use throttle reset time if provided and longer than backoff