imapflow 1.6.6 → 1.7.1

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.
package/lib/imap-flow.js CHANGED
@@ -38,6 +38,7 @@ const {
38
38
  AuthenticationFailure,
39
39
  getColorFlags,
40
40
  hasCapability,
41
+ logConnectionError,
41
42
  unrefTimer,
42
43
  parseUintValue,
43
44
  isUnsafeKey,
@@ -64,6 +65,91 @@ const MAX_THROTTLE_DELAY = 5 * 60 * 1000;
64
65
  // ImapFlow constructor option `maxLockHoldTime`. Set to 0 or false to disable.
65
66
  const HELD_LOCK_WARN_MS = 30 * 60 * 1000;
66
67
 
68
+ // How long the connection has to stay inactive before auto-IDLE starts. Long enough that a caller
69
+ // running a sequence of commands is not interrupted by an IDLE it immediately has to break.
70
+ // Configurable via the ImapFlow constructor option `autoIdleDelay`.
71
+ const AUTO_IDLE_DELAY = 15 * 1000;
72
+
73
+ // Headroom kept between the auto-IDLE delay and the socket inactivity watchdog, so IDLE reaches
74
+ // the wire before the watchdog can fire. See normalizeAutoIdleDelay().
75
+ const AUTO_IDLE_SOCKET_MARGIN = 1000;
76
+
77
+ // Commands whose client frames carry credentials; the raw traffic log withholds frame content
78
+ // while one of these is in flight. See the logRaw branch in write().
79
+ const RAW_SENSITIVE_COMMANDS = new Set(['LOGIN', 'AUTHENTICATE']);
80
+
81
+ // Stand-in payload for a withheld raw client frame. Fixed width, so the entry says nothing
82
+ // about the length of what it replaced.
83
+ const RAW_HIDDEN_PLACEHOLDER = Buffer.from('(* value hidden *)\r\n').toString('base64');
84
+
85
+ // Whether any attribute of a command is marked as a secret. Recurses into nested lists because
86
+ // the command compiler honors `sensitive` at any depth, and the two must agree on what counts.
87
+ function hasSensitiveAttribute(attributes) {
88
+ return [].concat(attributes || []).some(node => (Array.isArray(node) ? hasSensitiveAttribute(node) : !!node && node.sensitive));
89
+ }
90
+
91
+ // How deep flattenLoggedError() follows a chain of errors. Bounded because the chain comes from
92
+ // whatever failed, not from this library: a cause chain can be arbitrarily long, and the cycle
93
+ // check below only catches errors that repeat.
94
+ const MAX_ERROR_FLATTEN_DEPTH = 4;
95
+
96
+ // Recognizes an Error without instanceof, which fails for an error that crossed a realm boundary
97
+ // (worker thread, vm context) even though it serializes exactly the same way.
98
+ function isErrorLike(value) {
99
+ return value instanceof Error || (!!value && typeof value === 'object' && typeof value.message === 'string' && typeof value.stack === 'string');
100
+ }
101
+
102
+ // An Error carries `message` and `stack` on its prototype rather than as own enumerable
103
+ // properties, so JSON.stringify() renders one as `{}` and both logger fallback paths (the console
104
+ // fallback and emitLogs) would drop everything identifying it. Flattening happens here for both,
105
+ // so their shapes cannot drift apart.
106
+ //
107
+ // Nested errors are flattened too, because the top level is often not where the answer is: this
108
+ // library attaches the underlying failure as an enumerable `_err` (proxy setup, response
109
+ // processing, normalized connection deadlines), and Node reports a multi-address connect failure
110
+ // as an AggregateError whose members hold the per-address causes.
111
+ function flattenLoggedError(value, depth = 0, seen = new Set()) {
112
+ if (depth >= MAX_ERROR_FLATTEN_DEPTH) {
113
+ return isErrorLike(value) ? value.message : value;
114
+ }
115
+
116
+ if (Array.isArray(value)) {
117
+ return value.map(entry => flattenLoggedError(entry, depth + 1, seen));
118
+ }
119
+
120
+ if (!isErrorLike(value)) {
121
+ // Anything else is left alone: exploding a Buffer would produce one key per byte, and a
122
+ // Date would become a pair of undefined fields.
123
+ return value;
124
+ }
125
+
126
+ // A repeat renders as its message alone, so a chain that loops back does not restate a full
127
+ // stack for every level down to the depth cap
128
+ if (seen.has(value)) {
129
+ return value.message;
130
+ }
131
+ seen.add(value);
132
+
133
+ let flatErr = {
134
+ message: value.message,
135
+ stack: value.stack
136
+ };
137
+
138
+ // `cause` (passed through the Error options argument) and the AggregateError members are own
139
+ // properties but not enumerable, so Object.keys does not list them
140
+ for (let key of new Set([...Object.keys(value), 'cause', 'errors'])) {
141
+ if (key in value) {
142
+ flatErr[key] = flattenLoggedError(value[key], depth + 1, seen);
143
+ }
144
+ }
145
+
146
+ return flatErr;
147
+ }
148
+
149
+ // The largest delay setTimeout can honor (2^31 - 1 ms). Anything above fires after 1 ms instead,
150
+ // so the auto-IDLE delay cap has to stay inside this range even when socketTimeout is not.
151
+ const MAX_TIMER_DELAY = 2 ** 31 - 1;
152
+
67
153
  const states = {
68
154
  NOT_AUTHENTICATED: 0x01,
69
155
  AUTHENTICATED: 0x02,
@@ -71,6 +157,49 @@ const states = {
71
157
  LOGOUT: 0x04
72
158
  };
73
159
 
160
+ /**
161
+ * Normalizes the configured auto-IDLE delay into a value `setTimeout` can honor. Anything Node
162
+ * would silently turn into a 1ms timer - NaN, a negative number, a value above the 32-bit range -
163
+ * falls back to the default instead, because a 1ms delay means an IDLE/DONE round trip around
164
+ * every single command. The delay is also capped below `socketTimeout`, see AUTO_IDLE_SOCKET_MARGIN.
165
+ *
166
+ * @param {*} value - The configured `autoIdleDelay` option.
167
+ * @param {Number} socketTimeout - The normalized socket inactivity timeout.
168
+ * @param {Object} log - Logger, used to report a value that could not be used as given.
169
+ * @param {String} cid - Connection id for the log entry.
170
+ * @returns {Number} Delay in milliseconds.
171
+ */
172
+ const normalizeAutoIdleDelay = (value, socketTimeout, log, cid) => {
173
+ const maxDelay = Math.max(0, Math.min(socketTimeout, MAX_TIMER_DELAY) - AUTO_IDLE_SOCKET_MARGIN);
174
+ const configured = value !== undefined && value !== null;
175
+
176
+ // Numeric strings are accepted, because configuration usually arrives from an environment
177
+ // variable or a JSON file. Booleans and blank strings are not: Number() would read them as 0,
178
+ // i.e. "IDLE around every command", the opposite of the "off" they suggest.
179
+ let delay = typeof value === 'number' || (typeof value === 'string' && value.trim()) ? Number(value) : NaN;
180
+ let reason = null;
181
+
182
+ if (!Number.isFinite(delay) || delay < 0) {
183
+ reason = 'not a non-negative finite number';
184
+ delay = AUTO_IDLE_DELAY;
185
+ }
186
+
187
+ if (delay > maxDelay) {
188
+ // An invalid value keeps its own reason: the cap then applies to the fallback default,
189
+ // not to anything the caller asked for.
190
+ reason = reason || `above socketTimeout (${socketTimeout} ms)`;
191
+ delay = maxDelay;
192
+ }
193
+
194
+ // Only an explicitly configured value is worth warning about. Capping the default because the
195
+ // caller picked a short socketTimeout is expected behavior, not a misconfiguration.
196
+ if (configured && reason) {
197
+ log.warn({ msg: 'Adjusted unusable autoIdleDelay option', requested: value, autoIdleDelay: delay, reason, cid });
198
+ }
199
+
200
+ return Math.floor(delay);
201
+ };
202
+
74
203
  /**
75
204
  * @typedef {Object} MailboxObject
76
205
  * @global
@@ -188,6 +317,15 @@ class ImapFlow extends EventEmitter {
188
317
  * @property {Boolean} [disableAutoIdle=false]
189
318
  * If `true`, do not start IDLE automatically. Useful when only specific operations are needed.
190
319
  *
320
+ * @property {Number} [autoIdleDelay=15000]
321
+ * How long (in milliseconds) the connection has to be inactive before IDLE is started automatically.
322
+ * Keep it above the pause your own code usually leaves between two commands, otherwise every command is
323
+ * followed by an IDLE that the next command has to break, costing two extra round-trips per command.
324
+ * To turn auto-IDLE off entirely use `disableAutoIdle` rather than a very large delay: the value is
325
+ * capped below `socketTimeout`, because auto-IDLE has to start before the inactivity watchdog fires.
326
+ * On servers without IDLE support this controls when the polling fallback starts, not how often it
327
+ * polls - the poll interval is `maxIdleTime`, capped at 2 minutes.
328
+ *
191
329
  * @property {Object} [tls]
192
330
  * Additional TLS options. For details, see [Node.js TLS connect](https://nodejs.org/api/tls.html#tls_tls_connect_options_callback).
193
331
  *
@@ -206,6 +344,7 @@ class ImapFlow extends EventEmitter {
206
344
  *
207
345
  * @property {Boolean} [logRaw=false]
208
346
  * If `true`, logs all raw data (read and written) in base64 encoding. You can pipe such logs to [eerawlog](https://github.com/postalsys/eerawlog) command for readable output.
347
+ * Client frames that carry credentials are replaced with a fixed placeholder and the entry is marked with `hidden: true`.
209
348
  *
210
349
  * @property {Boolean} [emitLogs=false]
211
350
  * If `true`, emits `'log'` events with the same data passed to the logger.
@@ -373,6 +512,13 @@ class ImapFlow extends EventEmitter {
373
512
 
374
513
  this.commandParts = [];
375
514
 
515
+ // Whether the command currently being written carries credentials. send() sets this for
516
+ // every command before its first frame reaches the socket, and the raw traffic log reads
517
+ // it; every write belongs to the command send() dispatched last, because trySend() keeps
518
+ // one command in flight at a time. The initial value only covers a write before the
519
+ // first command, which no current path performs. See write().
520
+ this.rawSensitiveCommand = true;
521
+
376
522
  /**
377
523
  * Active IMAP capabilities. Value is either `true` for toggleable capabilities (eg. `UIDPLUS`)
378
524
  * or a number for capabilities with a value (eg. `APPENDLIMIT`)
@@ -438,6 +584,14 @@ class ImapFlow extends EventEmitter {
438
584
  this.idRequested = false;
439
585
 
440
586
  this.maxIdleTime = this.options.maxIdleTime || false;
587
+ this.autoIdleDelay = normalizeAutoIdleDelay(this.options.autoIdleDelay, this.socketTimeout, this.log, this.id);
588
+
589
+ // Wall-clock time of the last fallback poll, owned by lib/commands/idle.js
590
+ this._lastPollAt = 0;
591
+
592
+ // Download streams still fetching chunks. Counted, not a flag, so overlapping downloads
593
+ // cannot clear each other's suppression of auto-IDLE.
594
+ this._openDownloads = 0;
441
595
  this.missingIdleCommand = (this.options.missingIdleCommand || '').toString().toUpperCase().trim() || 'NOOP';
442
596
 
443
597
  this.disableBinary = !!this.options.disableBinary;
@@ -559,10 +713,17 @@ class ImapFlow extends EventEmitter {
559
713
  }
560
714
 
561
715
  if (this.logRaw) {
716
+ // Client frames of an authentication exchange carry credentials: the LOGIN
717
+ // arguments, and for AUTHENTICATE also the continuation writes (SASL PLAIN
718
+ // response, AUTH=LOGIN password, OAuth token payload) that bypass send(). The
719
+ // parsed command log masks these, so the raw log must withhold them too, but
720
+ // `data` still carries the placeholder rather than being dropped - the field is
721
+ // part of the documented log format and consumers decode it unconditionally.
562
722
  this.log.trace({
563
723
  src: 'c',
564
724
  msg: 'write to socket',
565
- data: chunk.toString('base64'),
725
+ data: this.rawSensitiveCommand ? RAW_HIDDEN_PLACEHOLDER : chunk.toString('base64'),
726
+ ...(this.rawSensitiveCommand ? { hidden: true } : {}),
566
727
  compress: !!this._deflate,
567
728
  secure: !!this.secureConnection,
568
729
  cid: this.id
@@ -618,6 +779,19 @@ class ImapFlow extends EventEmitter {
618
779
  return;
619
780
  }
620
781
 
782
+ // Classify before the first await. Every frame of this command - the command line and
783
+ // any continuation write that follows it - belongs to it until the next send(), because
784
+ // trySend() keeps one command in flight at a time. Reading currentRequest inside write()
785
+ // instead would be racy: rejectCurrentRequest() can clear it while the two compiler
786
+ // awaits below are pending, and the credential frame would then be logged in the clear.
787
+ // Uppercased because the wire protocol is case-insensitive and exec() passes the
788
+ // caller's spelling through unchanged. The command list covers the mechanisms whose
789
+ // secret arrives in a continuation frame, which carries no attributes of its own; the
790
+ // `sensitive` marker catches anything that instead puts a secret on the command line,
791
+ // so marking an attribute is enough to keep a new command out of the raw log too.
792
+ this.rawSensitiveCommand =
793
+ RAW_SENSITIVE_COMMANDS.has(typeof data.command === 'string' ? data.command.toUpperCase() : '') || hasSensitiveAttribute(data.attributes);
794
+
621
795
  // Compile with asArray=true: splits output into parts for literal handling.
622
796
  // First part is the command text up to the first literal, remaining parts
623
797
  // are stored in this.commandParts and sent after server "+" continuations.
@@ -715,7 +889,7 @@ class ImapFlow extends EventEmitter {
715
889
  // trySend() settles dispatch failures itself, by rejecting the affected
716
890
  // command through requestTagMap; this catch exists only so a throw from the
717
891
  // dispatch machinery itself can never surface as a floating rejection.
718
- this.trySend().catch(err => this.log.warn({ err, cid: this.id }));
892
+ this.trySend().catch(err => logConnectionError(this, 'Failed to dispatch command', err));
719
893
  });
720
894
 
721
895
  // Prevent unhandled promise rejection if close() rejects this request
@@ -726,15 +900,14 @@ class ImapFlow extends EventEmitter {
726
900
  return promise;
727
901
  }
728
902
 
729
- // Resolves the handler for an untagged server response. IMAP untagged responses
730
- // come in two forms:
903
+ // Resolves an untagged server response to the keyword it is dispatched on. IMAP untagged
904
+ // responses come in two forms:
731
905
  // * CAPABILITY ... (keyword as command)
732
906
  // * 42 FETCH (...) (numeric prefix + keyword)
733
- // For numeric-prefixed responses, we extract the keyword (FETCH, EXISTS, EXPUNGE, etc.)
734
- // and look up the handler by that keyword instead.
735
- // Handler priority: command-specific handlers (registered per exec() call) take
736
- // precedence over global handlers (registered on the connection).
737
- getUntaggedHandler(command, attributes) {
907
+ // For numeric-prefixed responses the keyword sits in the first attribute, because `command`
908
+ // holds the sequence number. Also used for logging, so a failure reports FETCH rather than
909
+ // the message number that happened to precede it.
910
+ normalizeUntaggedCommand(command, attributes) {
738
911
  if (/^[0-9]+$/.test(command)) {
739
912
  let type = attributes && attributes.length && typeof attributes[0].value === 'string' ? attributes[0].value.toUpperCase() : false;
740
913
  if (type) {
@@ -742,7 +915,13 @@ class ImapFlow extends EventEmitter {
742
915
  }
743
916
  }
744
917
 
745
- command = command.toUpperCase().trim();
918
+ return command.toUpperCase().trim();
919
+ }
920
+
921
+ // Handler priority: command-specific handlers (registered per exec() call) take
922
+ // precedence over global handlers (registered on the connection).
923
+ getUntaggedHandler(command, attributes) {
924
+ command = this.normalizeUntaggedCommand(command, attributes);
746
925
  // Check command-specific handler first (registered in exec() options.untagged)
747
926
  if (this.currentRequest && this.currentRequest.options && this.currentRequest.options.untagged && this.currentRequest.options.untagged[command]) {
748
927
  return this.currentRequest.options.untagged[command];
@@ -920,7 +1099,7 @@ class ImapFlow extends EventEmitter {
920
1099
  err.parserError = parserError;
921
1100
  this.rejectCurrentRequest(err);
922
1101
 
923
- this.trySend().catch(sendErr => this.log.warn({ err: sendErr, cid: this.id }));
1102
+ this.trySend().catch(sendErr => logConnectionError(this, 'Failed to dispatch command', sendErr));
924
1103
  }
925
1104
 
926
1105
  /**
@@ -991,7 +1170,9 @@ class ImapFlow extends EventEmitter {
991
1170
  try {
992
1171
  await this.currentRequest.options.onPlusTag(parsed);
993
1172
  } catch (err) {
994
- this.log.warn({ err, cid: this.id });
1173
+ // The handler ran across an await and may have closed the connection, which
1174
+ // clears currentRequest, so the command name is read defensively
1175
+ this.log.warn({ msg: 'Failed to process continuation response', command: this.currentRequest?.command, err, cid: this.id });
995
1176
  }
996
1177
  return true;
997
1178
  }
@@ -1005,7 +1186,7 @@ class ImapFlow extends EventEmitter {
1005
1186
  this.write(content);
1006
1187
  this.log.debug({ src: 'c', msg: `(* ${content.length}B continuation *)`, cid: this.id });
1007
1188
  } catch (err) {
1008
- this.log.warn({ err, cid: this.id });
1189
+ logConnectionError(this, 'Failed to send literal continuation', err);
1009
1190
  }
1010
1191
  return true;
1011
1192
  }
@@ -1014,12 +1195,13 @@ class ImapFlow extends EventEmitter {
1014
1195
  // section[0] can be a parsed NIL (null), e.g. from a "[NIL]" response code - the
1015
1196
  // dereference must be guarded or one such line tears down the whole connection
1016
1197
  if (section && section.length && section[0] && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
1017
- let sectionHandler = this.getSectionHandler(section[0].value.toUpperCase().trim());
1198
+ let sectionKey = section[0].value.toUpperCase().trim();
1199
+ let sectionHandler = this.getSectionHandler(sectionKey);
1018
1200
  if (sectionHandler) {
1019
1201
  try {
1020
1202
  await sectionHandler(section.slice(1));
1021
1203
  } catch (err) {
1022
- this.log.warn({ err, cid: this.id });
1204
+ this.log.warn({ msg: 'Failed to process response section', section: sectionKey, err, cid: this.id });
1023
1205
  }
1024
1206
  }
1025
1207
  }
@@ -1030,7 +1212,14 @@ class ImapFlow extends EventEmitter {
1030
1212
  try {
1031
1213
  await untaggedHandler(parsed);
1032
1214
  } catch (err) {
1033
- this.log.warn({ err, cid: this.id });
1215
+ // Normalized only here: this runs for every untagged response, including
1216
+ // every FETCH, and the keyword is needed only to describe a failure
1217
+ this.log.warn({
1218
+ msg: 'Failed to process untagged response',
1219
+ command: this.normalizeUntaggedCommand(parsed.command, parsed.attributes),
1220
+ err,
1221
+ cid: this.id
1222
+ });
1034
1223
  return true;
1035
1224
  }
1036
1225
  }
@@ -1141,6 +1330,9 @@ class ImapFlow extends EventEmitter {
1141
1330
 
1142
1331
  if (err.responseStatus === 'NO' && txt.includes('Some of the requested messages no longer exist')) {
1143
1332
  // Treat as successful response
1333
+ // Kept at warn: the caller is handed fewer messages than it asked for and
1334
+ // is told nothing else about it, so this entry is the only record that
1335
+ // the response was truncated.
1144
1336
  this.log.warn({ msg: 'Partial FETCH response', cid: this.id, err });
1145
1337
  await new Promise(resolve => request.resolve({ response: parsed, next: resolve }));
1146
1338
  break;
@@ -1252,10 +1444,22 @@ class ImapFlow extends EventEmitter {
1252
1444
  /**
1253
1445
  * Socket timeout event handler.
1254
1446
  *
1255
- * When a socket timeout occurs during IDLE, the handler attempts to recover the connection
1256
- * by sending a NOOP command and then returning to IDLE state.
1447
+ * A quiet socket is only a dead connection when something was supposed to be talking. An
1448
+ * idling session, a download whose consumer stopped draining, and a held mailbox lock
1449
+ * whose owner is busy between commands are all expected to go quiet, so the handler keeps
1450
+ * such a connection alive with a NOOP instead of tearing it down. An in-flight command is
1451
+ * the opposite: its reply is overdue, a recovery NOOP would only queue up behind it and
1452
+ * never reach the wire, so the timeout is reported as an error. The IDLE command itself is
1453
+ * the one exception - it stays in flight for as long as idling lasts, and run() breaks it
1454
+ * through preCheck() before the NOOP is dispatched.
1257
1455
  *
1258
- * @fires ImapFlow#error Emits error event unless the current command is IDLE
1456
+ * IDLE is not restarted here: run() re-arms auto-IDLE once the NOOP settles, and
1457
+ * autoidle() knows whether the connection is actually free for IDLE - an open download or
1458
+ * a held lock keeps just the keepalive, and with disableAutoIdle nothing restarts at all.
1459
+ * If the server is dead the NOOP never settles, and the next timeout fires with the NOOP
1460
+ * as the stuck in-flight command, which lands in the error branch below.
1461
+ *
1462
+ * @fires ImapFlow#error Emits error event if the connection cannot be recovered
1259
1463
  */
1260
1464
  this._socketTimeout =
1261
1465
  this._socketTimeout ||
@@ -1263,26 +1467,21 @@ class ImapFlow extends EventEmitter {
1263
1467
  const err = new Error('Socket timeout');
1264
1468
  err.code = 'ETIMEOUT';
1265
1469
 
1266
- if (this.idling) {
1470
+ const quietExpected = this.idling || this._openDownloads || this.currentLock;
1471
+ const commandStuck = this.currentRequest && !(this.idling && this.currentRequest.command === 'IDLE');
1472
+
1473
+ if (quietExpected && !commandStuck) {
1267
1474
  if (!this.usable || !this.socket || this.socket.destroyed) {
1268
1475
  this.emitError(err);
1269
1476
  return;
1270
1477
  }
1271
- // Attempt to recover IDLE connections. During true IDLE the NOOP cannot
1272
- // reach the server until IDLE has been terminated: run() awaits preCheck(),
1273
- // which sends DONE and only resolves once the server has completed the IDLE
1274
- // command. Fallback polling has no such handshake - preCheck() there just
1275
- // cancels the polling session.
1276
- this.run('NOOP')
1277
- .then(() => this.idle())
1278
- .catch(err => {
1279
- this.log.warn({ msg: 'IDLE recovery failed after timeout', err, cid: this.id });
1280
- if (!this.isClosed) {
1281
- this.close();
1282
- }
1283
- });
1478
+ this.run('NOOP').catch(err => {
1479
+ this.log.warn({ msg: 'Connection recovery failed after timeout', err, cid: this.id });
1480
+ if (!this.isClosed) {
1481
+ this.close();
1482
+ }
1483
+ });
1284
1484
  } else {
1285
- // Close immediately for non-IDLE operations
1286
1485
  this.log.debug({ msg: 'Socket timeout', cid: this.id });
1287
1486
  this.emitError(err);
1288
1487
  }
@@ -1445,7 +1644,7 @@ class ImapFlow extends EventEmitter {
1445
1644
  }
1446
1645
  this.writeSocket.end();
1447
1646
  } catch (err) {
1448
- this.log.error({ err, info: 'Failed to destroy PassThrough socket', cid: this.id });
1647
+ this.log.error({ err, msg: 'Failed to destroy PassThrough socket', cid: this.id });
1449
1648
  throw err;
1450
1649
  }
1451
1650
  };
@@ -1951,6 +2150,22 @@ class ImapFlow extends EventEmitter {
1951
2150
  });
1952
2151
  }
1953
2152
 
2153
+ // Reports one expunged message, either through the caller's expungeHandler or as an
2154
+ // 'expunge' event. Shared by the EXPUNGE and VANISHED paths so the two cannot drift.
2155
+ async notifyExpunge(payload) {
2156
+ if (typeof this.options.expungeHandler !== 'function') {
2157
+ this.emit('expunge', payload);
2158
+ return;
2159
+ }
2160
+
2161
+ try {
2162
+ await this.options.expungeHandler(payload);
2163
+ } catch (err) {
2164
+ // The throw comes from the caller's own handler, not from this library
2165
+ this.log.error({ msg: 'Failed to notify expunge event', payload, err, cid: this.id });
2166
+ }
2167
+ }
2168
+
1954
2169
  async untaggedExpunge(untagged) {
1955
2170
  if (!this.mailbox) {
1956
2171
  // mailbox closed, ignore
@@ -1971,15 +2186,7 @@ class ImapFlow extends EventEmitter {
1971
2186
  vanished: false
1972
2187
  };
1973
2188
 
1974
- if (typeof this.options.expungeHandler === 'function') {
1975
- try {
1976
- await this.options.expungeHandler(payload);
1977
- } catch (err) {
1978
- this.log.error({ msg: 'Failed to notify expunge event', payload, error: err, cid: this.id });
1979
- }
1980
- } else {
1981
- this.emit('expunge', payload);
1982
- }
2189
+ await this.notifyExpunge(payload);
1983
2190
  }
1984
2191
  }
1985
2192
 
@@ -2018,15 +2225,7 @@ class ImapFlow extends EventEmitter {
2018
2225
  earlier: tags.includes('EARLIER')
2019
2226
  };
2020
2227
 
2021
- if (typeof this.options.expungeHandler === 'function') {
2022
- try {
2023
- await this.options.expungeHandler(payload);
2024
- } catch (err) {
2025
- this.log.error({ msg: 'Failed to notify expunge event', payload, error: err, cid: this.id });
2026
- }
2027
- } else {
2028
- this.emit('expunge', payload);
2029
- }
2228
+ await this.notifyExpunge(payload);
2030
2229
  }
2031
2230
  }
2032
2231
 
@@ -2120,6 +2319,16 @@ class ImapFlow extends EventEmitter {
2120
2319
  return range;
2121
2320
  }
2122
2321
 
2322
+ // The single definition of "the connection is not free". A held or queued mailbox lock, a
2323
+ // command in flight or queued, and an open download stream all mean a caller is
2324
+ // mid-sequence: starting IDLE there injects an IDLE/DONE round trip - or, with
2325
+ // `missingIdleCommand` set to SELECT or STATUS, a mailbox poll - between two of that
2326
+ // caller's own commands. Every one of those states ends by calling autoidle() again, so
2327
+ // declining while busy postpones IDLE, it never cancels it.
2328
+ connectionBusy() {
2329
+ return !!(this.currentLock || this.locks.length || this.currentRequest || this.requestQueue.length || this._openDownloads);
2330
+ }
2331
+
2123
2332
  // Timer process-liveness policy: connection establishment and greeting deadlines keep the
2124
2333
  // process alive, because a caller is waiting on connect() to settle. Background timers
2125
2334
  // (auto-IDLE, IDLE restart, fallback polling, throttle back-off, the held-lock diagnostic) are
@@ -2130,9 +2339,22 @@ class ImapFlow extends EventEmitter {
2130
2339
  if (this.options.disableAutoIdle || this.state !== this.states.SELECTED) {
2131
2340
  return;
2132
2341
  }
2342
+
2343
+ if (this.connectionBusy()) {
2344
+ return;
2345
+ }
2346
+
2133
2347
  this.idleStartTimer = setTimeout(() => {
2134
- this.idle().catch(err => this.log.warn({ err, cid: this.id }));
2135
- }, 15 * 1000);
2348
+ // Re-checked at fire time: paths that take ownership of the connection clear this
2349
+ // timer, but the guard must not depend on every one of them doing so - a single
2350
+ // missed clearTimeout would inject IDLE between a caller's own commands. Declining
2351
+ // postpones rather than cancels: whatever made the connection busy calls autoidle()
2352
+ // again when it finishes.
2353
+ if (this.state !== this.states.SELECTED || this.connectionBusy()) {
2354
+ return;
2355
+ }
2356
+ this.idle().catch(err => logConnectionError(this, 'Auto-IDLE failed', err));
2357
+ }, this.autoIdleDelay);
2136
2358
  unrefTimer(this.idleStartTimer);
2137
2359
  }
2138
2360
 
@@ -2192,16 +2414,21 @@ class ImapFlow extends EventEmitter {
2192
2414
  throw new Error('Failed to setup proxy connection');
2193
2415
  }
2194
2416
  } catch (err) {
2417
+ // Logged here rather than relying on proxy-connection.js, which only reports
2418
+ // failures from inside the two connect helpers. An unsupported scheme, a proxy URL
2419
+ // that will not parse and a deadline that expired before the connect started all
2420
+ // reject before any logging happens there, so this is the one place that sees
2421
+ // every way proxy setup can fail.
2422
+ this.log.error({ msg: 'Failed to setup proxy connection', err, cid: this.id });
2423
+
2195
2424
  if (err.code === 'CONNECT_TIMEOUT') {
2196
2425
  // The shared deadline expired during proxy setup. Report it as the documented
2197
2426
  // connection timeout rather than as a generic proxy failure.
2198
- this.log.error({ err, cid: this.id });
2199
2427
  throw err;
2200
2428
  }
2201
2429
  let error = new Error('Failed to setup proxy connection');
2202
2430
  error.code = err.code || 'ProxyError';
2203
2431
  error._err = err;
2204
- this.log.error({ error, cid: this.id });
2205
2432
  throw error;
2206
2433
  }
2207
2434
  }
@@ -2410,7 +2637,9 @@ class ImapFlow extends EventEmitter {
2410
2637
  }
2411
2638
 
2412
2639
  if (typeof this.preCheck === 'function') {
2413
- this.preCheck().catch(err => this.log.warn({ err, cid: this.id }));
2640
+ // Runs while the connection is being torn down, so the rejection this sees is
2641
+ // almost always the NoConnection close() is about to raise itself.
2642
+ this.preCheck().catch(err => logConnectionError(this, 'Failed to break IDLE while closing', err));
2414
2643
  }
2415
2644
 
2416
2645
  // Session-only public state must not survive the connection it describes: callers read
@@ -2503,7 +2732,7 @@ class ImapFlow extends EventEmitter {
2503
2732
  this._inflate.destroy();
2504
2733
  this._inflate = null;
2505
2734
  } catch (err) {
2506
- this.log.error({ err, info: 'Failed to destroy inflate stream', cid: this.id });
2735
+ this.log.error({ err, msg: 'Failed to destroy inflate stream', cid: this.id });
2507
2736
  }
2508
2737
  }
2509
2738
 
@@ -2513,7 +2742,7 @@ class ImapFlow extends EventEmitter {
2513
2742
  this._deflate.destroy();
2514
2743
  this._deflate = null;
2515
2744
  } catch (err) {
2516
- this.log.error({ err, info: 'Failed to destroy deflate stream', cid: this.id });
2745
+ this.log.error({ err, msg: 'Failed to destroy deflate stream', cid: this.id });
2517
2746
  }
2518
2747
  }
2519
2748
 
@@ -2531,7 +2760,7 @@ class ImapFlow extends EventEmitter {
2531
2760
  this.streamer.destroy();
2532
2761
  }
2533
2762
  } catch (err) {
2534
- this.log.error({ err, info: 'Failed to cleanup streamer', cid: this.id });
2763
+ this.log.error({ err, msg: 'Failed to cleanup streamer', cid: this.id });
2535
2764
  }
2536
2765
  }
2537
2766
 
@@ -2584,7 +2813,7 @@ class ImapFlow extends EventEmitter {
2584
2813
  this._socketEnd = null;
2585
2814
  this._socketTimeout = null;
2586
2815
 
2587
- this.log.trace({
2816
+ this.log.debug({
2588
2817
  msg: 'Connection closed',
2589
2818
  cid: this.id,
2590
2819
  ...(this._unknownTagCount ? { unknownTagCount: this._unknownTagCount } : {})
@@ -2600,7 +2829,7 @@ class ImapFlow extends EventEmitter {
2600
2829
  this.emit('close');
2601
2830
  } catch (ex) {
2602
2831
  // close failed
2603
- this.log.error(ex);
2832
+ this.log.error({ err: ex, cid: this.id });
2604
2833
  }
2605
2834
  }
2606
2835
 
@@ -3963,6 +4192,23 @@ class ImapFlow extends EventEmitter {
3963
4192
  }
3964
4193
  };
3965
4194
 
4195
+ // A download is a sequence of chunk FETCHes with a backpressure wait in between. Those
4196
+ // gaps look exactly like an inactive connection, so without this auto-IDLE would start
4197
+ // between chunks and the next chunk would have to break it again - two extra round
4198
+ // trips per chunk, for as long as the consumer is slow. Counted before control returns
4199
+ // to the event loop: the head chunk's own FETCH already armed the auto-IDLE timer, and
4200
+ // with a very short autoIdleDelay that timer could otherwise fire before the deferred
4201
+ // chunk loop below has marked the download open.
4202
+ this._openDownloads++;
4203
+ let downloadDone = false;
4204
+ let finishDownload = () => {
4205
+ if (!downloadDone) {
4206
+ downloadDone = true;
4207
+ this._openDownloads--;
4208
+ this.autoidle();
4209
+ }
4210
+ };
4211
+
3966
4212
  // Kick off the download pipeline asynchronously. The first chunk was
3967
4213
  // already fetched above (to get metadata); write it to the decoder
3968
4214
  // stream and then fetch remaining chunks via fetchAllParts().
@@ -3987,6 +4233,7 @@ class ImapFlow extends EventEmitter {
3987
4233
  /* c8 ignore stop */
3988
4234
  })
3989
4235
  .finally(() => {
4236
+ finishDownload();
3990
4237
  if (!fetchAborted && stream && !stream.destroyed) {
3991
4238
  stream.end();
3992
4239
  }
@@ -3999,6 +4246,7 @@ class ImapFlow extends EventEmitter {
3999
4246
  writeResult = writeChunk(chunk);
4000
4247
  } catch (err) {
4001
4248
  stream.emit('error', err);
4249
+ finishDownload();
4002
4250
  /* c8 ignore next 3 */ // emitting the error above triggers cleanup (fetchAborted=true), so this end() guard is already false here
4003
4251
  if (!fetchAborted && stream && !stream.destroyed) {
4004
4252
  stream.end();
@@ -4006,12 +4254,14 @@ class ImapFlow extends EventEmitter {
4006
4254
  return;
4007
4255
  }
4008
4256
 
4009
- /* c8 ignore next 7 */ // `stream` is piped to the limiter before this runs, so the head write drains synchronously and always returns true (verified for chunkSize up to 8MB); the drain-wait branch is unreachable
4257
+ /* c8 ignore next 9 */ // `stream` is piped to the limiter before this runs, so the head write drains synchronously and always returns true (verified for chunkSize up to 8MB); the drain-wait branch is unreachable
4010
4258
  if (!writeResult) {
4011
4259
  // Initial chunk filled the buffer, wait for drain
4012
4260
  stream.once('drain', () => {
4013
4261
  if (!fetchAborted) {
4014
4262
  runFetchAllParts();
4263
+ } else {
4264
+ finishDownload();
4015
4265
  }
4016
4266
  });
4017
4267
  } else {
@@ -4182,18 +4432,22 @@ class ImapFlow extends EventEmitter {
4182
4432
 
4183
4433
  clearTimeout(this.idleStartTimer);
4184
4434
 
4185
- if (typeof this.preCheck === 'function') {
4186
- await this.preCheck();
4187
- }
4188
-
4189
- let result = await this.runInternal(command, ...args);
4435
+ try {
4436
+ // The preCheck (breaking an active IDLE) sits inside the try on purpose: the
4437
+ // clearTimeout above is unconditional, so every exit - a failed command or a
4438
+ // preCheck that rejects - must still reach the finally, or auto-IDLE would stay
4439
+ // disarmed on an otherwise healthy connection until some later command succeeded.
4440
+ if (typeof this.preCheck === 'function') {
4441
+ await this.preCheck();
4442
+ }
4190
4443
 
4191
- if (command !== 'IDLE') {
4192
- // do not autostart IDLE, if IDLE itself was stopped
4193
- this.autoidle();
4444
+ return await this.runInternal(command, ...args);
4445
+ } finally {
4446
+ if (command !== 'IDLE') {
4447
+ // do not autostart IDLE, if IDLE itself was stopped
4448
+ this.autoidle();
4449
+ }
4194
4450
  }
4195
-
4196
- return result;
4197
4451
  }
4198
4452
 
4199
4453
  /**
@@ -4314,6 +4568,10 @@ class ImapFlow extends EventEmitter {
4314
4568
  idling: this.idling
4315
4569
  });
4316
4570
  this.currentLock = false;
4571
+ // autoidle() will not arm while a lock is held, so the release is what
4572
+ // restarts it. It re-checks the queue itself, so a lock waiting behind
4573
+ // this one still keeps IDLE off.
4574
+ this.autoidle();
4317
4575
  // Use setImmediate to avoid stack overflow
4318
4576
  setImmediate(() => {
4319
4577
  this.processLocks().catch(err => this.log.error({ err, cid: this.id }));
@@ -4335,6 +4593,18 @@ class ImapFlow extends EventEmitter {
4335
4593
  continue; // Process next lock in queue
4336
4594
  }
4337
4595
 
4596
+ // Both grant paths finish the same way. autoidle() is re-checked because a stale
4597
+ // auto-IDLE timer may still be armed at this point: on the SELECT path run()
4598
+ // re-arms auto-IDLE when the SELECT settles - a moment before currentLock is set -
4599
+ // and the fast path can inherit a timer from an earlier command. Either way the
4600
+ // timer must not fire inside the lock.
4601
+ const grantLock = () => {
4602
+ this.currentLock = lock;
4603
+ armHeldTimer();
4604
+ this.autoidle();
4605
+ resolve({ path, release });
4606
+ };
4607
+
4338
4608
  if (this.mailbox && this.mailbox.path === path && !!this.mailbox.readOnly === !!options.readOnly) {
4339
4609
  // Fast path: mailbox is already selected with the right access mode
4340
4610
  this.log.trace({
@@ -4344,9 +4614,7 @@ class ImapFlow extends EventEmitter {
4344
4614
  idling: this.idling,
4345
4615
  ...(options.description && { description: options.description })
4346
4616
  });
4347
- this.currentLock = lock;
4348
- armHeldTimer();
4349
- resolve({ path, release });
4617
+ grantLock();
4350
4618
  break; // Stop processing; next lock waits for release()
4351
4619
  }
4352
4620
 
@@ -4360,9 +4628,7 @@ class ImapFlow extends EventEmitter {
4360
4628
  idling: this.idling,
4361
4629
  ...(options.description && { description: options.description })
4362
4630
  });
4363
- this.currentLock = lock;
4364
- armHeldTimer();
4365
- resolve({ path, release });
4631
+ grantLock();
4366
4632
  break; // Wait for this lock to be released
4367
4633
  } catch (err) {
4368
4634
  if (err.responseStatus === 'NO') {
@@ -4497,7 +4763,19 @@ class ImapFlow extends EventEmitter {
4497
4763
  // we are checking to make sure the level is supported.
4498
4764
  // if it isn't supported but the level is error or fatal, log to console anyway.
4499
4765
  if (level === 'fatal' || level === 'error') {
4500
- console.log(JSON.stringify(...args));
4766
+ let entry = args[0];
4767
+ try {
4768
+ if (entry && typeof entry === 'object' && entry.err) {
4769
+ entry = Object.assign({}, entry, { err: flattenLoggedError(entry.err) });
4770
+ }
4771
+ console.error(JSON.stringify(entry));
4772
+ } catch {
4773
+ // Serializing failed (a circular structure, a BigInt, a throwing
4774
+ // getter). This fallback exists so an error is never lost, so hand
4775
+ // the entry to console.error itself - it inspects rather than
4776
+ // serializes, and handles all three - instead of dropping it.
4777
+ console.error(entry);
4778
+ }
4501
4779
  }
4502
4780
  } else {
4503
4781
  mainLogger[level](...args);
@@ -4505,18 +4783,21 @@ class ImapFlow extends EventEmitter {
4505
4783
  }
4506
4784
 
4507
4785
  if (this.emitLogs && args && args[0] && typeof args[0] === 'object') {
4508
- let logEntry = Object.assign({ level, t: Date.now(), cid: this.id, lo: ++this.lo }, args[0]);
4509
- if (logEntry.err && typeof logEntry.err === 'object') {
4510
- let err = logEntry.err;
4511
- logEntry.err = {
4512
- stack: err.stack
4513
- };
4514
- // enumerable error fields
4515
- Object.keys(err).forEach(key => {
4516
- logEntry.err[key] = err[key];
4517
- });
4786
+ // Guarded for the same reason as the console fallback above: a log call must
4787
+ // never throw. Most of these run inside catch blocks in the protocol
4788
+ // machinery, where a throw would escape the handler that was recovering from
4789
+ // something else and strand the connection. A throwing property getter on the
4790
+ // logged error and a throwing 'log' listener both end up here.
4791
+ try {
4792
+ let logEntry = Object.assign({ level, t: Date.now(), cid: this.id, lo: ++this.lo }, args[0]);
4793
+ if (logEntry.err) {
4794
+ logEntry.err = flattenLoggedError(logEntry.err);
4795
+ }
4796
+ this.emit('log', logEntry);
4797
+ } catch {
4798
+ // Nothing to do with it: reporting the failure would re-enter this
4799
+ // same path
4518
4800
  }
4519
- this.emit('log', logEntry);
4520
4801
  }
4521
4802
  };
4522
4803
  }