imapflow 1.6.6 → 1.7.0

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.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "1.6.6"
2
+ ".": "1.7.0"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.7.0](https://github.com/postalsys/imapflow/compare/v1.6.6...v1.7.0) (2026-08-11)
4
+
5
+
6
+ ### Features
7
+
8
+ * **idle:** make the auto-IDLE delay configurable ([311fe0c](https://github.com/postalsys/imapflow/commit/311fe0ccb0d75d4ddbe797bf5bc755df5bf0485f))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **idle:** align the socket watchdog with the auto-IDLE busy guard ([d5e7191](https://github.com/postalsys/imapflow/commit/d5e71915ac7a1829b7e1340da6195a5e3e9984b2))
14
+ * **idle:** validate autoIdleDelay and keep auto-IDLE off a busy connection ([aeafdf6](https://github.com/postalsys/imapflow/commit/aeafdf62fab3b29cd488d9b8cbdffc8896107d6e))
15
+
3
16
  ## [1.6.6](https://github.com/postalsys/imapflow/compare/v1.6.5...v1.6.6) (2026-08-07)
4
17
 
5
18
 
@@ -257,14 +257,16 @@ async function runPollingFallback(connection, maxIdleTime) {
257
257
 
258
258
  pollOnce(connection, session)
259
259
  .then(() => {
260
+ // Stamped only after a poll actually completed: a failed poll must not
261
+ // satisfy the resumed schedule below, or the next session would defer
262
+ // its first poll a full interval past an attempt that checked nothing.
263
+ connection._lastPollAt = Date.now();
260
264
  // Cancellation is re-checked here: the session may have been broken while
261
265
  // this poll was in flight, and an orphaned poller must not schedule again.
262
266
  if (session.cancelled) {
263
267
  return;
264
268
  }
265
- session.timer = setTimeout(runPoll, interval);
266
- // Background polling must not keep the process alive
267
- unrefTimer(session.timer);
269
+ scheduleNextPoll(interval);
268
270
  })
269
271
  .catch(err => {
270
272
  connection.log.warn({ err, cid: connection.id });
@@ -272,9 +274,28 @@ async function runPollingFallback(connection, maxIdleTime) {
272
274
  });
273
275
  };
274
276
 
277
+ function scheduleNextPoll(delay) {
278
+ session.timer = setTimeout(runPoll, delay);
279
+ // Background polling must not keep the process alive
280
+ unrefTimer(session.timer);
281
+ }
282
+
275
283
  connection.log.debug({ src: 'c', msg: `initiated NOOP loop`, cid: connection.id });
276
- // Keep the immediate first poll
277
- runPoll();
284
+
285
+ // Every auto-IDLE restart begins a fresh polling session, so an unconditional first
286
+ // poll would tie the poll rate to how often the caller runs commands rather than to
287
+ // `interval`: with a short autoIdleDelay, a command every few seconds turns into a
288
+ // poll every few seconds. The last poll timestamp lives on the connection, so a new
289
+ // session resumes the previous one's schedule instead of restarting it.
290
+ // Clamped at zero because a backward wall-clock step (NTP, VM resume) leaves the
291
+ // stamp in the future; however large the jump, the next poll must never be more
292
+ // than one full interval away.
293
+ let sinceLastPoll = Math.max(0, Date.now() - (connection._lastPollAt || 0));
294
+ if (sinceLastPoll >= interval) {
295
+ runPoll();
296
+ } else {
297
+ scheduleNextPoll(interval - sinceLastPoll);
298
+ }
278
299
  });
279
300
  } finally {
280
301
  session.cancelled = true;
@@ -30,6 +30,17 @@ export interface ImapFlowOptions {
30
30
  clientInfo?: IdInfoObject;
31
31
  /** If true, then do not start IDLE when connection is established */
32
32
  disableAutoIdle?: boolean;
33
+ /**
34
+ * How long (in ms) the connection has to be inactive before IDLE is started automatically.
35
+ * Keep it above the pause your own code usually leaves between two commands, otherwise every
36
+ * command is followed by an IDLE that the next command has to break, costing two extra
37
+ * round-trips per command. To turn auto-IDLE off use `disableAutoIdle` rather than a very
38
+ * large delay: the value is capped below `socketTimeout`, because auto-IDLE has to start
39
+ * before the inactivity watchdog fires. On servers without IDLE support this controls when
40
+ * the polling fallback starts, not how often it polls - the poll interval is `maxIdleTime`,
41
+ * capped at 2 minutes. Default: 15000 ms.
42
+ */
43
+ autoIdleDelay?: number;
33
44
  /** Additional TLS options (see Node.js TLS documentation) */
34
45
  tls?: ConnectionOptions;
35
46
  /** Custom logger instance. Set to false to disable logging */
package/lib/imap-flow.js CHANGED
@@ -64,6 +64,19 @@ const MAX_THROTTLE_DELAY = 5 * 60 * 1000;
64
64
  // ImapFlow constructor option `maxLockHoldTime`. Set to 0 or false to disable.
65
65
  const HELD_LOCK_WARN_MS = 30 * 60 * 1000;
66
66
 
67
+ // How long the connection has to stay inactive before auto-IDLE starts. Long enough that a caller
68
+ // running a sequence of commands is not interrupted by an IDLE it immediately has to break.
69
+ // Configurable via the ImapFlow constructor option `autoIdleDelay`.
70
+ const AUTO_IDLE_DELAY = 15 * 1000;
71
+
72
+ // Headroom kept between the auto-IDLE delay and the socket inactivity watchdog, so IDLE reaches
73
+ // the wire before the watchdog can fire. See normalizeAutoIdleDelay().
74
+ const AUTO_IDLE_SOCKET_MARGIN = 1000;
75
+
76
+ // The largest delay setTimeout can honor (2^31 - 1 ms). Anything above fires after 1 ms instead,
77
+ // so the auto-IDLE delay cap has to stay inside this range even when socketTimeout is not.
78
+ const MAX_TIMER_DELAY = 2 ** 31 - 1;
79
+
67
80
  const states = {
68
81
  NOT_AUTHENTICATED: 0x01,
69
82
  AUTHENTICATED: 0x02,
@@ -71,6 +84,49 @@ const states = {
71
84
  LOGOUT: 0x04
72
85
  };
73
86
 
87
+ /**
88
+ * Normalizes the configured auto-IDLE delay into a value `setTimeout` can honor. Anything Node
89
+ * would silently turn into a 1ms timer - NaN, a negative number, a value above the 32-bit range -
90
+ * falls back to the default instead, because a 1ms delay means an IDLE/DONE round trip around
91
+ * every single command. The delay is also capped below `socketTimeout`, see AUTO_IDLE_SOCKET_MARGIN.
92
+ *
93
+ * @param {*} value - The configured `autoIdleDelay` option.
94
+ * @param {Number} socketTimeout - The normalized socket inactivity timeout.
95
+ * @param {Object} log - Logger, used to report a value that could not be used as given.
96
+ * @param {String} cid - Connection id for the log entry.
97
+ * @returns {Number} Delay in milliseconds.
98
+ */
99
+ const normalizeAutoIdleDelay = (value, socketTimeout, log, cid) => {
100
+ const maxDelay = Math.max(0, Math.min(socketTimeout, MAX_TIMER_DELAY) - AUTO_IDLE_SOCKET_MARGIN);
101
+ const configured = value !== undefined && value !== null;
102
+
103
+ // Numeric strings are accepted, because configuration usually arrives from an environment
104
+ // variable or a JSON file. Booleans and blank strings are not: Number() would read them as 0,
105
+ // i.e. "IDLE around every command", the opposite of the "off" they suggest.
106
+ let delay = typeof value === 'number' || (typeof value === 'string' && value.trim()) ? Number(value) : NaN;
107
+ let reason = null;
108
+
109
+ if (!Number.isFinite(delay) || delay < 0) {
110
+ reason = 'not a non-negative finite number';
111
+ delay = AUTO_IDLE_DELAY;
112
+ }
113
+
114
+ if (delay > maxDelay) {
115
+ // An invalid value keeps its own reason: the cap then applies to the fallback default,
116
+ // not to anything the caller asked for.
117
+ reason = reason || `above socketTimeout (${socketTimeout} ms)`;
118
+ delay = maxDelay;
119
+ }
120
+
121
+ // Only an explicitly configured value is worth warning about. Capping the default because the
122
+ // caller picked a short socketTimeout is expected behavior, not a misconfiguration.
123
+ if (configured && reason) {
124
+ log.warn({ msg: 'Adjusted unusable autoIdleDelay option', requested: value, autoIdleDelay: delay, reason, cid });
125
+ }
126
+
127
+ return Math.floor(delay);
128
+ };
129
+
74
130
  /**
75
131
  * @typedef {Object} MailboxObject
76
132
  * @global
@@ -188,6 +244,15 @@ class ImapFlow extends EventEmitter {
188
244
  * @property {Boolean} [disableAutoIdle=false]
189
245
  * If `true`, do not start IDLE automatically. Useful when only specific operations are needed.
190
246
  *
247
+ * @property {Number} [autoIdleDelay=15000]
248
+ * How long (in milliseconds) the connection has to be inactive before IDLE is started automatically.
249
+ * Keep it above the pause your own code usually leaves between two commands, otherwise every command is
250
+ * followed by an IDLE that the next command has to break, costing two extra round-trips per command.
251
+ * To turn auto-IDLE off entirely use `disableAutoIdle` rather than a very large delay: the value is
252
+ * capped below `socketTimeout`, because auto-IDLE has to start before the inactivity watchdog fires.
253
+ * On servers without IDLE support this controls when the polling fallback starts, not how often it
254
+ * polls - the poll interval is `maxIdleTime`, capped at 2 minutes.
255
+ *
191
256
  * @property {Object} [tls]
192
257
  * Additional TLS options. For details, see [Node.js TLS connect](https://nodejs.org/api/tls.html#tls_tls_connect_options_callback).
193
258
  *
@@ -438,6 +503,14 @@ class ImapFlow extends EventEmitter {
438
503
  this.idRequested = false;
439
504
 
440
505
  this.maxIdleTime = this.options.maxIdleTime || false;
506
+ this.autoIdleDelay = normalizeAutoIdleDelay(this.options.autoIdleDelay, this.socketTimeout, this.log, this.id);
507
+
508
+ // Wall-clock time of the last fallback poll, owned by lib/commands/idle.js
509
+ this._lastPollAt = 0;
510
+
511
+ // Download streams still fetching chunks. Counted, not a flag, so overlapping downloads
512
+ // cannot clear each other's suppression of auto-IDLE.
513
+ this._openDownloads = 0;
441
514
  this.missingIdleCommand = (this.options.missingIdleCommand || '').toString().toUpperCase().trim() || 'NOOP';
442
515
 
443
516
  this.disableBinary = !!this.options.disableBinary;
@@ -1252,10 +1325,22 @@ class ImapFlow extends EventEmitter {
1252
1325
  /**
1253
1326
  * Socket timeout event handler.
1254
1327
  *
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.
1328
+ * A quiet socket is only a dead connection when something was supposed to be talking. An
1329
+ * idling session, a download whose consumer stopped draining, and a held mailbox lock
1330
+ * whose owner is busy between commands are all expected to go quiet, so the handler keeps
1331
+ * such a connection alive with a NOOP instead of tearing it down. An in-flight command is
1332
+ * the opposite: its reply is overdue, a recovery NOOP would only queue up behind it and
1333
+ * never reach the wire, so the timeout is reported as an error. The IDLE command itself is
1334
+ * the one exception - it stays in flight for as long as idling lasts, and run() breaks it
1335
+ * through preCheck() before the NOOP is dispatched.
1336
+ *
1337
+ * IDLE is not restarted here: run() re-arms auto-IDLE once the NOOP settles, and
1338
+ * autoidle() knows whether the connection is actually free for IDLE - an open download or
1339
+ * a held lock keeps just the keepalive, and with disableAutoIdle nothing restarts at all.
1340
+ * If the server is dead the NOOP never settles, and the next timeout fires with the NOOP
1341
+ * as the stuck in-flight command, which lands in the error branch below.
1257
1342
  *
1258
- * @fires ImapFlow#error Emits error event unless the current command is IDLE
1343
+ * @fires ImapFlow#error Emits error event if the connection cannot be recovered
1259
1344
  */
1260
1345
  this._socketTimeout =
1261
1346
  this._socketTimeout ||
@@ -1263,26 +1348,21 @@ class ImapFlow extends EventEmitter {
1263
1348
  const err = new Error('Socket timeout');
1264
1349
  err.code = 'ETIMEOUT';
1265
1350
 
1266
- if (this.idling) {
1351
+ const quietExpected = this.idling || this._openDownloads || this.currentLock;
1352
+ const commandStuck = this.currentRequest && !(this.idling && this.currentRequest.command === 'IDLE');
1353
+
1354
+ if (quietExpected && !commandStuck) {
1267
1355
  if (!this.usable || !this.socket || this.socket.destroyed) {
1268
1356
  this.emitError(err);
1269
1357
  return;
1270
1358
  }
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
- });
1359
+ this.run('NOOP').catch(err => {
1360
+ this.log.warn({ msg: 'Connection recovery failed after timeout', err, cid: this.id });
1361
+ if (!this.isClosed) {
1362
+ this.close();
1363
+ }
1364
+ });
1284
1365
  } else {
1285
- // Close immediately for non-IDLE operations
1286
1366
  this.log.debug({ msg: 'Socket timeout', cid: this.id });
1287
1367
  this.emitError(err);
1288
1368
  }
@@ -2120,6 +2200,16 @@ class ImapFlow extends EventEmitter {
2120
2200
  return range;
2121
2201
  }
2122
2202
 
2203
+ // The single definition of "the connection is not free". A held or queued mailbox lock, a
2204
+ // command in flight or queued, and an open download stream all mean a caller is
2205
+ // mid-sequence: starting IDLE there injects an IDLE/DONE round trip - or, with
2206
+ // `missingIdleCommand` set to SELECT or STATUS, a mailbox poll - between two of that
2207
+ // caller's own commands. Every one of those states ends by calling autoidle() again, so
2208
+ // declining while busy postpones IDLE, it never cancels it.
2209
+ connectionBusy() {
2210
+ return !!(this.currentLock || this.locks.length || this.currentRequest || this.requestQueue.length || this._openDownloads);
2211
+ }
2212
+
2123
2213
  // Timer process-liveness policy: connection establishment and greeting deadlines keep the
2124
2214
  // process alive, because a caller is waiting on connect() to settle. Background timers
2125
2215
  // (auto-IDLE, IDLE restart, fallback polling, throttle back-off, the held-lock diagnostic) are
@@ -2130,9 +2220,22 @@ class ImapFlow extends EventEmitter {
2130
2220
  if (this.options.disableAutoIdle || this.state !== this.states.SELECTED) {
2131
2221
  return;
2132
2222
  }
2223
+
2224
+ if (this.connectionBusy()) {
2225
+ return;
2226
+ }
2227
+
2133
2228
  this.idleStartTimer = setTimeout(() => {
2229
+ // Re-checked at fire time: paths that take ownership of the connection clear this
2230
+ // timer, but the guard must not depend on every one of them doing so - a single
2231
+ // missed clearTimeout would inject IDLE between a caller's own commands. Declining
2232
+ // postpones rather than cancels: whatever made the connection busy calls autoidle()
2233
+ // again when it finishes.
2234
+ if (this.state !== this.states.SELECTED || this.connectionBusy()) {
2235
+ return;
2236
+ }
2134
2237
  this.idle().catch(err => this.log.warn({ err, cid: this.id }));
2135
- }, 15 * 1000);
2238
+ }, this.autoIdleDelay);
2136
2239
  unrefTimer(this.idleStartTimer);
2137
2240
  }
2138
2241
 
@@ -3963,6 +4066,23 @@ class ImapFlow extends EventEmitter {
3963
4066
  }
3964
4067
  };
3965
4068
 
4069
+ // A download is a sequence of chunk FETCHes with a backpressure wait in between. Those
4070
+ // gaps look exactly like an inactive connection, so without this auto-IDLE would start
4071
+ // between chunks and the next chunk would have to break it again - two extra round
4072
+ // trips per chunk, for as long as the consumer is slow. Counted before control returns
4073
+ // to the event loop: the head chunk's own FETCH already armed the auto-IDLE timer, and
4074
+ // with a very short autoIdleDelay that timer could otherwise fire before the deferred
4075
+ // chunk loop below has marked the download open.
4076
+ this._openDownloads++;
4077
+ let downloadDone = false;
4078
+ let finishDownload = () => {
4079
+ if (!downloadDone) {
4080
+ downloadDone = true;
4081
+ this._openDownloads--;
4082
+ this.autoidle();
4083
+ }
4084
+ };
4085
+
3966
4086
  // Kick off the download pipeline asynchronously. The first chunk was
3967
4087
  // already fetched above (to get metadata); write it to the decoder
3968
4088
  // stream and then fetch remaining chunks via fetchAllParts().
@@ -3987,6 +4107,7 @@ class ImapFlow extends EventEmitter {
3987
4107
  /* c8 ignore stop */
3988
4108
  })
3989
4109
  .finally(() => {
4110
+ finishDownload();
3990
4111
  if (!fetchAborted && stream && !stream.destroyed) {
3991
4112
  stream.end();
3992
4113
  }
@@ -3999,6 +4120,7 @@ class ImapFlow extends EventEmitter {
3999
4120
  writeResult = writeChunk(chunk);
4000
4121
  } catch (err) {
4001
4122
  stream.emit('error', err);
4123
+ finishDownload();
4002
4124
  /* c8 ignore next 3 */ // emitting the error above triggers cleanup (fetchAborted=true), so this end() guard is already false here
4003
4125
  if (!fetchAborted && stream && !stream.destroyed) {
4004
4126
  stream.end();
@@ -4006,12 +4128,14 @@ class ImapFlow extends EventEmitter {
4006
4128
  return;
4007
4129
  }
4008
4130
 
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
4131
+ /* 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
4132
  if (!writeResult) {
4011
4133
  // Initial chunk filled the buffer, wait for drain
4012
4134
  stream.once('drain', () => {
4013
4135
  if (!fetchAborted) {
4014
4136
  runFetchAllParts();
4137
+ } else {
4138
+ finishDownload();
4015
4139
  }
4016
4140
  });
4017
4141
  } else {
@@ -4182,18 +4306,22 @@ class ImapFlow extends EventEmitter {
4182
4306
 
4183
4307
  clearTimeout(this.idleStartTimer);
4184
4308
 
4185
- if (typeof this.preCheck === 'function') {
4186
- await this.preCheck();
4187
- }
4188
-
4189
- let result = await this.runInternal(command, ...args);
4309
+ try {
4310
+ // The preCheck (breaking an active IDLE) sits inside the try on purpose: the
4311
+ // clearTimeout above is unconditional, so every exit - a failed command or a
4312
+ // preCheck that rejects - must still reach the finally, or auto-IDLE would stay
4313
+ // disarmed on an otherwise healthy connection until some later command succeeded.
4314
+ if (typeof this.preCheck === 'function') {
4315
+ await this.preCheck();
4316
+ }
4190
4317
 
4191
- if (command !== 'IDLE') {
4192
- // do not autostart IDLE, if IDLE itself was stopped
4193
- this.autoidle();
4318
+ return await this.runInternal(command, ...args);
4319
+ } finally {
4320
+ if (command !== 'IDLE') {
4321
+ // do not autostart IDLE, if IDLE itself was stopped
4322
+ this.autoidle();
4323
+ }
4194
4324
  }
4195
-
4196
- return result;
4197
4325
  }
4198
4326
 
4199
4327
  /**
@@ -4314,6 +4442,10 @@ class ImapFlow extends EventEmitter {
4314
4442
  idling: this.idling
4315
4443
  });
4316
4444
  this.currentLock = false;
4445
+ // autoidle() will not arm while a lock is held, so the release is what
4446
+ // restarts it. It re-checks the queue itself, so a lock waiting behind
4447
+ // this one still keeps IDLE off.
4448
+ this.autoidle();
4317
4449
  // Use setImmediate to avoid stack overflow
4318
4450
  setImmediate(() => {
4319
4451
  this.processLocks().catch(err => this.log.error({ err, cid: this.id }));
@@ -4335,6 +4467,18 @@ class ImapFlow extends EventEmitter {
4335
4467
  continue; // Process next lock in queue
4336
4468
  }
4337
4469
 
4470
+ // Both grant paths finish the same way. autoidle() is re-checked because a stale
4471
+ // auto-IDLE timer may still be armed at this point: on the SELECT path run()
4472
+ // re-arms auto-IDLE when the SELECT settles - a moment before currentLock is set -
4473
+ // and the fast path can inherit a timer from an earlier command. Either way the
4474
+ // timer must not fire inside the lock.
4475
+ const grantLock = () => {
4476
+ this.currentLock = lock;
4477
+ armHeldTimer();
4478
+ this.autoidle();
4479
+ resolve({ path, release });
4480
+ };
4481
+
4338
4482
  if (this.mailbox && this.mailbox.path === path && !!this.mailbox.readOnly === !!options.readOnly) {
4339
4483
  // Fast path: mailbox is already selected with the right access mode
4340
4484
  this.log.trace({
@@ -4344,9 +4488,7 @@ class ImapFlow extends EventEmitter {
4344
4488
  idling: this.idling,
4345
4489
  ...(options.description && { description: options.description })
4346
4490
  });
4347
- this.currentLock = lock;
4348
- armHeldTimer();
4349
- resolve({ path, release });
4491
+ grantLock();
4350
4492
  break; // Stop processing; next lock waits for release()
4351
4493
  }
4352
4494
 
@@ -4360,9 +4502,7 @@ class ImapFlow extends EventEmitter {
4360
4502
  idling: this.idling,
4361
4503
  ...(options.description && { description: options.description })
4362
4504
  });
4363
- this.currentLock = lock;
4364
- armHeldTimer();
4365
- resolve({ path, release });
4505
+ grantLock();
4366
4506
  break; // Wait for this lock to be released
4367
4507
  } catch (err) {
4368
4508
  if (err.responseStatus === 'NO') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "1.6.6",
3
+ "version": "1.7.0",
4
4
  "description": "IMAP Client for Node",
5
5
  "main": "lib/imap-flow.js",
6
6
  "types": "lib/imap-flow.d.ts",
@@ -0,0 +1,470 @@
1
+ 'use strict';
2
+
3
+ // Auto-IDLE policy: how the `autoIdleDelay` option is normalized, when the timer is allowed to
4
+ // arm at all, and how the socket inactivity watchdog treats connections that are supposed to be
5
+ // quiet.
6
+ //
7
+ // All of it matters more than the option looks. The configured value reaches setTimeout directly,
8
+ // where NaN, a negative number and anything above the 32-bit range all become a 1ms timer, i.e.
9
+ // an IDLE/DONE round trip around every single command. Arming while a caller still owns the
10
+ // connection injects that round trip - or, with `missingIdleCommand` set to SELECT or STATUS, a
11
+ // mailbox poll - between two of that caller's own commands. And the watchdog has to tell a
12
+ // connection that is legitimately quiet (idling, a stalled download, a held lock) from one whose
13
+ // command reply is overdue.
14
+
15
+ const { makeClient, makeIdleReadyClient, chunkedFetchOne } = require('./fixtures/test-client');
16
+ const { withFakeTimers } = require('./fixtures/fake-timers');
17
+
18
+ const DEFAULT_DELAY = 15 * 1000;
19
+ const DEFAULT_SOCKET_TIMEOUT = 5 * 60 * 1000;
20
+ const SOCKET_MARGIN = 1000;
21
+ const TIMEOUT_MAX = 2 ** 31 - 1;
22
+
23
+ // Captures warn entries, so the "silently ignored configuration" cases can be asserted as
24
+ // reported rather than guessed at.
25
+ const makeLoggingClient = (overrides = {}) => {
26
+ let warnings = [];
27
+ let client = makeClient({
28
+ ...overrides,
29
+ logger: { trace() {}, debug() {}, info() {}, warn: entry => warnings.push(entry), error() {}, fatal() {} }
30
+ });
31
+ return { client, warnings };
32
+ };
33
+
34
+ // ============================================================================
35
+ // option normalization
36
+ // ============================================================================
37
+
38
+ module.exports['Auto-IDLE: an unset delay uses the default'] = test => {
39
+ let { client, warnings } = makeLoggingClient();
40
+ test.equal(client.autoIdleDelay, DEFAULT_DELAY);
41
+ test.equal(warnings.length, 0, 'the default is not a misconfiguration');
42
+ test.done();
43
+ };
44
+
45
+ module.exports['Auto-IDLE: a configured delay is honored and reaches setTimeout'] = async test => {
46
+ await withFakeTimers(async timers => {
47
+ let client = makeIdleReadyClient({ autoIdleDelay: 1234 });
48
+ test.equal(client.autoIdleDelay, 1234);
49
+
50
+ client.autoidle();
51
+
52
+ let armed = timers.pending();
53
+ test.equal(armed.length, 1, 'exactly one auto-IDLE timer is armed');
54
+ test.equal(armed[0].delay, 1234, 'the configured delay is what the timer uses');
55
+
56
+ client.close();
57
+ });
58
+ test.done();
59
+ };
60
+
61
+ module.exports['Auto-IDLE: numeric strings are accepted'] = test => {
62
+ // The normal shape of a value coming from an environment variable or a JSON/YAML config file
63
+ test.equal(makeClient({ autoIdleDelay: '2000' }).autoIdleDelay, 2000);
64
+ test.equal(makeClient({ autoIdleDelay: ' 2000 ' }).autoIdleDelay, 2000);
65
+ test.done();
66
+ };
67
+
68
+ module.exports['Auto-IDLE: zero is honored, fractions are floored'] = test => {
69
+ test.equal(makeClient({ autoIdleDelay: 0 }).autoIdleDelay, 0);
70
+ test.equal(makeClient({ autoIdleDelay: 1500.9 }).autoIdleDelay, 1500);
71
+ test.done();
72
+ };
73
+
74
+ module.exports['Auto-IDLE: unusable values fall back to the default and are reported'] = test => {
75
+ // Every one of these is something setTimeout would turn into a 1ms timer, or something a
76
+ // caller plausibly means as "off" - which is what disableAutoIdle is for.
77
+ for (let value of [NaN, -1, -0.5, Infinity, -Infinity, 'soon', '', ' ', true, false, {}, []]) {
78
+ let { client, warnings } = makeLoggingClient({ autoIdleDelay: value });
79
+ test.equal(client.autoIdleDelay, DEFAULT_DELAY, `${String(value)} falls back to the default`);
80
+ test.equal(warnings.length, 1, `${String(value)} is reported rather than silently swallowed`);
81
+ test.equal(warnings[0].msg, 'Adjusted unusable autoIdleDelay option');
82
+ test.equal(warnings[0].reason, 'not a non-negative finite number', `${String(value)} is reported with the right reason`);
83
+ }
84
+ test.done();
85
+ };
86
+
87
+ module.exports['Auto-IDLE: the delay is capped below socketTimeout'] = test => {
88
+ // A delay at or above socketTimeout means the inactivity watchdog fires before IDLE ever
89
+ // starts. `idling` is still false at that point, so the handler emits ETIMEOUT and tears down
90
+ // a quiet but perfectly healthy connection instead of letting it enter IDLE.
91
+ let { client, warnings } = makeLoggingClient({ autoIdleDelay: 10 * 60 * 1000 });
92
+ test.equal(client.autoIdleDelay, DEFAULT_SOCKET_TIMEOUT - SOCKET_MARGIN);
93
+ test.equal(warnings.length, 1, 'the caller is told the value was capped');
94
+
95
+ // The cap follows a custom socketTimeout, and keeps the delay inside the range setTimeout can
96
+ // represent: 2 ** 31 and larger would otherwise silently become a 1ms timer.
97
+ test.equal(makeClient({ autoIdleDelay: 60000, socketTimeout: 30000 }).autoIdleDelay, 29000);
98
+ test.equal(makeClient({ autoIdleDelay: 2 ** 31 }).autoIdleDelay, DEFAULT_SOCKET_TIMEOUT - SOCKET_MARGIN);
99
+ test.equal(makeClient({ autoIdleDelay: Number.MAX_SAFE_INTEGER }).autoIdleDelay, DEFAULT_SOCKET_TIMEOUT - SOCKET_MARGIN);
100
+ test.done();
101
+ };
102
+
103
+ module.exports['Auto-IDLE: the cap stays inside the timer range for a huge socketTimeout'] = test => {
104
+ // socket.setTimeout truncates an over-range socketTimeout on its own, so a huge watchdog
105
+ // deadline still works - but the auto-IDLE delay must not inherit the raw value, or
106
+ // setTimeout would turn it into a 1ms timer and IDLE would follow every single command.
107
+ test.equal(makeClient({ socketTimeout: 2 ** 32, autoIdleDelay: 2 ** 31 }).autoIdleDelay, TIMEOUT_MAX - SOCKET_MARGIN);
108
+ test.equal(makeClient({ socketTimeout: 2200000000, autoIdleDelay: 2150000000 }).autoIdleDelay, TIMEOUT_MAX - SOCKET_MARGIN);
109
+ test.done();
110
+ };
111
+
112
+ module.exports['Auto-IDLE: capping the default for a short socketTimeout is silent'] = test => {
113
+ let { client, warnings } = makeLoggingClient({ socketTimeout: 5000 });
114
+ test.equal(client.autoIdleDelay, 4000, 'the default is capped too');
115
+ test.equal(warnings.length, 0, 'but nothing was misconfigured, so nothing is reported');
116
+ test.done();
117
+ };
118
+
119
+ module.exports['Auto-IDLE: an invalid value keeps its own reason when the default is then capped'] = test => {
120
+ // 'soon' falls back to the 15s default, which a 5s socketTimeout then caps to 4s. The
121
+ // warning must still name what was wrong with the configured value - the cap applied to
122
+ // the fallback, not to anything the caller asked for.
123
+ let { client, warnings } = makeLoggingClient({ autoIdleDelay: 'soon', socketTimeout: 5000 });
124
+ test.equal(client.autoIdleDelay, 4000);
125
+ test.equal(warnings.length, 1);
126
+ test.equal(warnings[0].reason, 'not a non-negative finite number');
127
+ test.done();
128
+ };
129
+
130
+ // ============================================================================
131
+ // when the timer may arm
132
+ // ============================================================================
133
+
134
+ module.exports['Auto-IDLE: no timer is armed while a mailbox lock is held'] = async test => {
135
+ await withFakeTimers(async timers => {
136
+ let client = makeIdleReadyClient({ autoIdleDelay: 200 });
137
+
138
+ let lock = await client.getMailboxLock('INBOX');
139
+ test.ok(client.currentLock, 'the lock was granted through the fast path');
140
+
141
+ client.autoidle();
142
+ test.equal(timers.count(), 0, 'IDLE must not be injected between a lock holder own commands');
143
+
144
+ lock.release();
145
+ let armed = timers.pending();
146
+ test.equal(armed.length, 1, 'releasing the lock re-arms auto-IDLE');
147
+ test.equal(armed[0].delay, 200);
148
+
149
+ client.close();
150
+ });
151
+ test.done();
152
+ };
153
+
154
+ module.exports['Auto-IDLE: acquiring a lock through SELECT leaves no timer behind'] = async test => {
155
+ await withFakeTimers(async timers => {
156
+ let client = makeIdleReadyClient({ autoIdleDelay: 200 });
157
+ client.mailbox = false;
158
+ // The SELECT that opens the mailbox goes through run(), which re-arms auto-IDLE when it
159
+ // settles - a moment before currentLock is set. That timer would fire inside the lock.
160
+ client.mailboxOpen = async path => {
161
+ client.mailbox = { path, readOnly: false };
162
+ client.autoidle();
163
+ return client.mailbox;
164
+ };
165
+
166
+ let lock = await client.getMailboxLock('INBOX');
167
+ test.ok(client.currentLock, 'the lock was granted through the SELECT path');
168
+ test.equal(timers.count(), 0, 'the timer armed while opening the mailbox was cleared');
169
+
170
+ lock.release();
171
+ test.equal(timers.count(), 1, 'and auto-IDLE resumes once the lock is released');
172
+
173
+ client.close();
174
+ });
175
+ test.done();
176
+ };
177
+
178
+ module.exports['Auto-IDLE: releasing a lock with another one queued does not arm'] = async test => {
179
+ await withFakeTimers(async timers => {
180
+ let client = makeIdleReadyClient({ autoIdleDelay: 200 });
181
+
182
+ let lock = await client.getMailboxLock('INBOX');
183
+ // Queue a second request; the connection is not free when the first holder lets go
184
+ let queued = client.getMailboxLock('INBOX');
185
+ test.equal(client.locks.length, 1, 'the second request is waiting');
186
+
187
+ lock.release();
188
+ test.equal(timers.count(), 0, 'the next holder owns the connection, so IDLE stays off');
189
+
190
+ (await queued).release();
191
+ test.equal(timers.count(), 1, 'the last release re-arms auto-IDLE');
192
+
193
+ client.close();
194
+ });
195
+ test.done();
196
+ };
197
+
198
+ module.exports['Auto-IDLE: no timer is armed while a command is in flight or queued'] = async test => {
199
+ await withFakeTimers(async timers => {
200
+ let client = makeIdleReadyClient({ autoIdleDelay: 200 });
201
+
202
+ // Concurrent commands each clear the timer on entry, so without this guard the first one
203
+ // to settle would arm a timer that fires while the others are still running.
204
+ client.currentRequest = { tag: 'A001', command: 'FETCH', sent: true };
205
+ client.autoidle();
206
+ test.equal(timers.count(), 0, 'a command in flight owns the connection');
207
+
208
+ client.currentRequest = false;
209
+ client.requestQueue = [{ tag: 'A002', command: 'FETCH' }];
210
+ client.autoidle();
211
+ test.equal(timers.count(), 0, 'so does a command still waiting in the queue');
212
+
213
+ client.requestQueue = [];
214
+ client.autoidle();
215
+ test.equal(timers.count(), 1, 'an idle connection arms the timer');
216
+
217
+ client.close();
218
+ });
219
+ test.done();
220
+ };
221
+
222
+ module.exports['Auto-IDLE: the armed timer re-checks the busy guard when it fires'] = async test => {
223
+ await withFakeTimers(async timers => {
224
+ let client = makeIdleReadyClient({ autoIdleDelay: 200 });
225
+ let idleCalls = 0;
226
+ client.idle = async () => {
227
+ idleCalls++;
228
+ };
229
+
230
+ client.autoidle();
231
+ test.equal(timers.count(), 1, 'the timer was armed on a free connection');
232
+
233
+ // Ownership taken without clearing the timer - the guard must not depend on every
234
+ // ownership-taking path remembering its clearTimeout.
235
+ client.currentRequest = { tag: 'A001', command: 'FETCH', sent: true };
236
+ await timers.fire();
237
+ test.equal(idleCalls, 0, 'a connection that is busy at fire time does not start IDLE');
238
+
239
+ client.currentRequest = false;
240
+ client.autoidle();
241
+ await timers.fire();
242
+ test.equal(idleCalls, 1, 'a connection that is free at fire time does');
243
+
244
+ client.close();
245
+ });
246
+ test.done();
247
+ };
248
+
249
+ module.exports['Auto-IDLE: no timer is armed while a download is streaming'] = async test => {
250
+ await withFakeTimers(async timers => {
251
+ let client = makeIdleReadyClient({ autoIdleDelay: 200 });
252
+ let body = Buffer.from('A'.repeat(20));
253
+ let armedAfterChunk = [];
254
+
255
+ let serveChunk = chunkedFetchOne(body);
256
+ client.fetchOne = async (range, query) => {
257
+ // Stands in for run(), which clears the auto-IDLE timer on entry and re-arms it once
258
+ // the command settles. The pause between two chunks is where IDLE would slip in.
259
+ clearTimeout(client.idleStartTimer);
260
+ let part = await serveChunk(range, query);
261
+ client.autoidle();
262
+ armedAfterChunk.push(timers.count());
263
+ return part;
264
+ };
265
+
266
+ let { content } = await client.download('1', false, { chunkSize: 4 });
267
+ let received = [];
268
+ for await (let chunk of content) {
269
+ received.push(chunk);
270
+ }
271
+
272
+ test.equal(Buffer.concat(received).toString(), 'A'.repeat(20), 'the whole body arrived');
273
+ test.ok(armedAfterChunk.length > 2, 'the body really was fetched in several chunks');
274
+ test.deepEqual(
275
+ armedAfterChunk.slice(1).filter(count => count !== 0),
276
+ [],
277
+ 'no chunk boundary inside the download armed an auto-IDLE timer'
278
+ );
279
+ test.equal(timers.count(), 1, 'auto-IDLE resumes once the download stream is finished');
280
+
281
+ client.close();
282
+ });
283
+ test.done();
284
+ };
285
+
286
+ module.exports['Auto-IDLE: a download counts as busy before control returns to the event loop'] = async test => {
287
+ await withFakeTimers(async () => {
288
+ let client = makeIdleReadyClient({ autoIdleDelay: 200 });
289
+ let body = Buffer.from('B'.repeat(12));
290
+ client.fetchOne = chunkedFetchOne(body);
291
+
292
+ let { content } = await client.download('1', false, { chunkSize: 4 });
293
+ // The head chunk's own FETCH re-arms auto-IDLE a moment before the download exists;
294
+ // with a very short delay that timer fires before the deferred chunk loop starts, so
295
+ // the busy guard must already see the download when download() hands the stream back.
296
+ test.equal(client._openDownloads, 1, 'the download is counted before streaming starts');
297
+
298
+ let received = [];
299
+ for await (let chunk of content) {
300
+ received.push(chunk);
301
+ }
302
+ test.equal(Buffer.concat(received).toString(), 'B'.repeat(12), 'the whole body arrived');
303
+ test.equal(client._openDownloads, 0, 'the download is released once the stream is done');
304
+
305
+ client.close();
306
+ });
307
+ test.done();
308
+ };
309
+
310
+ module.exports['Auto-IDLE: a failing command still re-arms the timer'] = async test => {
311
+ await withFakeTimers(async timers => {
312
+ let client = makeIdleReadyClient({ autoIdleDelay: 300 });
313
+ // run() clears the timer before dispatching, so re-arming only on the success path would
314
+ // leave auto-IDLE off for good after one rejected command.
315
+ client.runInternal = async () => {
316
+ throw new Error('command failed');
317
+ };
318
+
319
+ let err;
320
+ try {
321
+ await client.run('NOOP');
322
+ } catch (E) {
323
+ err = E;
324
+ }
325
+
326
+ test.ok(err, 'the failure still reaches the caller');
327
+ let armed = timers.pending();
328
+ test.equal(armed.length, 1, 'auto-IDLE is re-armed after a failed command');
329
+ test.equal(armed[0].delay, 300);
330
+
331
+ client.close();
332
+ });
333
+ test.done();
334
+ };
335
+
336
+ module.exports['Auto-IDLE: a failing IDLE break still re-arms the timer'] = async test => {
337
+ await withFakeTimers(async timers => {
338
+ let client = makeIdleReadyClient({ autoIdleDelay: 300 });
339
+ // run() awaits preCheck() (breaking an active IDLE) before dispatching. A break that
340
+ // rejects exits run() before the command ever starts, and must still re-arm auto-IDLE
341
+ // exactly like a failed command does.
342
+ client.preCheck = async () => {
343
+ throw new Error('IDLE break failed');
344
+ };
345
+
346
+ let err;
347
+ try {
348
+ await client.run('NOOP');
349
+ } catch (E) {
350
+ err = E;
351
+ }
352
+
353
+ test.ok(err, 'the failure still reaches the caller');
354
+ let armed = timers.pending();
355
+ test.equal(armed.length, 1, 'auto-IDLE is re-armed after the failed IDLE break');
356
+ test.equal(armed[0].delay, 300);
357
+
358
+ client.close();
359
+ });
360
+ test.done();
361
+ };
362
+
363
+ // ============================================================================
364
+ // the socket inactivity watchdog
365
+ // ============================================================================
366
+
367
+ // A client wired up for _socketTimeout() with recording stubs: recovery commands land in
368
+ // `recovered`, emitted errors in `errors`. run() is stubbed, so nothing here re-arms auto-IDLE -
369
+ // which is deliberate: the handler itself must never start IDLE, that decision belongs to
370
+ // autoidle() once the recovery NOOP settles for real.
371
+ const makeWatchdogClient = () => {
372
+ let client = makeIdleReadyClient();
373
+ let errors = [];
374
+ let recovered = [];
375
+ client.on('error', err => errors.push(err));
376
+ client.run = async command => recovered.push(command);
377
+ client.idle = async () => recovered.push('IDLE');
378
+ client.writeSocket = client.socket;
379
+ client.setSocketHandlers();
380
+ return { client, errors, recovered };
381
+ };
382
+
383
+ const drainImmediate = () => new Promise(resolve => setImmediate(resolve));
384
+
385
+ module.exports['Auto-IDLE: a stalled download survives the socket watchdog'] = async test => {
386
+ // Suppressing auto-IDLE during a download means `idling` is false when the inactivity
387
+ // watchdog fires, so without the download clause the watchdog would tear down a connection
388
+ // whose only problem is a consumer that stopped draining.
389
+ let { client, errors, recovered } = makeWatchdogClient();
390
+ client._openDownloads = 1;
391
+
392
+ client._socketTimeout();
393
+ await drainImmediate();
394
+
395
+ test.deepEqual(errors, [], 'no ETIMEOUT is emitted while a download is open');
396
+ test.deepEqual(recovered, ['NOOP'], 'kept alive with a NOOP only - IDLE mid-download is what the busy guard exists to prevent');
397
+
398
+ client._openDownloads = 0;
399
+ client._socketTimeout();
400
+ test.equal(errors.length, 1, 'once the download is done a quiet socket is a timeout again');
401
+ test.equal(errors[0].code, 'ETIMEOUT');
402
+
403
+ client.close();
404
+ test.ok(client.isClosed, 'the socket stub supports everything close() needs');
405
+ test.done();
406
+ };
407
+
408
+ module.exports['Auto-IDLE: a stuck chunk FETCH mid-download is a dead connection'] = async test => {
409
+ // A quiet socket while a command is awaiting its reply means the reply is overdue. A
410
+ // recovery NOOP would only queue behind the stuck command and never reach the wire, so
411
+ // the watchdog must report the timeout instead of recovering into a silent hang.
412
+ let { client, errors, recovered } = makeWatchdogClient();
413
+ client._openDownloads = 1;
414
+ client.currentRequest = { tag: 'A001', command: 'FETCH', sent: true };
415
+
416
+ client._socketTimeout();
417
+ await drainImmediate();
418
+
419
+ test.deepEqual(recovered, [], 'no recovery is attempted behind a stuck command');
420
+ test.equal(errors.length, 1, 'the caller learns the connection is dead');
421
+ test.equal(errors[0].code, 'ETIMEOUT');
422
+
423
+ client.close();
424
+ test.done();
425
+ };
426
+
427
+ module.exports['Auto-IDLE: a held mailbox lock keeps a quiet connection alive'] = async test => {
428
+ // A lock holder pausing between commands for longer than socketTimeout is legitimate (the
429
+ // held-lock diagnostic warns only after 30 minutes), and with auto-IDLE declining to arm
430
+ // during a lock there is no IDLE traffic to keep the socket busy - the watchdog has to.
431
+ let { client, errors, recovered } = makeWatchdogClient();
432
+ client.currentLock = { lockId: 1 };
433
+
434
+ client._socketTimeout();
435
+ await drainImmediate();
436
+
437
+ test.deepEqual(errors, [], 'the lock holder keeps its connection');
438
+ test.deepEqual(recovered, ['NOOP'], 'kept alive with a NOOP only, no IDLE inside the lock');
439
+
440
+ client.currentLock = false;
441
+ client._socketTimeout();
442
+ test.equal(errors.length, 1, 'with the lock gone a quiet socket is a timeout again');
443
+ test.equal(errors[0].code, 'ETIMEOUT');
444
+
445
+ client.close();
446
+ test.done();
447
+ };
448
+
449
+ module.exports['Auto-IDLE: idling recovers even with the IDLE command in flight'] = async test => {
450
+ // During true IDLE the in-flight command IS the IDLE command; run() breaks it through
451
+ // preCheck() before the recovery NOOP goes out, so it does not count as stuck. A recovery
452
+ // NOOP that then never settles does: the next timeout must fail rather than queue another.
453
+ let { client, errors, recovered } = makeWatchdogClient();
454
+ client.idling = true;
455
+ client.currentRequest = { tag: 'A001', command: 'IDLE', sent: true };
456
+
457
+ client._socketTimeout();
458
+ await drainImmediate();
459
+ test.deepEqual(errors, [], 'the idling connection is not torn down');
460
+ test.deepEqual(recovered, ['NOOP'], 'it is recovered with a NOOP');
461
+
462
+ client.idling = false;
463
+ client.currentRequest = { tag: 'A002', command: 'NOOP', sent: true };
464
+ client._socketTimeout();
465
+ test.equal(errors.length, 1, 'a recovery NOOP that never settled is a dead connection');
466
+ test.equal(errors[0].code, 'ETIMEOUT');
467
+
468
+ client.close();
469
+ test.done();
470
+ };
@@ -254,7 +254,9 @@ module.exports['Connection Edge: Socket timeout during IDLE'] = test => {
254
254
  // Give async operations time to complete
255
255
  setTimeout(() => {
256
256
  test.ok(noopCalled, 'NOOP should be called to recover from IDLE timeout');
257
- test.ok(idleCalled, 'Should return to IDLE after NOOP');
257
+ // Returning to IDLE is autoidle()'s decision once the NOOP settles (run() re-arms it);
258
+ // the watchdog handler itself must not bypass the busy guard by calling idle() directly
259
+ test.equal(idleCalled, false, 'the timeout handler does not restart IDLE by itself');
258
260
  test.done();
259
261
  }, 100);
260
262
  };
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+
3
+ // Shared ImapFlow construction for unit tests that poke at internals without a live server.
4
+ // One definition instead of a per-file copy, so a change to what the constructor needs lands
5
+ // in one place and cannot drift between suites.
6
+
7
+ const { ImapFlow } = require('../../lib/imap-flow');
8
+
9
+ // A socket stub complete enough for setSocketHandlers(), clearSocketHandlers() and close() to
10
+ // run against. removeListener/removeAllListeners matter most: close() removes the handlers it
11
+ // installed, and a stub without them makes close() throw half-way through teardown with the
12
+ // client still holding its socket and streamer references.
13
+ const makeSocketStub = () => ({
14
+ destroyed: false,
15
+ destroy: () => {},
16
+ on: () => {},
17
+ once: () => {},
18
+ end: () => {},
19
+ unpipe: () => {},
20
+ removeListener: () => {},
21
+ removeAllListeners: () => {},
22
+ setKeepAlive: () => {},
23
+ setTimeout: () => {}
24
+ });
25
+
26
+ // Bare construction: no socket, no state overrides. Tests that need a transport use
27
+ // makeSocketStub() or makeIdleReadyClient() instead of reaching for their own object literals.
28
+ const makeClient = (overrides = {}) =>
29
+ new ImapFlow({
30
+ host: '127.0.0.1',
31
+ port: 993,
32
+ logger: false,
33
+ auth: { user: 'test', pass: 'secret' },
34
+ ...overrides
35
+ });
36
+
37
+ // A client parked in SELECTED with a stubbed socket and a no-op idle(), ready for autoidle(),
38
+ // lock handling and watchdog behavior to be exercised directly
39
+ const makeIdleReadyClient = (overrides = {}) => {
40
+ let client = makeClient({ maxLockHoldTime: 0, ...overrides });
41
+ client.socket = makeSocketStub();
42
+ client.usable = true;
43
+ client.state = client.states.SELECTED;
44
+ client.mailbox = { path: 'INBOX', readOnly: false };
45
+ client.idle = async () => {};
46
+ return client;
47
+ };
48
+
49
+ // A fetchOne stand-in that serves `body` back in download()'s chunk-query shape
50
+ // ({source: {start, maxLength}}), so download tests do not each restate that contract
51
+ const chunkedFetchOne = body => async (range, query) => {
52
+ let start = query.source.start;
53
+ let maxLength = query.source.maxLength;
54
+ return { uid: 1, size: body.length, source: body.subarray(start, start + maxLength) };
55
+ };
56
+
57
+ module.exports = { makeClient, makeIdleReadyClient, makeSocketStub, chunkedFetchOne };
@@ -428,3 +428,91 @@ module.exports['Polling: a break in the initiation tick prevents the first poll'
428
428
  test.done();
429
429
  });
430
430
  };
431
+
432
+ module.exports['Polling: a restarted session resumes the schedule instead of polling again'] = async test => {
433
+ await withFakeTimers(async timers => {
434
+ let connection = createConnection();
435
+
436
+ let first = idleCommand(connection, 60000);
437
+ await timers.drain();
438
+ test.deepEqual(connection.commands, ['NOOP'], 'the first session polls immediately');
439
+
440
+ await connection.preCheck();
441
+ await first;
442
+
443
+ // Auto-IDLE restarts the loop after every caller command. An unconditional first poll here
444
+ // would tie the poll rate to how often the caller runs commands instead of to the poll
445
+ // interval - with a short autoIdleDelay, a command every few seconds means a poll every
446
+ // few seconds.
447
+ let second = idleCommand(connection, 60000);
448
+ await timers.drain();
449
+ test.deepEqual(connection.commands, ['NOOP'], 'the restarted session does not poll again');
450
+ test.equal(timers.count(), 1, 'it waits out the remainder of the interval instead');
451
+ test.ok(timers.pending()[0].delay <= 60000, 'and never longer than a full interval');
452
+
453
+ await connection.preCheck();
454
+ await second;
455
+
456
+ // Once a full interval has elapsed, a fresh session polls at once again
457
+ connection._lastPollAt = Date.now() - 61000;
458
+ let third = idleCommand(connection, 60000);
459
+ await timers.drain();
460
+ test.deepEqual(connection.commands, ['NOOP', 'NOOP'], 'a session starting after the interval polls right away');
461
+
462
+ await connection.preCheck();
463
+ await third;
464
+ test.done();
465
+ });
466
+ };
467
+
468
+ module.exports['Polling: a failed poll does not defer the next session'] = async test => {
469
+ await withFakeTimers(async timers => {
470
+ let failNext = true;
471
+ let connection = createConnection({
472
+ exec: async command => {
473
+ connection.commands.push(command);
474
+ if (failNext) {
475
+ failNext = false;
476
+ throw new Error('poll failed');
477
+ }
478
+ return { next: () => {} };
479
+ }
480
+ });
481
+
482
+ // The failing poll cancels its own session. The attempt checked nothing, so it must not
483
+ // count as a poll: only a completed poll moves the schedule stamp forward.
484
+ let first = idleCommand(connection, 60000);
485
+ await timers.drain();
486
+ await first;
487
+ test.deepEqual(connection.commands, ['NOOP'], 'the first poll ran and failed');
488
+
489
+ let second = idleCommand(connection, 60000);
490
+ await timers.drain();
491
+ test.deepEqual(connection.commands, ['NOOP', 'NOOP'], 'the next session retries immediately instead of waiting out an interval');
492
+
493
+ await connection.preCheck();
494
+ await second;
495
+ test.done();
496
+ });
497
+ };
498
+
499
+ module.exports['Polling: a backward clock step never defers the next poll past one interval'] = async test => {
500
+ await withFakeTimers(async timers => {
501
+ let connection = createConnection();
502
+
503
+ // A last-poll stamp in the future is what an NTP step or a VM clock sync leaves behind.
504
+ // Without the clamp the remainder math would schedule the next poll a full clock jump
505
+ // plus one interval away.
506
+ connection._lastPollAt = Date.now() + 60 * 60 * 1000;
507
+
508
+ let idlePromise = idleCommand(connection, 60000);
509
+ await timers.drain();
510
+ test.deepEqual(connection.commands, [], 'no immediate poll - the schedule is resumed');
511
+ test.equal(timers.count(), 1, 'a poll timer is armed');
512
+ test.ok(timers.pending()[0].delay <= 60000, 'and it is never more than one interval away');
513
+
514
+ await connection.preCheck();
515
+ await idlePromise;
516
+ test.done();
517
+ });
518
+ };
@@ -5,6 +5,7 @@
5
5
  // reader/handler error branches.
6
6
 
7
7
  const { ImapFlow } = require('../lib/imap-flow');
8
+ const { makeSocketStub } = require('./fixtures/test-client');
8
9
 
9
10
  const makeClient = (overrides = {}) => {
10
11
  let client = new ImapFlow({
@@ -293,15 +294,6 @@ module.exports['Coverage: authenticate throws when run yields falsy auth result'
293
294
  // Socket event handlers (built by setSocketHandlers)
294
295
  // ============================================================================
295
296
 
296
- // Minimal socket stub that records listeners so handlers can be invoked directly.
297
- const makeSocketStub = () => ({
298
- destroyed: false,
299
- once() {},
300
- on() {},
301
- removeListener() {},
302
- destroy() {}
303
- });
304
-
305
297
  module.exports['Coverage: setSocketHandlers removes a lingering connect error handler'] = test => {
306
298
  let client = makeClient();
307
299
  let removed = null;
@@ -389,7 +381,9 @@ module.exports['Coverage: _socketTimeout recovers an IDLE connection with NOOP']
389
381
  await drain();
390
382
  await drain();
391
383
  test.ok(noopRun, 'NOOP issued to recover IDLE');
392
- test.ok(idleResumed, 'IDLE resumed after NOOP');
384
+ // Restarting IDLE is autoidle()'s decision once the NOOP settles (run() re-arms it);
385
+ // the watchdog handler itself must not bypass the busy guard by calling idle() directly.
386
+ test.equal(idleResumed, false, 'the handler does not restart IDLE by itself');
393
387
  test.done();
394
388
  };
395
389
 
@@ -9,6 +9,7 @@ const libbase64 = require('libbase64');
9
9
  const libqp = require('libqp');
10
10
  const libmime = require('libmime');
11
11
  const { Writable, finished } = require('stream');
12
+ const { chunkedFetchOne } = require('./fixtures/test-client');
12
13
 
13
14
  const makeClient = (overrides = {}) => {
14
15
  let client = new ImapFlow({
@@ -217,11 +218,7 @@ module.exports['Download: returns empty object without mailbox'] = async test =>
217
218
  module.exports['Download: full message in multiple chunks'] = async test => {
218
219
  let client = makeClient();
219
220
  let body = Buffer.from('A'.repeat(10));
220
- client.fetchOne = async (range, query) => {
221
- let start = query.source.start;
222
- let maxLength = query.source.maxLength;
223
- return { uid: 1, size: body.length, source: body.slice(start, start + maxLength) };
224
- };
221
+ client.fetchOne = chunkedFetchOne(body);
225
222
  let { meta, content } = await client.download('1', false, { chunkSize: 4 });
226
223
  test.equal(meta.contentType, 'message/rfc822');
227
224
  test.equal(meta.expectedSize, 10);
@@ -233,11 +230,7 @@ module.exports['Download: full message in multiple chunks'] = async test => {
233
230
  module.exports['Download: falls back to default chunkSize/maxBytes when zero'] = async test => {
234
231
  let client = makeClient();
235
232
  let body = Buffer.from('tiny');
236
- client.fetchOne = async (range, query) => {
237
- let start = query.source.start;
238
- let maxLength = query.source.maxLength;
239
- return { uid: 1, size: body.length, source: body.slice(start, start + maxLength) };
240
- };
233
+ client.fetchOne = chunkedFetchOne(body);
241
234
  // zero values are falsy -> the (|| default) fallbacks kick in
242
235
  let { content } = await client.download('1', false, { chunkSize: 0, maxBytes: 0 });
243
236
  let data = await collect(content);
@@ -6,17 +6,8 @@
6
6
  // and the untaggedFetch flag/modseq branches.
7
7
 
8
8
  const { ImapFlow } = require('../lib/imap-flow');
9
-
10
- const makeClient = (overrides = {}) => {
11
- let client = new ImapFlow({
12
- host: 'imap.example.com',
13
- port: 993,
14
- auth: { user: 'test', pass: 'test' },
15
- logger: false,
16
- ...overrides
17
- });
18
- return client;
19
- };
9
+ const { withFakeTimers } = require('./fixtures/fake-timers');
10
+ const { makeClient, makeIdleReadyClient } = require('./fixtures/test-client');
20
11
 
21
12
  // ============================================================================
22
13
  // emitError
@@ -275,29 +266,20 @@ module.exports['Internals: autoidle does nothing when not selected'] = test => {
275
266
  test.done();
276
267
  };
277
268
 
278
- module.exports['Internals: autoidle schedules idle when selected'] = test => {
279
- let client = makeClient();
280
- client.state = client.states.SELECTED;
269
+ module.exports['Internals: autoidle schedules idle when selected'] = async test => {
270
+ await withFakeTimers(async timers => {
271
+ let client = makeIdleReadyClient();
272
+
273
+ let idleCalled = false;
274
+ client.idle = async () => {
275
+ idleCalled = true;
276
+ };
281
277
 
282
- let realSetTimeout = global.setTimeout;
283
- let idleCalled = false;
284
- client.idle = async () => {
285
- idleCalled = true;
286
- };
287
- // Intercept the 15s idle timer and fire it synchronously
288
- global.setTimeout = (fn, ms) => {
289
- if (ms === 15 * 1000) {
290
- fn();
291
- return { unref() {} };
292
- }
293
- return realSetTimeout(fn, ms);
294
- };
295
- try {
296
278
  client.autoidle();
297
- } finally {
298
- global.setTimeout = realSetTimeout;
299
- }
300
- test.ok(idleCalled);
279
+ await timers.fire();
280
+
281
+ test.ok(idleCalled);
282
+ });
301
283
  test.done();
302
284
  };
303
285
 
@@ -9,9 +9,9 @@
9
9
  // Asserted through timer identity and cleanup rather than wall-clock sleeps.
10
10
 
11
11
  const net = require('net');
12
- const { ImapFlow } = require('../lib/imap-flow');
13
12
  const idleCommand = require('../lib/commands/idle.js');
14
13
  const { withFakeTimers } = require('./fixtures/fake-timers');
14
+ const { makeClient, makeIdleReadyClient } = require('./fixtures/test-client');
15
15
 
16
16
  const CAPS = 'IMAP4rev1 ID ENABLE NAMESPACE IDLE';
17
17
 
@@ -52,15 +52,6 @@ const createServer = () =>
52
52
 
53
53
  const listen = server => new Promise(resolve => server.listen(0, '127.0.0.1', () => resolve(server.address().port)));
54
54
 
55
- const makeClient = (overrides = {}) =>
56
- new ImapFlow({
57
- host: '127.0.0.1',
58
- port: 993,
59
- logger: false,
60
- auth: { user: 'test', pass: 'secret' },
61
- ...overrides
62
- });
63
-
64
55
  module.exports['Timers: connection and greeting deadlines keep the process alive'] = async test => {
65
56
  let server = createServer();
66
57
  let port = await listen(server);
@@ -99,15 +90,13 @@ module.exports['Timers: connection and greeting deadlines keep the process alive
99
90
 
100
91
  module.exports['Timers: the auto-IDLE timer is unrefd and cleared on close'] = async test => {
101
92
  await withFakeTimers(async timers => {
102
- let client = makeClient();
103
- client.state = client.states.SELECTED;
104
- client.idle = async () => {};
93
+ let client = makeIdleReadyClient();
105
94
 
106
95
  client.autoidle();
107
96
 
108
97
  let armed = timers.pending();
109
98
  test.equal(armed.length, 1, 'exactly one auto-IDLE timer is armed');
110
- test.equal(armed[0].delay, 15 * 1000);
99
+ test.equal(armed[0].delay, client.autoIdleDelay);
111
100
  test.ok(armed[0].unrefd, 'the background auto-IDLE timer does not keep the process alive');
112
101
 
113
102
  client.close();
@@ -119,9 +108,7 @@ module.exports['Timers: the auto-IDLE timer is unrefd and cleared on close'] = a
119
108
 
120
109
  module.exports['Timers: a restarted auto-IDLE timer replaces the previous one'] = async test => {
121
110
  await withFakeTimers(async timers => {
122
- let client = makeClient();
123
- client.state = client.states.SELECTED;
124
- client.idle = async () => {};
111
+ let client = makeIdleReadyClient();
125
112
 
126
113
  client.autoidle();
127
114
  client.autoidle();