imapflow 2.2.6 → 2.2.7

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 (72) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/cjs/commands/append.js +10 -3
  3. package/dist/cjs/commands/authenticate.js +27 -18
  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/esearch-parser.js +8 -2
  8. package/dist/cjs/commands/fetch.js +35 -9
  9. package/dist/cjs/commands/id.js +8 -1
  10. package/dist/cjs/commands/idle.js +15 -5
  11. package/dist/cjs/commands/list.js +10 -1
  12. package/dist/cjs/commands/login.js +5 -1
  13. package/dist/cjs/commands/logout.js +7 -0
  14. package/dist/cjs/commands/namespace.js +7 -3
  15. package/dist/cjs/commands/quota.js +3 -1
  16. package/dist/cjs/commands/rename.js +2 -1
  17. package/dist/cjs/commands/select.js +5 -0
  18. package/dist/cjs/commands/status.js +6 -1
  19. package/dist/cjs/commands/store.d.ts +1 -1
  20. package/dist/cjs/commands/store.js +1 -2
  21. package/dist/cjs/download.js +82 -91
  22. package/dist/cjs/handler/imap-compiler.js +19 -10
  23. package/dist/cjs/handler/limits.d.ts +11 -0
  24. package/dist/cjs/handler/limits.js +16 -1
  25. package/dist/cjs/handler/parser-instance.d.ts +10 -0
  26. package/dist/cjs/handler/parser-instance.js +25 -10
  27. package/dist/cjs/handler/token-parser.js +36 -28
  28. package/dist/cjs/imap-flow.js +235 -89
  29. package/dist/cjs/package-info.d.ts +1 -1
  30. package/dist/cjs/package-info.js +1 -1
  31. package/dist/cjs/proxy-connection.js +7 -7
  32. package/dist/cjs/search-compiler.js +30 -13
  33. package/dist/cjs/special-use.js +10 -5
  34. package/dist/cjs/tools.d.ts +10 -1
  35. package/dist/cjs/tools.js +30 -3
  36. package/dist/cjs/types.d.ts +19 -4
  37. package/dist/esm/commands/append.js +11 -4
  38. package/dist/esm/commands/authenticate.js +28 -19
  39. package/dist/esm/commands/close.d.ts +12 -1
  40. package/dist/esm/commands/close.js +5 -3
  41. package/dist/esm/commands/delete.js +2 -1
  42. package/dist/esm/commands/esearch-parser.js +8 -2
  43. package/dist/esm/commands/fetch.js +35 -9
  44. package/dist/esm/commands/id.js +9 -2
  45. package/dist/esm/commands/idle.js +16 -6
  46. package/dist/esm/commands/list.js +10 -1
  47. package/dist/esm/commands/login.js +6 -2
  48. package/dist/esm/commands/logout.js +7 -0
  49. package/dist/esm/commands/namespace.js +7 -3
  50. package/dist/esm/commands/quota.js +3 -1
  51. package/dist/esm/commands/rename.js +2 -1
  52. package/dist/esm/commands/select.js +5 -0
  53. package/dist/esm/commands/status.js +7 -2
  54. package/dist/esm/commands/store.d.ts +1 -1
  55. package/dist/esm/commands/store.js +1 -2
  56. package/dist/esm/download.js +82 -91
  57. package/dist/esm/handler/imap-compiler.js +19 -10
  58. package/dist/esm/handler/limits.d.ts +11 -0
  59. package/dist/esm/handler/limits.js +14 -0
  60. package/dist/esm/handler/parser-instance.d.ts +10 -0
  61. package/dist/esm/handler/parser-instance.js +25 -10
  62. package/dist/esm/handler/token-parser.js +37 -29
  63. package/dist/esm/imap-flow.js +235 -89
  64. package/dist/esm/package-info.d.ts +1 -1
  65. package/dist/esm/package-info.js +1 -1
  66. package/dist/esm/proxy-connection.js +7 -7
  67. package/dist/esm/search-compiler.js +30 -13
  68. package/dist/esm/special-use.js +10 -5
  69. package/dist/esm/tools.d.ts +10 -1
  70. package/dist/esm/tools.js +29 -3
  71. package/dist/esm/types.d.ts +19 -4
  72. package/package.json +1 -1
@@ -53,17 +53,12 @@ let toSequenceValue = (value) => [].concat(value).join(',');
53
53
  let toSearchValue = (value) => UNICODE_PATTERN.test(value) && !value.includes('\0') ? { type: 'LITERAL', value: Buffer.from(value) } : { type: 'ATOM', value };
54
54
  /**
55
55
  * Adds a search option with its value(s) to the attributes array.
56
- * Handles NOT operations and array values.
57
56
  *
58
57
  * @param attributes - Array to append the attribute to
59
58
  * @param term - The search term (e.g., 'FROM', 'SUBJECT')
60
- * @param value - The value for the search term (string, array, or falsy for NOT)
59
+ * @param value - The value for the search term (string or array)
61
60
  */
62
61
  let setOpt = (attributes, term, value) => {
63
- // Handle NOT operations for false or null values
64
- if (value === false || value === null) {
65
- attributes.push({ type: 'ATOM', value: 'NOT' });
66
- }
67
62
  attributes.push({ type: 'ATOM', value: term.toUpperCase() });
68
63
  // Handle array values (e.g. HEADER name/value pairs)
69
64
  if (Array.isArray(value)) {
@@ -253,6 +248,11 @@ const searchCompiler = (connection, query) => {
253
248
  break;
254
249
  // Email ID support (OBJECTID or Gmail extension)
255
250
  case 'EMAILID':
251
+ // A falsy value means no criterion, as for the text fields. It used to
252
+ // compile into NOT EMAILID "false", which matched every message
253
+ if (!params[term]) {
254
+ break;
255
+ }
256
256
  if (connection.capabilities.has('OBJECTID')) {
257
257
  setOpt(attributes, 'EMAILID', params[term]);
258
258
  }
@@ -260,7 +260,7 @@ const searchCompiler = (connection, query) => {
260
260
  // Fallback to Gmail message ID
261
261
  setOpt(attributes, 'X-GM-MSGID', params[term]);
262
262
  }
263
- else if (params[term]) {
263
+ else {
264
264
  // Dropping the criterion would widen the search to every message
265
265
  // matching the rest of the query, which a delete or move acts on
266
266
  fail('MissingServerExtension', 'Server does not support OBJECTID or X-GM-EXT-1 extension required for EMAILID');
@@ -268,6 +268,11 @@ const searchCompiler = (connection, query) => {
268
268
  break;
269
269
  // Thread ID support (OBJECTID or Gmail extension)
270
270
  case 'THREADID':
271
+ // A falsy value means no criterion, as for the text fields. It used to
272
+ // compile into NOT THREADID "false", which matched every message
273
+ if (!params[term]) {
274
+ break;
275
+ }
271
276
  if (connection.capabilities.has('OBJECTID')) {
272
277
  setOpt(attributes, 'THREADID', params[term]);
273
278
  }
@@ -275,7 +280,7 @@ const searchCompiler = (connection, query) => {
275
280
  // Fallback to Gmail thread ID
276
281
  setOpt(attributes, 'X-GM-THRID', params[term]);
277
282
  }
278
- else if (params[term]) {
283
+ else {
279
284
  // Dropping the criterion would widen the search to every message
280
285
  // matching the rest of the query, which a delete or move acts on
281
286
  fail('MissingServerExtension', 'Server does not support OBJECTID or X-GM-EXT-1 extension required for THREADID');
@@ -338,11 +343,11 @@ const searchCompiler = (connection, query) => {
338
343
  if (!value) {
339
344
  break;
340
345
  }
341
- // Use WITHIN extension for better timezone handling if available
342
- if (connection.capabilities.has('WITHIN')) {
343
- // Convert to seconds ago from now
344
- const now = Date.now();
345
- const withinSeconds = Math.round(Math.max(0, now - value.getTime()) / 1000);
346
+ // Use WITHIN extension for better timezone handling if available.
347
+ // The interval is an nz-number (RFC 5032), so a date that is not in the
348
+ // past (OLDER 0 would be answered with BAD) takes the date path instead
349
+ const withinSeconds = Math.round((Date.now() - value.getTime()) / 1000);
350
+ if (connection.capabilities.has('WITHIN') && withinSeconds >= 1) {
346
351
  const withinKeyword = term.toUpperCase() === 'BEFORE' ? 'OLDER' : 'YOUNGER';
347
352
  setOpt(attributes, withinKeyword, withinSeconds.toString());
348
353
  break;
@@ -362,7 +367,15 @@ const searchCompiler = (connection, query) => {
362
367
  case 'KEYWORD':
363
368
  case 'UNKEYWORD':
364
369
  {
370
+ if (typeof params[term] !== 'string') {
371
+ fail('InvalidSearchQuery', `Search value for ${term.toLowerCase()} must be a string`);
372
+ }
365
373
  let flag = (0, tools_js_1.formatFlag)(params[term]);
374
+ // formatFlag() refuses \Recent, which is not a keyword. Dropping the
375
+ // criterion would widen the search, so the query is refused instead
376
+ if (flag === false) {
377
+ fail('InvalidSearchQuery', `${params[term]} can not be searched as a keyword, use the "recent" search key instead`);
378
+ }
366
379
  // Compiled even when the mailbox does not allow the keyword: the
367
380
  // correct answer is then the empty set, which dropping the
368
381
  // criterion would turn into every message matching the rest
@@ -460,6 +473,10 @@ const searchCompiler = (connection, query) => {
460
473
  walkOrTree(genOrTree(params[term]));
461
474
  }
462
475
  break;
476
+ default:
477
+ // An unknown key is refused: dropping it silently widened the search (a
478
+ // query of only unknown keys matched every message)
479
+ fail('InvalidSearchQuery', `Unknown search key "${term}"`);
463
480
  }
464
481
  });
465
482
  };
@@ -77,6 +77,7 @@ const TOKEN_SPLIT = /[\s\-_/.,()[\]]+/;
77
77
  // we fall back to matching folder names against these lists of known
78
78
  // translations in various languages (including non-Latin scripts).
79
79
  exports.flags = ['\\All', '\\Archive', '\\Drafts', '\\Flagged', '\\Junk', '\\Sent', '\\Trash'];
80
+ const LOWERCASE_FLAGS = exports.flags.map(flag => flag.toLowerCase());
80
81
  exports.names = {
81
82
  '\\Sent': [
82
83
  'aika',
@@ -863,9 +864,10 @@ for (let flag of Object.keys(exports.names)) {
863
864
  }
864
865
  }
865
866
  // Fold a folder name into the form the name tables are stored in.
866
- // Remove U+200E (LEFT-TO-RIGHT MARK) which some mail clients (especially for
867
- // RTL languages like Arabic, Hebrew) insert into folder names for display purposes.
868
- // These invisible marks would otherwise prevent exact string matching.
867
+ // Remove U+200E and U+200F (LEFT-TO-RIGHT and RIGHT-TO-LEFT MARK) which some mail clients
868
+ // (especially for RTL languages like Arabic, Hebrew) insert into folder names for display
869
+ // purposes. These invisible marks would otherwise prevent exact string matching. U+200B
870
+ // (ZERO WIDTH SPACE) is kept, Khmer names in the tables use it as their word separator.
869
871
  // Normalize with NFKC last: the same folder name can arrive in different but
870
872
  // equivalent forms depending on the client that created it, and matching is exact.
871
873
  // NFKC rather than NFC because the compatibility folding is what maps halfwidth
@@ -874,7 +876,7 @@ for (let flag of Object.keys(exports.names)) {
874
876
  function normalizeName(name) {
875
877
  return name
876
878
  .toLowerCase()
877
- .replace(/\u200e/g, '')
879
+ .replace(/[\u200e\u200f]/g, '')
878
880
  .trim()
879
881
  .normalize('NFKC');
880
882
  }
@@ -883,7 +885,10 @@ const specialUse = (hasSpecialUseExtension, folder) => {
883
885
  // Extension-provided flags take precedence over name-based detection because they
884
886
  // are authoritative - the server explicitly marks the folder's role.
885
887
  if (hasSpecialUseExtension) {
886
- const flag = exports.flags.find(flag => folder.flags.has(flag));
888
+ // Flags are atoms and compare case-insensitively (RFC 9051 section 9)
889
+ const listed = new Set();
890
+ folder.flags.forEach(flag => listed.add(flag.toLowerCase()));
891
+ const flag = exports.flags.find((flag, i) => listed.has(LOWERCASE_FLAGS[i]));
887
892
  if (flag) {
888
893
  return { flag, source: 'extension' };
889
894
  }
@@ -119,6 +119,14 @@ export declare function unrefTimer<T extends NodeJS.Timeout | null | undefined>(
119
119
  * @param err - The error to log
120
120
  */
121
121
  export declare function logConnectionError(connection: ImapFlow, msg: string, err: ImapFlowError | null | undefined): void;
122
+ /**
123
+ * Whether a command failed because the server answered it with a tagged NO or BAD, as opposed
124
+ * to a lost connection, a timeout or a local failure that says nothing about the server's answer.
125
+ *
126
+ * @param err - The error the command failed with
127
+ * @returns True for a tagged NO or BAD
128
+ */
129
+ export declare function isServerRefusal(err: ImapFlowError | null | undefined): boolean;
122
130
  /**
123
131
  * Checks whether IMAP4rev2 semantics are active for the connection: either the
124
132
  * client enabled IMAP4rev2 explicitly, or the server is rev2-only (advertises
@@ -407,7 +415,8 @@ export declare function isUnsafeKey(key: unknown): boolean;
407
415
  /**
408
416
  * Reads a parsed attribute list of atoms or strings (a flag list, a capability list) into
409
417
  * an array of strings. Any element can be a parsed NIL, and the list itself can be NIL,
410
- * so both levels are guarded here rather than at each call site.
418
+ * so both levels are guarded here rather than at each call site. A literal (a Gmail label
419
+ * the server could not send quoted) arrives as a Buffer and is decoded as UTF-8.
411
420
  *
412
421
  * @param list - Parsed attribute list from a response.
413
422
  * @returns The string values, in order, with unusable entries dropped.
package/dist/cjs/tools.js CHANGED
@@ -12,6 +12,7 @@ exports.guardedReject = guardedReject;
12
12
  exports.clearTimer = clearTimer;
13
13
  exports.unrefTimer = unrefTimer;
14
14
  exports.logConnectionError = logConnectionError;
15
+ exports.isServerRefusal = isServerRefusal;
15
16
  exports.isRev2Active = isRev2Active;
16
17
  exports.hasCapability = hasCapability;
17
18
  exports.buildStatusQueryAttributes = buildStatusQueryAttributes;
@@ -266,6 +267,16 @@ function logConnectionError(connection, msg, err) {
266
267
  let routine = !!err && CONNECTION_GONE_CODES.has(err.code) && !err.reason;
267
268
  connection.log[routine ? 'debug' : 'warn']({ msg, err, cid: connection.id });
268
269
  }
270
+ /**
271
+ * Whether a command failed because the server answered it with a tagged NO or BAD, as opposed
272
+ * to a lost connection, a timeout or a local failure that says nothing about the server's answer.
273
+ *
274
+ * @param err - The error the command failed with
275
+ * @returns True for a tagged NO or BAD
276
+ */
277
+ function isServerRefusal(err) {
278
+ return !!err && (err.responseStatus === 'NO' || err.responseStatus === 'BAD');
279
+ }
269
280
  /**
270
281
  * Checks whether IMAP4rev2 semantics are active for the connection: either the
271
282
  * client enabled IMAP4rev2 explicitly, or the server is rev2-only (advertises
@@ -593,7 +604,12 @@ function getFolderTree(folders) {
593
604
  }
594
605
  return node;
595
606
  };
596
- for (let folder of folders) {
607
+ // Parents are inserted before their children, as getTreeNode() can only descend into nodes
608
+ // that already exist. LIST gives no ordering guarantee, and the LIST command sorts special-use
609
+ // mailboxes first, which put a child such as "[Gmail]/Sent Mail" ahead of "[Gmail]" and left
610
+ // it at the root. The sort is stable, so siblings keep their listing order
611
+ let byDepth = [...folders].sort((a, b) => (a.parent ? a.parent.length : 0) - (b.parent ? b.parent.length : 0));
612
+ for (let folder of byDepth) {
597
613
  let parent = getTreeNode(folder.parent);
598
614
  // see if entry already exists
599
615
  let existing = parent.folders && parent.folders.find(existing => existing.name === folder.name);
@@ -1412,7 +1428,8 @@ function isUnsafeKey(key) {
1412
1428
  /**
1413
1429
  * Reads a parsed attribute list of atoms or strings (a flag list, a capability list) into
1414
1430
  * an array of strings. Any element can be a parsed NIL, and the list itself can be NIL,
1415
- * so both levels are guarded here rather than at each call site.
1431
+ * so both levels are guarded here rather than at each call site. A literal (a Gmail label
1432
+ * the server could not send quoted) arrives as a Buffer and is decoded as UTF-8.
1416
1433
  *
1417
1434
  * @param list - Parsed attribute list from a response.
1418
1435
  * @returns The string values, in order, with unusable entries dropped.
@@ -1421,7 +1438,17 @@ function getStringList(list) {
1421
1438
  if (!Array.isArray(list)) {
1422
1439
  return [];
1423
1440
  }
1424
- return list.map(entry => (entry && typeof entry.value === 'string' ? entry.value : false)).filter(entry => entry);
1441
+ let strings = [];
1442
+ for (let entry of list) {
1443
+ let value = entry && entry.value;
1444
+ if (Buffer.isBuffer(value)) {
1445
+ value = value.toString();
1446
+ }
1447
+ if (value && typeof value === 'string') {
1448
+ strings.push(value);
1449
+ }
1450
+ }
1451
+ return strings;
1425
1452
  }
1426
1453
  /**
1427
1454
  * Parses an untrusted decimal value from a server response into a BigInt.
@@ -155,7 +155,15 @@ export interface ImapFlowOptions {
155
155
  * 30 minutes. Set to 0 or false to disable.
156
156
  */
157
157
  maxLockHoldTime?: number | false | undefined;
158
- /** If true, uses TLS. If false, uses cleartext. If not set, upgrades to TLS if available */
158
+ /**
159
+ * STARTTLS policy for a connection that was not opened with `secure: true`. If true, the
160
+ * connection must upgrade to TLS, and connecting fails when the server does not offer
161
+ * STARTTLS. If false, the connection stays in cleartext. If not set, the connection upgrades
162
+ * when the server offers STARTTLS and otherwise continues in cleartext with a warning logged:
163
+ * the capability list that decides this was itself received in cleartext, so an attacker on
164
+ * the path can strip it. Set `doSTARTTLS: true` whenever the credentials must never cross the
165
+ * wire unencrypted.
166
+ */
159
167
  doSTARTTLS?: boolean | undefined;
160
168
  /** Custom instance ID string for logs */
161
169
  id?: string | undefined;
@@ -646,8 +654,8 @@ export interface DownloadOptions {
646
654
  /** How large content parts to ask from the server. Defaults to 65536 */
647
655
  chunkSize?: number | undefined;
648
656
  }
649
- /** Options for downloadMany(), the same shape as the download() options */
650
- export type DownloadManyOptions = DownloadOptions;
657
+ /** Options for downloadMany(): the download() options without `chunkSize`, as the parts come in one FETCH */
658
+ export type DownloadManyOptions = Pick<DownloadOptions, 'uid' | 'maxBytes'>;
651
659
  export interface DownloadManyPart {
652
660
  meta: DownloadMeta;
653
661
  content?: Buffer | null | undefined;
@@ -769,6 +777,13 @@ export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal';
769
777
  */
770
778
  export type InternalLogger = {
771
779
  [level in LogLevel]: (obj: any) => void;
780
+ } & {
781
+ /**
782
+ * Whether an entry logged at this level reaches anyone (the logger, or a 'log' event
783
+ * listener). Set on the logger the connection builds; a logger assigned from outside may
784
+ * lack it, and every level then counts as enabled
785
+ */
786
+ isLevelEnabled?: ((level: LogLevel) => boolean) | undefined;
772
787
  };
773
788
  export interface LogEvent {
774
789
  /** Log level */
@@ -802,7 +817,7 @@ export interface ESearchResult {
802
817
  partial?: {
803
818
  /** The requested range, e.g. "1:100" */
804
819
  range: string;
805
- /** Matching UIDs in that range as compact sequence-set */
820
+ /** Matching UIDs in that range as compact sequence-set, an empty string when the range lies past the end of the results */
806
821
  messages: string;
807
822
  } | undefined;
808
823
  /** Highest mod-sequence of the matching messages (RFC 7162, present when the search used a modseq criterion on a CONDSTORE session) */
@@ -1,4 +1,4 @@
1
- import { formatFlag, canUseFlag, formatDateTime, normalizePath, encodePath, comparePaths, parseBigIntValue, parseUintValue, MAX_UINT32_DIGITS, getSelectedMailbox, emitSafe, isAuthenticatedState, reportCommandError } from '../tools.js';
1
+ import { formatFlag, canUseFlag, formatDateTime, normalizePath, encodePath, comparePaths, parseBigIntValue, parseUintValue, MAX_UINT32_DIGITS, getSelectedMailbox, emitSafe, isAuthenticatedState, reportCommandError, logConnectionError } from '../tools.js';
2
2
  /**
3
3
  * Appends a message to a mailbox.
4
4
  *
@@ -155,10 +155,17 @@ export default async function append(connection, destination, content, flags, id
155
155
  }
156
156
  // If we have a sequence number but no UID (server doesn't support UIDPLUS),
157
157
  // look up the UID via SEARCH to provide a consistent result to the caller.
158
+ // The message is already stored, so a failed lookup only leaves the UID out:
159
+ // rejecting here would make a retrying caller append it twice.
158
160
  if (map.seq && !map.uid) {
159
- let list = await connection.search({ seq: map.seq }, { uid: true });
160
- if (Array.isArray(list) && list.length) {
161
- map.uid = list[0];
161
+ try {
162
+ let list = await connection.search({ seq: map.seq }, { uid: true });
163
+ if (Array.isArray(list) && list.length) {
164
+ map.uid = list[0];
165
+ }
166
+ }
167
+ catch (err) {
168
+ logConnectionError(connection, 'Failed to look up the UID of the appended message', err);
162
169
  }
163
170
  }
164
171
  return map;
@@ -1,4 +1,4 @@
1
- import { getStatusCode, getErrorText } from '../tools.js';
1
+ import { getStatusCode, getErrorText, isServerRefusal } from '../tools.js';
2
2
  /**
3
3
  * Handles authentication errors by enriching the error object with server response details.
4
4
  *
@@ -11,7 +11,10 @@ async function handleAuthError(err, errorResponse) {
11
11
  if (errorCode) {
12
12
  err.serverResponseCode = errorCode;
13
13
  }
14
- err.authenticationFailed = true;
14
+ // Only a tagged NO/BAD is the server refusing the credentials, see login.ts
15
+ if (isServerRefusal(err)) {
16
+ err.authenticationFailed = true;
17
+ }
15
18
  err.response = await getErrorText(err.response);
16
19
  if (errorResponse) {
17
20
  err.oauthError = errorResponse;
@@ -108,27 +111,33 @@ async function authLogin(connection, username, password) {
108
111
  try {
109
112
  // SASL LOGIN is a challenge-response mechanism: the server sends base64-encoded
110
113
  // prompts ("Username:" and "Password:") and the client responds with base64-encoded values.
114
+ let usernameSent = false;
111
115
  let response = await connection.exec('AUTHENTICATE', [{ type: 'ATOM', value: 'LOGIN' }], {
112
116
  onPlusTag: async (resp) => {
113
- if (resp.attributes && resp.attributes[0] && resp.attributes[0].type === 'TEXT') {
114
- // Decode the server's base64 challenge to determine what it's asking for.
115
- // Strip trailing colons and null bytes (\x00) that some servers append to the prompt.
116
- let question = Buffer.from(resp.attributes[0].value, 'base64')
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 = resp.attributes && resp.attributes[0] && resp.attributes[0].type === 'TEXT'
120
+ ? Buffer.from(resp.attributes[0].value, 'base64')
117
121
  .toString()
118
122
  .toLowerCase()
119
- .replace(/[:\x00]*$/, '');
120
- if (question === 'username' || question === 'user name') {
121
- let encodedUsername = Buffer.from(username).toString('base64');
122
- connection.log.debug({ src: 'c', msg: encodedUsername, comment: `Encoded username for AUTH=LOGIN`, cid: connection.id });
123
- connection.write(encodedUsername);
124
- }
125
- else if (question === 'password') {
126
- connection.log.debug({ src: 'c', msg: '(* value hidden *)', comment: `Encoded password for AUTH=LOGIN`, cid: connection.id });
127
- connection.write(Buffer.from(password).toString('base64'));
128
- }
129
- else {
130
- throw new Error(`Unknown LOGIN question "${question}"`);
131
- }
123
+ .replace(/[:\x00]*$/, '')
124
+ : '';
125
+ // Some servers send an empty first challenge, which by SASL LOGIN convention asks for the username
126
+ if (question === 'username' || question === 'user name' || (!question && !usernameSent)) {
127
+ let encodedUsername = Buffer.from(username).toString('base64');
128
+ connection.log.debug({ src: 'c', msg: encodedUsername, comment: `Encoded username for AUTH=LOGIN`, cid: connection.id });
129
+ connection.write(encodedUsername);
130
+ usernameSent = true;
131
+ }
132
+ else if (question === 'password') {
133
+ connection.log.debug({ src: 'c', msg: '(* value hidden *)', comment: `Encoded password for AUTH=LOGIN`, cid: connection.id });
134
+ connection.write(Buffer.from(password).toString('base64'));
135
+ }
136
+ else {
137
+ // Cancel the exchange (RFC 9051 section 6.2.2), so the server fails the command
138
+ // with a tagged BAD instead of waiting for an answer that never comes
139
+ connection.log.warn({ msg: 'Unknown AUTH=LOGIN challenge, cancelling', question, cid: connection.id });
140
+ connection.write('*');
132
141
  }
133
142
  }
134
143
  });
@@ -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>;
@@ -1,11 +1,12 @@
1
- import { emitSafe } from '../tools.js';
1
+ import { emitSafe, hasCapability } from '../tools.js';
2
2
  /**
3
3
  * Closes the currently selected mailbox.
4
4
  *
5
5
  * @param connection - IMAP connection instance
6
+ * @param options - Close options
6
7
  * @returns True on success, false on failure, or undefined if not in SELECTED state
7
8
  */
8
- export default async function close(connection) {
9
+ export default async function close(connection, options) {
9
10
  if (connection.state !== connection.states.SELECTED) {
10
11
  // nothing to do here
11
12
  return;
@@ -15,7 +16,8 @@ export default async function close(connection) {
15
16
  // IMAP CLOSE (RFC 3501 6.4.2): permanently removes all messages flagged \Deleted
16
17
  // from the currently selected mailbox (implicit expunge) and deselects it.
17
18
  // Unlike EXPUNGE, CLOSE does not send individual untagged EXPUNGE responses.
18
- response = await connection.exec('CLOSE');
19
+ // UNSELECT deselects the same way without removing anything.
20
+ response = await connection.exec(options?.unselect && hasCapability(connection, 'UNSELECT') ? 'UNSELECT' : 'CLOSE');
19
21
  response.next();
20
22
  // Transition from SELECTED back to AUTHENTICATED state.
21
23
  // Clear mailbox metadata so subsequent operations know no mailbox is selected.
@@ -17,7 +17,8 @@ export default async function deleteMailbox(connection, path) {
17
17
  // IMAP servers reject DELETE on the currently selected mailbox (RFC 3501 6.3.4).
18
18
  let selected = getSelectedMailbox(connection);
19
19
  if (selected && selected.path === path) {
20
- await connection.run('CLOSE');
20
+ // UNSELECT where possible, CLOSE would expunge messages flagged \Deleted
21
+ await connection.run('CLOSE', { unselect: true });
21
22
  }
22
23
  let response;
23
24
  try {
@@ -69,9 +69,15 @@ export function parseEsearchResponse(attrs) {
69
69
  const items = Array.isArray(listToken) ? listToken : null;
70
70
  if (!items || items.length < 2)
71
71
  break;
72
+ const range = items[0]?.value;
73
+ if (typeof range !== 'string')
74
+ break;
75
+ // RFC 9394 partial-results is a sequence-set or NIL, the latter when the requested
76
+ // range lies past the end of the results. NIL is reported as an empty set.
77
+ const messages = items[1]?.value;
72
78
  result.partial = {
73
- range: items[0].value,
74
- messages: items[1].value
79
+ range,
80
+ messages: typeof messages === 'string' ? messages : ''
75
81
  };
76
82
  break;
77
83
  }
@@ -25,11 +25,20 @@ export default async function fetch(connection, range, query, options) {
25
25
  // Every pass returns or throws: the last throttled attempt throws instead of retrying.
26
26
  const maxRetries = 4;
27
27
  const baseDelay = 1000; // Start with 1 second delay
28
+ // The highest UID (sequence number for a plain FETCH) handed to the streaming consumer. A
29
+ // retried FETCH answers with every message again, so a retry skips up to it: servers answer
30
+ // in ascending order, which keeps this to one comparison per row rather than a set of every
31
+ // row delivered. The consumer was otherwise given the rows before the throttle twice.
32
+ let maxDelivered = 0;
28
33
  for (let retryCount = 0;; retryCount++) {
29
34
  let messages = {
30
35
  count: 0,
31
36
  list: []
32
37
  };
38
+ // The first error the onUntaggedFetch consumer reported through next(err). Errors thrown
39
+ // by untagged handlers are only logged by the connection, so it is kept here and fails
40
+ // the command once the FETCH completes; later messages are no longer handed to the consumer.
41
+ let consumerError = null;
33
42
  let response;
34
43
  try {
35
44
  /* c8 ignore next */ // range is guaranteed truthy by the early-return guard above, so the '*' fallback is unreachable
@@ -179,18 +188,32 @@ export default async function fetch(connection, range, query, options) {
179
188
  // (useful for large result sets). Otherwise, collect all into messages.list.
180
189
  FETCH: async (untagged) => {
181
190
  messages.count++;
191
+ if (consumerError) {
192
+ return;
193
+ }
182
194
  let formatted = await formatMessageResponse(untagged, mailbox, connection.idHashAlgorithm);
183
195
  if (typeof options.onUntaggedFetch === 'function') {
184
- await new Promise((resolve, reject) => {
185
- options.onUntaggedFetch(formatted, err => {
186
- if (err) {
187
- reject(err);
188
- }
189
- else {
190
- resolve();
191
- }
196
+ /* c8 ignore next */ // a UID FETCH row without its UID is a non-compliant server, so the seq fallback is not exercised
197
+ let key = options.uid ? formatted.uid || formatted.seq : formatted.seq;
198
+ if (retryCount && key <= maxDelivered) {
199
+ return;
200
+ }
201
+ maxDelivered = Math.max(maxDelivered, key);
202
+ try {
203
+ await new Promise((resolve, reject) => {
204
+ options.onUntaggedFetch(formatted, err => {
205
+ if (err) {
206
+ reject(err);
207
+ }
208
+ else {
209
+ resolve();
210
+ }
211
+ });
192
212
  });
193
- });
213
+ }
214
+ catch (err) {
215
+ consumerError = err;
216
+ }
194
217
  }
195
218
  else {
196
219
  messages.list.push(formatted);
@@ -199,6 +222,9 @@ export default async function fetch(connection, range, query, options) {
199
222
  }
200
223
  });
201
224
  response.next();
225
+ if (consumerError) {
226
+ throw consumerError;
227
+ }
202
228
  return messages;
203
229
  }
204
230
  catch (err) {
@@ -1,4 +1,4 @@
1
- import { formatDateTime } from '../tools.js';
1
+ import { formatDateTime, isUnsafeKey } from '../tools.js';
2
2
  /**
3
3
  * Sends ID info to the server and updates server info data based on the response.
4
4
  *
@@ -40,7 +40,14 @@ export default async function id(connection, clientInfo) {
40
40
  key = val.value;
41
41
  }
42
42
  else if (typeof key === 'string' && typeof val.value === 'string') {
43
- map[key.toLowerCase().trim()] = val.value;
43
+ // The server picks the keys of this object, which the caller reads
44
+ // back as serverInfo: a prototype-chain name is skipped as it is for
45
+ // every other server-named key, so it can neither be shadowed nor
46
+ // written through
47
+ let name = key.toLowerCase().trim();
48
+ if (!isUnsafeKey(name)) {
49
+ map[name] = val.value;
50
+ }
44
51
  }
45
52
  });
46
53
  }
@@ -1,4 +1,4 @@
1
- import { guardedPromise, hasCapability, logConnectionError, restampConnectionError, unrefTimer, clearTimer, getSelectedMailbox } from '../tools.js';
1
+ import { guardedPromise, hasCapability, isServerRefusal, logConnectionError, restampConnectionError, unrefTimer, clearTimer, getSelectedMailbox } from '../tools.js';
2
2
  const NOOP_INTERVAL = 2 * 60 * 1000;
3
3
  /**
4
4
  * Marks the connection as idling on behalf of one session and returns a release function.
@@ -57,8 +57,11 @@ async function runIdle(connection) {
57
57
  path: connection.mailbox && connection.mailbox.path,
58
58
  cid: connection.id
59
59
  });
60
- connection.write('DONE');
60
+ // Marked before the write: write() closes the connection when the transport is
61
+ // already gone, and close() breaks IDLE through this very function, which
62
+ // would otherwise write DONE again from inside itself
61
63
  doneSent = true;
64
+ connection.write('DONE');
62
65
  releaseIdling();
63
66
  if (connection.preCheck === ownPreCheck) {
64
67
  connection.preCheck = false; // unset itself
@@ -124,7 +127,10 @@ async function runIdle(connection) {
124
127
  // A tagged NO or BAD only means the server refused IDLE; the connection is still usable,
125
128
  // so the waiters are released by the finally block below and their own commands run.
126
129
  // Anything else (close, lost socket, parser failure) fails the waiters too.
127
- let refusedByServer = ['NO', 'BAD'].includes(err.responseStatus);
130
+ let refusedByServer = isServerRefusal(err);
131
+ if (refusedByServer) {
132
+ connection.skipIdle = true;
133
+ }
128
134
  if (preCheckWaitQueue.length && !refusedByServer) {
129
135
  // One error for the whole queue: every waiter failed at the same site, for the same
130
136
  // reason. Built inside the guard so a teardown with nothing queued - the common case -
@@ -310,7 +316,7 @@ export default async function idle(connection, maxIdleTime) {
310
316
  // If server supports IDLE (RFC 2177, folded into base IMAP4rev2), use it for
311
317
  // real-time push notifications. Otherwise, fall back to periodic polling with
312
318
  // NOOP/STATUS/SELECT.
313
- if (hasCapability(connection, 'IDLE')) {
319
+ if (hasCapability(connection, 'IDLE') && !connection.skipIdle) {
314
320
  let idleTimer;
315
321
  let stillIdling = false;
316
322
  // IDLE loop: runs IDLE, and if maxIdleTime is reached, breaks and restarts to keep the
@@ -334,13 +340,17 @@ export default async function idle(connection, maxIdleTime) {
334
340
  }
335
341
  let resp = await runIdle(connection);
336
342
  clearTimeout(idleTimer);
337
- if (!stillIdling) {
343
+ // A restart only makes sense with nothing queued behind the break (a CLOSE, say; run()
344
+ // re-arms auto-IDLE once that command is done) and the mailbox still selected on a
345
+ // usable connection
346
+ const canRestart = stillIdling && !connection.requestQueue.length && !!getSelectedMailbox(connection) && connection.usable;
347
+ if (!canRestart) {
338
348
  return resp;
339
349
  }
340
350
  stillIdling = false;
341
351
  }
342
352
  }
343
- // Fallback for servers without IDLE support: poll at regular intervals using
353
+ // Fallback for servers without IDLE support, or that refused it: poll at regular intervals using
344
354
  // NOOP (default), STATUS, or SELECT depending on missingIdleCommand config.
345
355
  return runPollingFallback(connection, maxIdleTime);
346
356
  }