imapflow 2.2.6 → 2.2.8

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 (76) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/cjs/commands/append.js +10 -3
  3. package/dist/cjs/commands/authenticate.js +42 -22
  4. package/dist/cjs/commands/close.d.ts +12 -1
  5. package/dist/cjs/commands/close.js +4 -2
  6. package/dist/cjs/commands/delete.js +2 -1
  7. package/dist/cjs/commands/enable.js +6 -0
  8. package/dist/cjs/commands/esearch-parser.js +8 -2
  9. package/dist/cjs/commands/fetch.js +57 -14
  10. package/dist/cjs/commands/id.js +8 -1
  11. package/dist/cjs/commands/idle.js +15 -5
  12. package/dist/cjs/commands/list.js +10 -1
  13. package/dist/cjs/commands/login.js +5 -1
  14. package/dist/cjs/commands/logout.js +7 -0
  15. package/dist/cjs/commands/namespace.js +7 -3
  16. package/dist/cjs/commands/quota.js +3 -1
  17. package/dist/cjs/commands/rename.js +2 -1
  18. package/dist/cjs/commands/select.js +5 -0
  19. package/dist/cjs/commands/status.js +6 -1
  20. package/dist/cjs/commands/store.d.ts +1 -1
  21. package/dist/cjs/commands/store.js +8 -8
  22. package/dist/cjs/download.js +220 -95
  23. package/dist/cjs/handler/imap-compiler.js +19 -10
  24. package/dist/cjs/handler/limits.d.ts +11 -0
  25. package/dist/cjs/handler/limits.js +16 -1
  26. package/dist/cjs/handler/parser-instance.d.ts +10 -0
  27. package/dist/cjs/handler/parser-instance.js +25 -10
  28. package/dist/cjs/handler/token-parser.js +36 -28
  29. package/dist/cjs/imap-flow.d.ts +2 -2
  30. package/dist/cjs/imap-flow.js +256 -95
  31. package/dist/cjs/package-info.d.ts +1 -1
  32. package/dist/cjs/package-info.js +1 -1
  33. package/dist/cjs/proxy-connection.js +7 -7
  34. package/dist/cjs/search-compiler.js +33 -13
  35. package/dist/cjs/special-use.js +10 -5
  36. package/dist/cjs/tools.d.ts +14 -4
  37. package/dist/cjs/tools.js +69 -9
  38. package/dist/cjs/types.d.ts +19 -4
  39. package/dist/esm/commands/append.js +11 -4
  40. package/dist/esm/commands/authenticate.js +43 -23
  41. package/dist/esm/commands/close.d.ts +12 -1
  42. package/dist/esm/commands/close.js +5 -3
  43. package/dist/esm/commands/delete.js +2 -1
  44. package/dist/esm/commands/enable.js +6 -0
  45. package/dist/esm/commands/esearch-parser.js +8 -2
  46. package/dist/esm/commands/fetch.js +57 -14
  47. package/dist/esm/commands/id.js +9 -2
  48. package/dist/esm/commands/idle.js +16 -6
  49. package/dist/esm/commands/list.js +10 -1
  50. package/dist/esm/commands/login.js +6 -2
  51. package/dist/esm/commands/logout.js +7 -0
  52. package/dist/esm/commands/namespace.js +7 -3
  53. package/dist/esm/commands/quota.js +3 -1
  54. package/dist/esm/commands/rename.js +2 -1
  55. package/dist/esm/commands/select.js +5 -0
  56. package/dist/esm/commands/status.js +7 -2
  57. package/dist/esm/commands/store.d.ts +1 -1
  58. package/dist/esm/commands/store.js +9 -9
  59. package/dist/esm/download.js +220 -95
  60. package/dist/esm/handler/imap-compiler.js +19 -10
  61. package/dist/esm/handler/limits.d.ts +11 -0
  62. package/dist/esm/handler/limits.js +14 -0
  63. package/dist/esm/handler/parser-instance.d.ts +10 -0
  64. package/dist/esm/handler/parser-instance.js +25 -10
  65. package/dist/esm/handler/token-parser.js +37 -29
  66. package/dist/esm/imap-flow.d.ts +2 -2
  67. package/dist/esm/imap-flow.js +256 -95
  68. package/dist/esm/package-info.d.ts +1 -1
  69. package/dist/esm/package-info.js +1 -1
  70. package/dist/esm/proxy-connection.js +7 -7
  71. package/dist/esm/search-compiler.js +33 -13
  72. package/dist/esm/special-use.js +10 -5
  73. package/dist/esm/tools.d.ts +14 -4
  74. package/dist/esm/tools.js +68 -9
  75. package/dist/esm/types.d.ts +19 -4
  76. package/package.json +5 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.2.8](https://github.com/postalsys/imapflow/compare/v2.2.7...v2.2.8) (2026-10-07)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * decrement mailbox.exists on untagged VANISHED (RFC 7162 section 3.2.10) ([b40cea2](https://github.com/postalsys/imapflow/commit/b40cea22d19edd10428fe1cda98115c2f8b6cd7a))
9
+ * do not send sequence sets to an empty mailbox ([b40cea2](https://github.com/postalsys/imapflow/commit/b40cea22d19edd10428fe1cda98115c2f8b6cd7a))
10
+ * drop flags and keywords that are not atoms instead of sending them quoted ([b40cea2](https://github.com/postalsys/imapflow/commit/b40cea22d19edd10428fe1cda98115c2f8b6cd7a))
11
+ * encode and decode non-ASCII Gmail labels as modified UTF-7 like mailbox names ([b40cea2](https://github.com/postalsys/imapflow/commit/b40cea22d19edd10428fe1cda98115c2f8b6cd7a))
12
+ * expand the FETCH ALL, FAST and FULL macros instead of sending them in a list ([b40cea2](https://github.com/postalsys/imapflow/commit/b40cea22d19edd10428fe1cda98115c2f8b6cd7a))
13
+ * leave servername out of tls.connect() for IP literal hosts, which Bun rejects ([b40cea2](https://github.com/postalsys/imapflow/commit/b40cea22d19edd10428fe1cda98115c2f8b6cd7a))
14
+ * refetch body sections Apache James drops, check FETCH answers belong to the download, enable CONDSTORE with QRESYNC ([6973263](https://github.com/postalsys/imapflow/commit/697326393a4a66e743822d7fb0ac045454458492))
15
+ * send the OAuth token after the continuation request when SASL-IR is not advertised ([b40cea2](https://github.com/postalsys/imapflow/commit/b40cea22d19edd10428fe1cda98115c2f8b6cd7a))
16
+ * send the STORE UNCHANGEDSINCE modifier before the flags (RFC 7162 section 3.1.3) ([b40cea2](https://github.com/postalsys/imapflow/commit/b40cea22d19edd10428fe1cda98115c2f8b6cd7a))
17
+
18
+ ## [2.2.7](https://github.com/postalsys/imapflow/compare/v2.2.6...v2.2.7) (2026-10-07)
19
+
20
+
21
+ ### Bug Fixes
22
+
23
+ * close the transport with a failed connect, report a lost login once, and other reliability fixes ([6a3915a](https://github.com/postalsys/imapflow/commit/6a3915a0391442a9f4c98cf2622b70918387d26b))
24
+ * enforce downloadMany maxBytes, warn on cleartext STARTTLS fallback, refuse unknown search keys ([44f7b7f](https://github.com/postalsys/imapflow/commit/44f7b7ff0b93414507e3824597919b831868f257))
25
+ * harden session setup, SASL, search and folder handling found in code review ([d71d27e](https://github.com/postalsys/imapflow/commit/d71d27e9ccd44974c4424532b83920114126aacb))
26
+
3
27
  ## [2.2.6](https://github.com/postalsys/imapflow/compare/v2.2.5...v2.2.6) (2026-10-06)
4
28
 
5
29
 
@@ -158,10 +158,17 @@ async function append(connection, destination, content, flags, idate) {
158
158
  }
159
159
  // If we have a sequence number but no UID (server doesn't support UIDPLUS),
160
160
  // look up the UID via SEARCH to provide a consistent result to the caller.
161
+ // The message is already stored, so a failed lookup only leaves the UID out:
162
+ // rejecting here would make a retrying caller append it twice.
161
163
  if (map.seq && !map.uid) {
162
- let list = await connection.search({ seq: map.seq }, { uid: true });
163
- if (Array.isArray(list) && list.length) {
164
- map.uid = list[0];
164
+ try {
165
+ let list = await connection.search({ seq: map.seq }, { uid: true });
166
+ if (Array.isArray(list) && list.length) {
167
+ map.uid = list[0];
168
+ }
169
+ }
170
+ catch (err) {
171
+ (0, tools_js_1.logConnectionError)(connection, 'Failed to look up the UID of the appended message', err);
165
172
  }
166
173
  }
167
174
  return map;
@@ -14,7 +14,10 @@ async function handleAuthError(err, errorResponse) {
14
14
  if (errorCode) {
15
15
  err.serverResponseCode = errorCode;
16
16
  }
17
- err.authenticationFailed = true;
17
+ // Only a tagged NO/BAD is the server refusing the credentials, see login.ts
18
+ if ((0, tools_js_1.isServerRefusal)(err)) {
19
+ err.authenticationFailed = true;
20
+ }
18
21
  err.response = await (0, tools_js_1.getErrorText)(err.response);
19
22
  if (errorResponse) {
20
23
  err.oauthError = errorResponse;
@@ -63,15 +66,26 @@ async function authOauth(connection, username, accessToken) {
63
66
  // Empty breaker: XOAUTH2 expects an empty response to abort the SASL exchange
64
67
  breaker = '';
65
68
  }
69
+ let encoded = Buffer.from(oauthbearer).toString('base64');
70
+ // Without SASL-IR the payload may not ride on the command line (RFC 4959 section 3): it is
71
+ // sent as the answer to the first, empty continuation request instead
72
+ let payloadPending = !connection.capabilities.has('SASL-IR');
66
73
  let errorResponse = false;
67
74
  try {
68
- let response = await connection.exec('AUTHENTICATE', [
69
- { type: 'ATOM', value: command },
70
- { type: 'ATOM', value: Buffer.from(oauthbearer).toString('base64'), sensitive: true }
71
- ], {
75
+ let attributes = [{ type: 'ATOM', value: command }];
76
+ if (!payloadPending) {
77
+ attributes.push({ type: 'ATOM', value: encoded, sensitive: true });
78
+ }
79
+ let response = await connection.exec('AUTHENTICATE', attributes, {
72
80
  // Server sends a "+" continuation if auth fails, with a base64 JSON error payload.
73
81
  // We decode it for diagnostics, then send the breaker to terminate the exchange.
74
82
  onPlusTag: async (resp) => {
83
+ if (payloadPending) {
84
+ payloadPending = false;
85
+ connection.log.debug({ src: 'c', msg: '(* value hidden *)', comment: `Encoded response for AUTH=${command}`, cid: connection.id });
86
+ connection.write(encoded);
87
+ return;
88
+ }
75
89
  if (resp.attributes && resp.attributes[0] && resp.attributes[0].type === 'TEXT') {
76
90
  try {
77
91
  errorResponse = JSON.parse(Buffer.from(resp.attributes[0].value, 'base64').toString());
@@ -111,27 +125,33 @@ async function authLogin(connection, username, password) {
111
125
  try {
112
126
  // SASL LOGIN is a challenge-response mechanism: the server sends base64-encoded
113
127
  // prompts ("Username:" and "Password:") and the client responds with base64-encoded values.
128
+ let usernameSent = false;
114
129
  let response = await connection.exec('AUTHENTICATE', [{ type: 'ATOM', value: 'LOGIN' }], {
115
130
  onPlusTag: async (resp) => {
116
- if (resp.attributes && resp.attributes[0] && resp.attributes[0].type === 'TEXT') {
117
- // Decode the server's base64 challenge to determine what it's asking for.
118
- // Strip trailing colons and null bytes (\x00) that some servers append to the prompt.
119
- let question = Buffer.from(resp.attributes[0].value, 'base64')
131
+ // Decode the server's base64 challenge to determine what it's asking for.
132
+ // Strip trailing colons and null bytes (\x00) that some servers append to the prompt.
133
+ let question = resp.attributes && resp.attributes[0] && resp.attributes[0].type === 'TEXT'
134
+ ? Buffer.from(resp.attributes[0].value, 'base64')
120
135
  .toString()
121
136
  .toLowerCase()
122
- .replace(/[:\x00]*$/, '');
123
- if (question === 'username' || question === 'user name') {
124
- let encodedUsername = Buffer.from(username).toString('base64');
125
- connection.log.debug({ src: 'c', msg: encodedUsername, comment: `Encoded username for AUTH=LOGIN`, cid: connection.id });
126
- connection.write(encodedUsername);
127
- }
128
- else if (question === 'password') {
129
- connection.log.debug({ src: 'c', msg: '(* value hidden *)', comment: `Encoded password for AUTH=LOGIN`, cid: connection.id });
130
- connection.write(Buffer.from(password).toString('base64'));
131
- }
132
- else {
133
- throw new Error(`Unknown LOGIN question "${question}"`);
134
- }
137
+ .replace(/[:\x00]*$/, '')
138
+ : '';
139
+ // Some servers send an empty first challenge, which by SASL LOGIN convention asks for the username
140
+ if (question === 'username' || question === 'user name' || (!question && !usernameSent)) {
141
+ let encodedUsername = Buffer.from(username).toString('base64');
142
+ connection.log.debug({ src: 'c', msg: encodedUsername, comment: `Encoded username for AUTH=LOGIN`, cid: connection.id });
143
+ connection.write(encodedUsername);
144
+ usernameSent = true;
145
+ }
146
+ else if (question === 'password') {
147
+ connection.log.debug({ src: 'c', msg: '(* value hidden *)', comment: `Encoded password for AUTH=LOGIN`, cid: connection.id });
148
+ connection.write(Buffer.from(password).toString('base64'));
149
+ }
150
+ else {
151
+ // Cancel the exchange (RFC 9051 section 6.2.2), so the server fails the command
152
+ // with a tagged BAD instead of waiting for an answer that never comes
153
+ connection.log.warn({ msg: 'Unknown AUTH=LOGIN challenge, cancelling', question, cid: connection.id });
154
+ connection.write('*');
135
155
  }
136
156
  }
137
157
  });
@@ -1,8 +1,19 @@
1
1
  import type { ImapFlow } from '../imap-flow.js';
2
+ /**
3
+ * Options for the CLOSE command
4
+ */
5
+ export interface CloseCommandOptions {
6
+ /**
7
+ * Only deselect the mailbox: use UNSELECT (RFC 3691, folded into IMAP4rev2) when the server
8
+ * supports it, so messages flagged \Deleted are not expunged as a side effect. Falls back to CLOSE
9
+ */
10
+ unselect?: boolean | undefined;
11
+ }
2
12
  /**
3
13
  * Closes the currently selected mailbox.
4
14
  *
5
15
  * @param connection - IMAP connection instance
16
+ * @param options - Close options
6
17
  * @returns True on success, false on failure, or undefined if not in SELECTED state
7
18
  */
8
- export default function close(connection: ImapFlow): Promise<boolean | undefined>;
19
+ export default function close(connection: ImapFlow, options?: CloseCommandOptions | undefined): Promise<boolean | undefined>;
@@ -6,9 +6,10 @@ const tools_js_1 = require("../tools.js");
6
6
  * Closes the currently selected mailbox.
7
7
  *
8
8
  * @param connection - IMAP connection instance
9
+ * @param options - Close options
9
10
  * @returns True on success, false on failure, or undefined if not in SELECTED state
10
11
  */
11
- async function close(connection) {
12
+ async function close(connection, options) {
12
13
  if (connection.state !== connection.states.SELECTED) {
13
14
  // nothing to do here
14
15
  return;
@@ -18,7 +19,8 @@ async function close(connection) {
18
19
  // IMAP CLOSE (RFC 3501 6.4.2): permanently removes all messages flagged \Deleted
19
20
  // from the currently selected mailbox (implicit expunge) and deselects it.
20
21
  // Unlike EXPUNGE, CLOSE does not send individual untagged EXPUNGE responses.
21
- response = await connection.exec('CLOSE');
22
+ // UNSELECT deselects the same way without removing anything.
23
+ response = await connection.exec(options?.unselect && (0, tools_js_1.hasCapability)(connection, 'UNSELECT') ? 'UNSELECT' : 'CLOSE');
22
24
  response.next();
23
25
  // Transition from SELECTED back to AUTHENTICATED state.
24
26
  // Clear mailbox metadata so subsequent operations know no mailbox is selected.
@@ -20,7 +20,8 @@ async function deleteMailbox(connection, path) {
20
20
  // IMAP servers reject DELETE on the currently selected mailbox (RFC 3501 6.3.4).
21
21
  let selected = (0, tools_js_1.getSelectedMailbox)(connection);
22
22
  if (selected && selected.path === path) {
23
- await connection.run('CLOSE');
23
+ // UNSELECT where possible, CLOSE would expunge messages flagged \Deleted
24
+ await connection.run('CLOSE', { unselect: true });
24
25
  }
25
26
  let response;
26
27
  try {
@@ -49,6 +49,12 @@ async function enable(connection, extensionList) {
49
49
  // extensions enabled by this command (RFC 5161), so a replace would drop
50
50
  // grants from an earlier ENABLE call
51
51
  connection.enabled = new Set([...connection.enabled, ...enabled]);
52
+ if (connection.enabled.has('QRESYNC')) {
53
+ // ENABLE QRESYNC is a CONDSTORE enabling command (RFC 7162 3.2.3), whether or not the
54
+ // server lists CONDSTORE in its ENABLED answer. Apache James advertises only QRESYNC
55
+ // and leaves a lone ENABLE CONDSTORE unanswered, which the RFC allows.
56
+ connection.enabled.add('CONDSTORE');
57
+ }
52
58
  response.next();
53
59
  return connection.enabled;
54
60
  }
@@ -72,9 +72,15 @@ function parseEsearchResponse(attrs) {
72
72
  const items = Array.isArray(listToken) ? listToken : null;
73
73
  if (!items || items.length < 2)
74
74
  break;
75
+ const range = items[0]?.value;
76
+ if (typeof range !== 'string')
77
+ break;
78
+ // RFC 9394 partial-results is a sequence-set or NIL, the latter when the requested
79
+ // range lies past the end of the results. NIL is reported as an empty set.
80
+ const messages = items[1]?.value;
75
81
  result.partial = {
76
- range: items[0].value,
77
- messages: items[1].value
82
+ range,
83
+ messages: typeof messages === 'string' ? messages : ''
78
84
  };
79
85
  break;
80
86
  }
@@ -28,11 +28,20 @@ async function fetch(connection, range, query, options) {
28
28
  // Every pass returns or throws: the last throttled attempt throws instead of retrying.
29
29
  const maxRetries = 4;
30
30
  const baseDelay = 1000; // Start with 1 second delay
31
+ // The highest UID (sequence number for a plain FETCH) handed to the streaming consumer. A
32
+ // retried FETCH answers with every message again, so a retry skips up to it: servers answer
33
+ // in ascending order, which keeps this to one comparison per row rather than a set of every
34
+ // row delivered. The consumer was otherwise given the rows before the throttle twice.
35
+ let maxDelivered = 0;
31
36
  for (let retryCount = 0;; retryCount++) {
32
37
  let messages = {
33
38
  count: 0,
34
39
  list: []
35
40
  };
41
+ // The first error the onUntaggedFetch consumer reported through next(err). Errors thrown
42
+ // by untagged handlers are only logged by the connection, so it is kept here and fails
43
+ // the command once the FETCH completes; later messages are no longer handed to the consumer.
44
+ let consumerError = null;
36
45
  let response;
37
46
  try {
38
47
  /* c8 ignore next */ // range is guaranteed truthy by the early-return guard above, so the '*' fallback is unreachable
@@ -56,13 +65,30 @@ async function fetch(connection, range, query, options) {
56
65
  };
57
66
  queryStructure.push(bodyPeek);
58
67
  };
59
- // IMAP fetch macros (ALL, FAST, FULL) and standard data items map directly to IMAP atoms
60
- ['all', 'fast', 'full', 'uid', 'flags', 'bodyStructure', 'envelope', 'internalDate'].forEach(key => {
61
- if (query[key]) {
68
+ // The ALL, FAST and FULL macros may only be sent on their own, never in a list with other
69
+ // items (RFC 3501 section 9), and UID is always in the list, so they are expanded into
70
+ // the items they stand for. FULL is expanded to BODYSTRUCTURE rather than the
71
+ // non-extensible BODY, as documented for the full option.
72
+ let full = !!query.full;
73
+ let all = !!query.all || full;
74
+ let fast = !!query.fast || all;
75
+ let items = {
76
+ flags: query.flags || fast,
77
+ internalDate: query.internalDate || fast,
78
+ size: query.size || fast,
79
+ envelope: query.envelope || all,
80
+ bodyStructure: query.bodyStructure || full
81
+ };
82
+ // standard data items map directly to IMAP atoms
83
+ if (query.uid) {
84
+ queryStructure.push({ type: 'ATOM', value: 'UID' });
85
+ }
86
+ ['flags', 'bodyStructure', 'envelope', 'internalDate'].forEach(key => {
87
+ if (items[key]) {
62
88
  queryStructure.push({ type: 'ATOM', value: key.toUpperCase() });
63
89
  }
64
90
  });
65
- if (query.size) {
91
+ if (items.size) {
66
92
  queryStructure.push({ type: 'ATOM', value: 'RFC822.SIZE' });
67
93
  }
68
94
  // Fetch full message source, optionally with byte range (start/maxLength)
@@ -182,18 +208,32 @@ async function fetch(connection, range, query, options) {
182
208
  // (useful for large result sets). Otherwise, collect all into messages.list.
183
209
  FETCH: async (untagged) => {
184
210
  messages.count++;
185
- let formatted = await (0, tools_js_1.formatMessageResponse)(untagged, mailbox, connection.idHashAlgorithm);
211
+ if (consumerError) {
212
+ return;
213
+ }
214
+ let formatted = await (0, tools_js_1.formatMessageResponse)(untagged, mailbox, connection.idHashAlgorithm, connection);
186
215
  if (typeof options.onUntaggedFetch === 'function') {
187
- await new Promise((resolve, reject) => {
188
- options.onUntaggedFetch(formatted, err => {
189
- if (err) {
190
- reject(err);
191
- }
192
- else {
193
- resolve();
194
- }
216
+ /* c8 ignore next */ // a UID FETCH row without its UID is a non-compliant server, so the seq fallback is not exercised
217
+ let key = options.uid ? formatted.uid || formatted.seq : formatted.seq;
218
+ if (retryCount && key <= maxDelivered) {
219
+ return;
220
+ }
221
+ maxDelivered = Math.max(maxDelivered, key);
222
+ try {
223
+ await new Promise((resolve, reject) => {
224
+ options.onUntaggedFetch(formatted, err => {
225
+ if (err) {
226
+ reject(err);
227
+ }
228
+ else {
229
+ resolve();
230
+ }
231
+ });
195
232
  });
196
- });
233
+ }
234
+ catch (err) {
235
+ consumerError = err;
236
+ }
197
237
  }
198
238
  else {
199
239
  messages.list.push(formatted);
@@ -202,6 +242,9 @@ async function fetch(connection, range, query, options) {
202
242
  }
203
243
  });
204
244
  response.next();
245
+ if (consumerError) {
246
+ throw consumerError;
247
+ }
205
248
  return messages;
206
249
  }
207
250
  catch (err) {
@@ -43,7 +43,14 @@ async function id(connection, clientInfo) {
43
43
  key = val.value;
44
44
  }
45
45
  else if (typeof key === 'string' && typeof val.value === 'string') {
46
- map[key.toLowerCase().trim()] = val.value;
46
+ // The server picks the keys of this object, which the caller reads
47
+ // back as serverInfo: a prototype-chain name is skipped as it is for
48
+ // every other server-named key, so it can neither be shadowed nor
49
+ // written through
50
+ let name = key.toLowerCase().trim();
51
+ if (!(0, tools_js_1.isUnsafeKey)(name)) {
52
+ map[name] = val.value;
53
+ }
47
54
  }
48
55
  });
49
56
  }
@@ -60,8 +60,11 @@ async function runIdle(connection) {
60
60
  path: connection.mailbox && connection.mailbox.path,
61
61
  cid: connection.id
62
62
  });
63
- connection.write('DONE');
63
+ // Marked before the write: write() closes the connection when the transport is
64
+ // already gone, and close() breaks IDLE through this very function, which
65
+ // would otherwise write DONE again from inside itself
64
66
  doneSent = true;
67
+ connection.write('DONE');
65
68
  releaseIdling();
66
69
  if (connection.preCheck === ownPreCheck) {
67
70
  connection.preCheck = false; // unset itself
@@ -127,7 +130,10 @@ async function runIdle(connection) {
127
130
  // A tagged NO or BAD only means the server refused IDLE; the connection is still usable,
128
131
  // so the waiters are released by the finally block below and their own commands run.
129
132
  // Anything else (close, lost socket, parser failure) fails the waiters too.
130
- let refusedByServer = ['NO', 'BAD'].includes(err.responseStatus);
133
+ let refusedByServer = (0, tools_js_1.isServerRefusal)(err);
134
+ if (refusedByServer) {
135
+ connection.skipIdle = true;
136
+ }
131
137
  if (preCheckWaitQueue.length && !refusedByServer) {
132
138
  // One error for the whole queue: every waiter failed at the same site, for the same
133
139
  // reason. Built inside the guard so a teardown with nothing queued - the common case -
@@ -313,7 +319,7 @@ async function idle(connection, maxIdleTime) {
313
319
  // If server supports IDLE (RFC 2177, folded into base IMAP4rev2), use it for
314
320
  // real-time push notifications. Otherwise, fall back to periodic polling with
315
321
  // NOOP/STATUS/SELECT.
316
- if ((0, tools_js_1.hasCapability)(connection, 'IDLE')) {
322
+ if ((0, tools_js_1.hasCapability)(connection, 'IDLE') && !connection.skipIdle) {
317
323
  let idleTimer;
318
324
  let stillIdling = false;
319
325
  // IDLE loop: runs IDLE, and if maxIdleTime is reached, breaks and restarts to keep the
@@ -337,13 +343,17 @@ async function idle(connection, maxIdleTime) {
337
343
  }
338
344
  let resp = await runIdle(connection);
339
345
  clearTimeout(idleTimer);
340
- if (!stillIdling) {
346
+ // A restart only makes sense with nothing queued behind the break (a CLOSE, say; run()
347
+ // re-arms auto-IDLE once that command is done) and the mailbox still selected on a
348
+ // usable connection
349
+ const canRestart = stillIdling && !connection.requestQueue.length && !!(0, tools_js_1.getSelectedMailbox)(connection) && connection.usable;
350
+ if (!canRestart) {
341
351
  return resp;
342
352
  }
343
353
  stillIdling = false;
344
354
  }
345
355
  }
346
- // Fallback for servers without IDLE support: poll at regular intervals using
356
+ // Fallback for servers without IDLE support, or that refused it: poll at regular intervals using
347
357
  // NOOP (default), STATUS, or SELECT depending on missingIdleCommand config.
348
358
  return runPollingFallback(connection, maxIdleTime);
349
359
  }
@@ -378,6 +378,15 @@ async function list(connection, reference, mailbox, options) {
378
378
  // Subscribed-only mailboxes that weren't in LIST are intentionally ignored
379
379
  // (they may be phantom entries from old subscriptions to deleted mailboxes).
380
380
  let runLsub = async () => {
381
+ // LIST has completed, so its entries are indexed once instead of searched per LSUB
382
+ // response, which was quadratic in the folder count. The first entry for a path wins,
383
+ // as with the linear search it replaces
384
+ let entriesByPath = new Map();
385
+ for (let entry of entries) {
386
+ if (!entriesByPath.has(entry.path)) {
387
+ entriesByPath.set(entry.path, entry);
388
+ }
389
+ }
381
390
  let response = await connection.exec('LSUB', [(0, tools_js_1.encodePath)(connection, normalizedReference), (0, tools_js_1.encodePath)(connection, normalizedMailbox)], {
382
391
  untagged: {
383
392
  LSUB: async (untagged) => {
@@ -403,7 +412,7 @@ async function list(connection, reference, mailbox, options) {
403
412
  entry.parent = entry.delimiter ? entry.path.split(entry.delimiter) : [entry.path];
404
413
  entry.name = entry.parent.pop();
405
414
  // Merge LSUB data into existing LIST entry if found
406
- let existing = entries.find(existing => existing.path === entry.path);
415
+ let existing = entriesByPath.get(entry.path);
407
416
  if (existing) {
408
417
  existing.subscribed = true;
409
418
  // Merge any additional flags from LSUB into the LIST entry
@@ -33,7 +33,11 @@ async function login(connection, username, password) {
33
33
  if (errorCode) {
34
34
  err.serverResponseCode = errorCode;
35
35
  }
36
- err.authenticationFailed = true;
36
+ // Only a tagged NO/BAD is the server refusing the credentials; a lost connection or a
37
+ // timeout during LOGIN says nothing about them, and a caller may retry it
38
+ if ((0, tools_js_1.isServerRefusal)(err)) {
39
+ err.authenticationFailed = true;
40
+ }
37
41
  err.response = await (0, tools_js_1.getErrorText)(err.response);
38
42
  throw err;
39
43
  }
@@ -1,6 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.default = logout;
4
+ const tools_js_1 = require("../tools.js");
5
+ // How long to wait for the server to answer LOGOUT before closing the socket anyway. Without a
6
+ // bound of its own, an unanswered LOGOUT kept logout() pending until the socket timeout.
7
+ const LOGOUT_TIMEOUT = 10 * 1000;
4
8
  /**
5
9
  * Logs out the user and closes the connection.
6
10
  *
@@ -19,6 +23,8 @@ async function logout(connection) {
19
23
  return false;
20
24
  }
21
25
  let response;
26
+ // close() rejects the pending LOGOUT with NoConnection, which counts as a completed logout
27
+ let timer = setTimeout(() => connection.close(), LOGOUT_TIMEOUT);
22
28
  try {
23
29
  response = await connection.exec('LOGOUT');
24
30
  return true;
@@ -36,6 +42,7 @@ async function logout(connection) {
36
42
  // Set state to LOGOUT before closing to prevent any further commands from
37
43
  // being queued. The socket is closed unconditionally in this finally block
38
44
  // regardless of whether the LOGOUT command succeeded or failed.
45
+ (0, tools_js_1.clearTimer)(timer);
39
46
  connection.state = connection.states.LOGOUT;
40
47
  if (response && typeof response.next === 'function') {
41
48
  response.next();
@@ -34,7 +34,8 @@ async function namespace(connection) {
34
34
  }
35
35
  let response;
36
36
  try {
37
- let map = {};
37
+ // NIL personal namespaces and a missing NAMESPACE response leave the defaults in place
38
+ let map = { personal: [], other: false, shared: false };
38
39
  response = await connection.exec('NAMESPACE', false, {
39
40
  untagged: {
40
41
  // The NAMESPACE response (RFC 2342) contains exactly three sections:
@@ -46,19 +47,22 @@ async function namespace(connection) {
46
47
  if (!untagged.attributes || !untagged.attributes.length) {
47
48
  return;
48
49
  }
49
- map.personal = getNamsepaceInfo(untagged.attributes[0]);
50
+ // NIL personal namespaces are legal (RFC 2342 section 5, e.g. after an anonymous login)
51
+ map.personal = getNamsepaceInfo(untagged.attributes[0]) || [];
50
52
  map.other = getNamsepaceInfo(untagged.attributes[1]);
51
53
  map.shared = getNamsepaceInfo(untagged.attributes[2]);
52
54
  }
53
55
  }
54
56
  });
57
+ // Release the response before touching the parsed data, so nothing below can leave the
58
+ // reader parked behind this command
59
+ response.next();
55
60
  connection.namespaces = map;
56
61
  // make sure that we have the first personal namespace always set
57
62
  if (!connection.namespaces.personal[0]) {
58
63
  connection.namespaces.personal[0] = { prefix: '', delimiter: '.' };
59
64
  }
60
65
  connection.namespaces.personal[0].prefix = connection.namespaces.personal[0].prefix || '';
61
- response.next();
62
66
  connection.namespace = connection.namespaces.personal[0];
63
67
  return connection.namespace;
64
68
  }
@@ -14,7 +14,9 @@ async function quota(connection, path) {
14
14
  // nothing to do here
15
15
  return;
16
16
  }
17
- if (!connection.capabilities.has('QUOTA')) {
17
+ // An RFC 9208 server advertises its QUOTA=RES-* resource types and does not have to list
18
+ // the bare RFC 2087 QUOTA token as well
19
+ if (!connection.capabilities.has('QUOTA') && ![...connection.capabilities.keys()].some(capability => capability.startsWith('QUOTA=RES-'))) {
18
20
  return false;
19
21
  }
20
22
  path = (0, tools_js_1.normalizePath)(connection, path);
@@ -24,7 +24,8 @@ async function rename(connection, path, newPath) {
24
24
  // as IMAP servers will not rename an active mailbox.
25
25
  let selected = (0, tools_js_1.getSelectedMailbox)(connection);
26
26
  if (selected && selected.path === path) {
27
- await connection.run('CLOSE');
27
+ // UNSELECT where possible, CLOSE would expunge messages flagged \Deleted
28
+ await connection.run('CLOSE', { unselect: true });
28
29
  }
29
30
  let response;
30
31
  try {
@@ -229,6 +229,11 @@ async function select(connection, pathInput, options) {
229
229
  if (!currentMailbox || currentMailbox.path !== path) {
230
230
  (0, tools_js_1.emitSafe)(connection, 'mailboxOpen', connection.mailbox);
231
231
  }
232
+ else if (typeof map.exists === 'number' && map.exists !== currentMailbox.exists) {
233
+ // A re-SELECT of the open mailbox (the SELECT polling fallback) gets its EXISTS here
234
+ // instead of in the global handler, so it is reported the same way
235
+ (0, tools_js_1.emitSafe)(connection, 'exists', { path, count: map.exists, prevCount: currentMailbox.exists });
236
+ }
232
237
  response.next();
233
238
  return map;
234
239
  }
@@ -92,7 +92,12 @@ async function status(connection, path, query) {
92
92
  // Not a deadlock, and only reachable when the server rejects the STATUS, but a polled
93
93
  // STATUS of a missing folder ends the poll early.
94
94
  if (err.responseStatus === 'NO') {
95
- let folders = await connection.run('LIST', '', path, { listOnly: true });
95
+ // A failing probe (lost connection, throttling) answers nothing about the mailbox,
96
+ // so STATUS then fails the same way as any other failed STATUS
97
+ let folders = await connection.run('LIST', '', path, { listOnly: true }).catch((listErr) => {
98
+ (0, tools_js_1.logConnectionError)(connection, 'Failed to check if the mailbox exists', listErr);
99
+ return false;
100
+ });
96
101
  if (folders && !folders.length) {
97
102
  let error = new Error(`Mailbox doesn't exist: ${path}`);
98
103
  error.code = 'NotFound';
@@ -16,4 +16,4 @@ export interface StoreCommandOptions extends StoreOptions {
16
16
  * @param options - Store options
17
17
  * @returns True on success, false on failure or if nothing to do
18
18
  */
19
- export default function store(connection: ImapFlow, range: string, flags: string | string[], options: StoreCommandOptions): Promise<boolean>;
19
+ export default function store(connection: ImapFlow, range: string, flags: string | string[], options?: StoreCommandOptions | undefined): Promise<boolean>;
@@ -12,13 +12,12 @@ const tools_js_1 = require("../tools.js");
12
12
  * @returns True on success, false on failure or if nothing to do
13
13
  */
14
14
  async function store(connection, range, flags, options) {
15
+ options = options || {};
15
16
  let mailbox = (0, tools_js_1.getSelectedMailbox)(connection);
16
17
  if (!mailbox || !range || (options.useLabels && !connection.capabilities.has('X-GM-EXT-1'))) {
17
18
  // nothing to do here
18
19
  return false;
19
20
  }
20
- /* c8 ignore next */ // options.useLabels is dereferenced in the guard above, so options is always defined here
21
- options = options || {};
22
21
  // Build the IMAP STORE operation name. The format is:
23
22
  // [+|-]FLAGS[.SILENT] or [+|-]X-GM-LABELS
24
23
  // Where: no prefix = replace all, + = add, - = remove
@@ -62,7 +61,10 @@ async function store(connection, range, flags, options) {
62
61
  const dropped = [];
63
62
  flags = (Array.isArray(flags) ? flags : [].concat(flags || []))
64
63
  .map(flag => {
65
- let formatted = (0, tools_js_1.formatFlag)(flag);
64
+ // Gmail labels other than the \-prefixed system labels are mailbox names: astrings in
65
+ // the form mailbox names take on the session (modified UTF-7 unless UTF-8 is enabled),
66
+ // not atoms like IMAP keywords
67
+ let formatted = options.useLabels && flag && flag.charAt(0) !== '\\' ? (0, tools_js_1.encodePath)(connection, flag) : (0, tools_js_1.formatFlag)(flag);
66
68
  if (!formatted || (!(0, tools_js_1.canUseFlag)(flagSource, formatted) && operationName !== 'remove')) {
67
69
  dropped.push(flag);
68
70
  return false;
@@ -84,13 +86,10 @@ async function store(connection, range, flags, options) {
84
86
  if (!flags.length && !clearAll) {
85
87
  return false;
86
88
  }
87
- let attributes = [
88
- { type: 'SEQUENCE', value: range },
89
- { type: 'ATOM', value: operation },
90
- flags.map(flag => ({ type: 'ATOM', value: flag }))
91
- ];
89
+ let attributes = [{ type: 'SEQUENCE', value: range }];
92
90
  // CONDSTORE (RFC 7162): UNCHANGEDSINCE modifier prevents updating messages whose
93
91
  // mod-sequence is higher than the specified value, avoiding overwriting concurrent changes.
92
+ // The store-modifiers list goes between the sequence set and the item name (section 3.1.3).
94
93
  if (options.unchangedSince && connection.enabled.has('CONDSTORE') && !mailbox.noModseq) {
95
94
  attributes.push([
96
95
  {
@@ -103,6 +102,7 @@ async function store(connection, range, flags, options) {
103
102
  }
104
103
  ]);
105
104
  }
105
+ attributes.push({ type: 'ATOM', value: operation }, flags.map(flag => ({ type: 'ATOM', value: flag })));
106
106
  let response;
107
107
  try {
108
108
  response = await connection.exec(options.uid ? 'UID STORE' : 'STORE', attributes);