imapflow 1.2.8 → 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 +7 -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
package/.ncurc.js CHANGED
@@ -1,4 +1,4 @@
1
1
  module.exports = {
2
2
  upgrade: true,
3
- reject: ['jsdoc', 'grunt-eslint']
3
+ reject: ['grunt-eslint']
4
4
  };
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "1.2.8"
2
+ ".": "1.2.9"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.2.9](https://github.com/postalsys/imapflow/compare/v1.2.8...v1.2.9) (2026-02-06)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * trigger build ([fd7a4c2](https://github.com/postalsys/imapflow/commit/fd7a4c2012d0b7742c45a2aced2d05da8401aa9a))
9
+
3
10
  ## [1.2.8](https://github.com/postalsys/imapflow/compare/v1.2.7...v1.2.8) (2026-01-28)
4
11
 
5
12
 
package/README.md CHANGED
@@ -7,7 +7,7 @@ ImapFlow is a modern and easy-to-use IMAP client library for Node.js.
7
7
 
8
8
  The focus for ImapFlow is to provide easy to use API over IMAP. Using ImapFlow does not expect knowledge about specific IMAP details. A general understanding is good enough.
9
9
 
10
- IMAP extensions are handled in the background, so, for example, you can always request `labels` value from a {@link FetchQueryObject|fetch()} call, but if the IMAP server does not support `X-GM-EXT-1` extension, then `labels` value is not included in the response.
10
+ IMAP extensions are handled in the background, so, for example, you can always request `labels` value from a `fetch()` call, but if the IMAP server does not support `X-GM-EXT-1` extension, then `labels` value is not included in the response.
11
11
 
12
12
  ## Source
13
13
 
@@ -100,10 +100,10 @@ await client.connect();
100
100
 
101
101
  ## Documentation
102
102
 
103
- [API reference](https://imapflow.com/module-imapflow-ImapFlow.html).
103
+ [API reference](https://imapflow.com/docs/api/imapflow-client).
104
104
 
105
105
  ## License
106
106
 
107
- © 2020-2024 Postal Systems OÜ
107
+ © 2020-2025 Postal Systems OÜ
108
108
 
109
109
  Licensed under **MIT-license**
package/lib/charsets.js CHANGED
@@ -1,5 +1,8 @@
1
1
  'use strict';
2
2
 
3
+ // Subset of the IANA Character Sets registry (https://www.iana.org/assignments/character-sets/).
4
+ // Used to validate and resolve charset names found in MIME Content-Type parameters.
5
+ // This list covers the most commonly encountered charsets in email messages.
3
6
  const CHARACTER_SETS = [
4
7
  'US-ASCII',
5
8
  'ISO-8859-1',
@@ -262,6 +265,14 @@ const CHARACTER_SETS = [
262
265
 
263
266
  const CHARSET_MAP = new Map();
264
267
 
268
+ // Build a lookup map with normalized keys for fuzzy charset resolution.
269
+ // Normalization strategy:
270
+ // 1. Strip all underscores, hyphens, and spaces, then lowercase (e.g., "ISO-8859-1" -> "iso88591")
271
+ // 2. Create additional aliases for common alternative prefixes:
272
+ // - "windows" -> "win" (e.g., "windows1252" also matches as "win1252")
273
+ // - "usascii" -> "ascii" (common shorthand)
274
+ // - "iso8859" -> "latin" (e.g., "iso88591" also matches as "latin1")
275
+ // This handles the many variant spellings found in real-world email headers.
265
276
  CHARACTER_SETS.forEach(entry => {
266
277
  let key = entry.replace(/[_-\s]/g, '').toLowerCase();
267
278
  let modifiedKey = key
@@ -274,6 +285,10 @@ CHARACTER_SETS.forEach(entry => {
274
285
  }
275
286
  });
276
287
 
288
+ // Resolves a charset name to its canonical IANA form using case-insensitive,
289
+ // symbol-stripping normalization. For example, "WIN-1252", "windows_1252",
290
+ // and "WINDOWS-1252" all resolve to "windows-1252".
291
+ // Returns null if the charset is not recognized.
277
292
  module.exports.resolveCharset = charset => {
278
293
  let key = charset.replace(/[_-\s]/g, '').toLowerCase();
279
294
  if (CHARSET_MAP.has(key)) {
@@ -2,7 +2,17 @@
2
2
 
3
3
  const { formatFlag, canUseFlag, formatDateTime, normalizePath, encodePath, comparePaths, enhanceCommandError } = require('../tools.js');
4
4
 
5
- // Appends a message to a mailbox
5
+ /**
6
+ * Appends a message to a mailbox.
7
+ *
8
+ * @param {Object} connection - IMAP connection instance
9
+ * @param {string} destination - Destination mailbox path
10
+ * @param {Buffer|string} content - Message content (RFC 822 format)
11
+ * @param {string|string[]} [flags] - Message flags to set on the appended message
12
+ * @param {Date|string} [idate] - Internal date to set for the message
13
+ * @returns {Promise<{destination: string, path?: string, uid?: number, uidValidity?: BigInt, seq?: number}|undefined>} Append result with UID info if available, or undefined if preconditions not met
14
+ * @throws {Error} If the APPEND command fails or message exceeds APPENDLIMIT
15
+ */
6
16
  module.exports = async (connection, destination, content, flags, idate) => {
7
17
  if (![connection.states.AUTHENTICATED, connection.states.SELECTED].includes(connection.state) || !destination) {
8
18
  // nothing to do here
@@ -13,6 +23,8 @@ module.exports = async (connection, destination, content, flags, idate) => {
13
23
  content = Buffer.from(content);
14
24
  }
15
25
 
26
+ // APPENDLIMIT capability (RFC 7889): server may advertise the maximum message
27
+ // size it accepts. Check before sending to avoid a wasted round-trip.
16
28
  if (connection.capabilities.has('APPENDLIMIT')) {
17
29
  let appendLimit = connection.capabilities.get('APPENDLIMIT');
18
30
  if (typeof appendLimit === 'number' && appendLimit < content.length) {
@@ -24,28 +36,36 @@ module.exports = async (connection, destination, content, flags, idate) => {
24
36
 
25
37
  destination = normalizePath(connection, destination);
26
38
 
39
+ // If appending to the currently selected mailbox, we can listen for the
40
+ // untagged EXISTS response to capture the new message's sequence number.
27
41
  let expectExists = comparePaths(connection, connection.mailbox.path, destination);
28
42
 
43
+ // Validate and format flags. Only flags allowed by the mailbox's permanentFlags are included.
29
44
  flags = (Array.isArray(flags) ? flags : [].concat(flags || []))
30
45
  .map(flag => flag && formatFlag(flag.toString()))
31
46
  .filter(flag => flag && canUseFlag(connection.mailbox, flag));
32
47
 
48
+ // APPEND command format: APPEND <mailbox> [<flags>] [<date-time>] <literal>
33
49
  let attributes = [{ type: 'ATOM', value: encodePath(connection, destination) }];
34
50
 
51
+ // Internal date: the date the server should record for this message.
52
+ // Must be quoted (STRING type) per the IMAP date-time grammar.
35
53
  idate = idate ? formatDateTime(idate) : false;
36
54
 
55
+ // Flags and date are optional; flags must come before date if both are present
37
56
  if (flags.length || idate) {
38
57
  attributes.push(flags.map(flag => ({ type: 'ATOM', value: flag })));
39
58
  }
40
59
 
41
60
  if (idate) {
42
- attributes.push({ type: 'STRING', value: idate }); // force quotes as required by date-time
61
+ attributes.push({ type: 'STRING', value: idate });
43
62
  }
44
63
 
64
+ // BINARY extension (RFC 3516): if the message content contains NUL bytes,
65
+ // use literal8 syntax (~{size}\r\n) instead of regular literal ({size}\r\n).
66
+ // Regular literals cannot contain NUL bytes per the IMAP grammar.
45
67
  let isLiteral8 = false;
46
68
  if (connection.capabilities.has('BINARY') && !connection.disableBinary) {
47
- // Value is literal8 if it contains NULL bytes. The server must support the BINARY extension
48
- // and if it does not then send the value as a regular literal and hope for the best
49
69
  isLiteral8 = content.indexOf(Buffer.from([0])) >= 0;
50
70
  }
51
71
 
@@ -56,72 +76,58 @@ module.exports = async (connection, destination, content, flags, idate) => {
56
76
  map.path = connection.mailbox.path;
57
77
  }
58
78
 
79
+ // Handler for untagged EXISTS: captures the new message count which gives
80
+ // us the sequence number of the appended message (it's the latest message).
81
+ const handleExistsUpdate = untagged => {
82
+ map.seq = Number(untagged.command);
83
+
84
+ // Update the connection's mailbox state and emit 'exists' event if the
85
+ // count changed (notifies listeners about the new message).
86
+ if (expectExists) {
87
+ let prevCount = connection.mailbox.exists;
88
+ if (map.seq !== prevCount) {
89
+ connection.mailbox.exists = map.seq;
90
+ connection.emit('exists', {
91
+ path: connection.mailbox.path,
92
+ count: map.seq,
93
+ prevCount
94
+ });
95
+ }
96
+ }
97
+ };
98
+
59
99
  let response;
60
100
  try {
61
101
  response = await connection.exec('APPEND', attributes, {
62
- untagged: expectExists
63
- ? {
64
- EXISTS: async untagged => {
65
- map.seq = Number(untagged.command);
66
-
67
- if (expectExists) {
68
- let prevCount = connection.mailbox.exists;
69
- if (map.seq !== prevCount) {
70
- connection.mailbox.exists = map.seq;
71
- connection.emit('exists', {
72
- path: connection.mailbox.path,
73
- count: map.seq,
74
- prevCount
75
- });
76
- }
77
- }
78
- }
79
- }
80
- : false
102
+ // Only listen for EXISTS if we're appending to the currently selected mailbox
103
+ untagged: expectExists ? { EXISTS: handleExistsUpdate } : false
81
104
  });
82
105
 
106
+ // UIDPLUS (RFC 4315): the server may include APPENDUID response code in
107
+ // the tagged OK. Format: [APPENDUID <uidValidity> <uid>]
83
108
  let section = response.response.attributes && response.response.attributes[0] && response.response.attributes[0].section;
84
109
  if (section && section.length) {
85
110
  let responseCode = section[0] && typeof section[0].value === 'string' ? section[0].value : '';
86
- switch (responseCode.toUpperCase()) {
87
- case 'APPENDUID':
88
- {
89
- let uidValidity = section[1] && typeof section[1].value === 'string' && !isNaN(section[1].value) ? BigInt(section[1].value) : false;
90
- let uid = section[2] && typeof section[2].value === 'string' && !isNaN(section[2].value) ? Number(section[2].value) : false;
91
- if (uidValidity) {
92
- map.uidValidity = uidValidity;
93
- }
94
- if (uid) {
95
- map.uid = uid;
96
- }
97
- }
98
- break;
111
+ if (responseCode.toUpperCase() === 'APPENDUID') {
112
+ let uidValidity = section[1] && typeof section[1].value === 'string' && !isNaN(section[1].value) ? BigInt(section[1].value) : false;
113
+ let uid = section[2] && typeof section[2].value === 'string' && !isNaN(section[2].value) ? Number(section[2].value) : false;
114
+ if (uidValidity) {
115
+ map.uidValidity = uidValidity;
116
+ }
117
+ if (uid) {
118
+ map.uid = uid;
119
+ }
99
120
  }
100
121
  }
101
122
 
102
123
  response.next();
103
124
 
125
+ // If we didn't get an EXISTS during APPEND (some servers don't send it
126
+ // until the next command), issue a NOOP to flush pending notifications.
104
127
  if (expectExists && !map.seq) {
105
- // try to use NOOP to get the new sequence number
106
128
  try {
107
129
  response = await connection.exec('NOOP', false, {
108
- untagged: {
109
- EXISTS: async untagged => {
110
- map.seq = Number(untagged.command);
111
-
112
- if (expectExists) {
113
- let prevCount = connection.mailbox.exists;
114
- if (map.seq !== prevCount) {
115
- connection.mailbox.exists = map.seq;
116
- connection.emit('exists', {
117
- path: connection.mailbox.path,
118
- count: map.seq,
119
- prevCount
120
- });
121
- }
122
- }
123
- }
124
- },
130
+ untagged: { EXISTS: handleExistsUpdate },
125
131
  comment: 'Sequence not found from APPEND output'
126
132
  });
127
133
  response.next();
@@ -130,6 +136,8 @@ module.exports = async (connection, destination, content, flags, idate) => {
130
136
  }
131
137
  }
132
138
 
139
+ // If we have a sequence number but no UID (server doesn't support UIDPLUS),
140
+ // look up the UID via SEARCH to provide a consistent result to the caller.
133
141
  if (map.seq && !map.uid) {
134
142
  let list = await connection.search({ seq: map.seq }, { uid: true });
135
143
  if (list && list.length) {
@@ -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 {