imapflow 2.7.0 → 2.9.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.9.0](https://github.com/postalsys/imapflow/compare/v2.8.0...v2.9.0) (2026-10-22)
4
+
5
+
6
+ ### Features
7
+
8
+ * closedByClient tells a close the application asked for from one the server made, and auto-IDLE never waits longer than maxIdleTime ([5339c12](https://github.com/postalsys/imapflow/commit/5339c12895a759621b2e30bbb07521dba7b39929))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * logout() marks the close as the application's before the LOGOUT exchange, and closeAfter() is tested ([03602d2](https://github.com/postalsys/imapflow/commit/03602d2afa2dc8e243735ad6ede8403b733ed4b0))
14
+
15
+ ## [2.8.0](https://github.com/postalsys/imapflow/compare/v2.7.0...v2.8.0) (2026-10-21)
16
+
17
+
18
+ ### Features
19
+
20
+ * idle({ whenFree: true }) waits for the connection to be free before starting IDLE ([791c24c](https://github.com/postalsys/imapflow/commit/791c24ca607b60585ef619269abb0aa1f400f525))
21
+
3
22
  ## [2.7.0](https://github.com/postalsys/imapflow/compare/v2.6.0...v2.7.0) (2026-10-21)
4
23
 
5
24
 
@@ -45,7 +45,7 @@ async function compress(connection) {
45
45
  // STARTTLS guard does (starttls.ts records a flag, upgradeToSTARTTLS decides):
46
46
  // only the command module holds the response before its backpressure release,
47
47
  // so only it can order teardown ahead of that release.
48
- connection.closeAfter();
48
+ connection.closeConnectionAfter();
49
49
  response.next();
50
50
  throw error;
51
51
  }
@@ -4,7 +4,7 @@
4
4
  import { EventEmitter } from 'node:events';
5
5
  import { PassThrough, type Readable } from 'node:stream';
6
6
  import { AuthenticationFailure } from './errors.js';
7
- import type { AppendResponseObject, CopyResponseObject, DownloadManyOptions, DownloadManyResult, DownloadObject, DownloadNotFound, DownloadOptions, ESearchResult, FetchMessageObject, FetchOptions, FetchQueryObject, IdInfoObject, ImapFlowEvents, ImapFlowOptions, InternalLogger, ListOptions, ListResponse, ListTreeResponse, MailboxCreateResponse, MailboxDeleteResponse, MailboxLockObject, MailboxLockOptions, MailboxObject, MailboxOpenOptions, MailboxRenameResponse, MessageRange, MessageRangeOptions, NamespaceObject, NamespacesObject, QuotaResponse, SearchObject, SearchOptions, SearchReturnOption, SequenceString, StatusObject, StatusQuery, StoreOptions, TlsInfo, ImapTransportSocket } from './types.js';
7
+ import type { AppendResponseObject, CopyResponseObject, DownloadManyOptions, DownloadManyResult, DownloadObject, DownloadNotFound, DownloadOptions, ESearchResult, FetchMessageObject, FetchOptions, FetchQueryObject, IdInfoObject, ImapFlowEvents, ImapFlowOptions, InternalLogger, ListOptions, ListResponse, ListTreeResponse, MailboxCreateResponse, MailboxDeleteResponse, MailboxLockObject, MailboxLockOptions, IdleOptions, MailboxObject, MailboxOpenOptions, MailboxRenameResponse, MessageRange, MessageRangeOptions, NamespaceObject, NamespacesObject, QuotaResponse, SearchObject, SearchOptions, SearchReturnOption, SequenceString, StatusObject, StatusQuery, StoreOptions, TlsInfo, ImapTransportSocket } from './types.js';
8
8
  export type * from './types.js';
9
9
  export type { ImapFlowError } from './errors.js';
10
10
  export { AuthenticationFailure, ImapFlowErrorCode } from './errors.js';
@@ -157,8 +157,17 @@ export declare class ImapFlow extends EventEmitter {
157
157
  * what tells a server dropping an idle session from one dropping a session mid-command
158
158
  */
159
159
  idlingAtClose: boolean;
160
+ /**
161
+ * Whether the application ended the connection (`close()`, `closeAfter()`, `logout()`, disposing
162
+ * the client), `false` while it is open and when the server, the network or an error ended it.
163
+ * Tells a session the server dropped from one the application closed, also for an application
164
+ * that closes a connection only to open a new one
165
+ */
166
+ closedByClient: boolean;
160
167
  /** Whether log entries are also emitted as 'log' events, see the `emitLogs` option */
161
168
  emitLogs: boolean;
169
+ /** How often IDLE is restarted, in milliseconds (the `maxIdleTime` option), `false` for never */
170
+ maxIdleTime: number | false;
162
171
  /** Hash algorithm for the fallback message id, see `ImapFlowOptions.idHashAlgorithm` */
163
172
  idHashAlgorithm: string;
164
173
  /** The personal namespace, from the NAMESPACE command */
@@ -378,8 +387,10 @@ export declare class ImapFlow extends EventEmitter {
378
387
  * return until IDLE is finished which means you would have to call some other command out of scope.
379
388
  *
380
389
  * Unlike auto-IDLE, a direct call does not wait for the connection to be free: a mailbox lock
381
- * holder may idle inside its own lock, and idle() can not tell it from another caller.
390
+ * holder may idle inside its own lock, and idle() can not tell it from another caller. Code that
391
+ * holds no lock passes `{ whenFree: true }` to wait for the connection to be free first.
382
392
  *
393
+ * @param options IDLE options
383
394
  * @returns `false` if IDLE failed, `undefined` otherwise
384
395
  *
385
396
  * @example
@@ -387,7 +398,7 @@ export declare class ImapFlow extends EventEmitter {
387
398
  *
388
399
  * await client.idle();
389
400
  */
390
- idle(): Promise<boolean | undefined>;
401
+ idle(options?: IdleOptions | undefined): Promise<boolean | undefined>;
391
402
  /**
392
403
  * Sets flags for a message or message range
393
404
  *
@@ -302,7 +302,10 @@ class ImapFlow extends node_events_1.EventEmitter {
302
302
  this.context = this.options.context;
303
303
  this.connectedAt = null;
304
304
  this.lastClientWrite = null;
305
+ this._idleWaiters = [];
305
306
  this.idlingAtClose = false;
307
+ this.closedByClient = false;
308
+ this._closePending = false;
306
309
  this.emitLogs = !!this.options.emitLogs;
307
310
  this.lo = 0;
308
311
  this.untaggedHandlers = {};
@@ -314,6 +317,11 @@ class ImapFlow extends node_events_1.EventEmitter {
314
317
  this.idRequested = false;
315
318
  this.maxIdleTime = this.options.maxIdleTime || false;
316
319
  this.autoIdleDelay = normalizeAutoIdleDelay(this.options.autoIdleDelay, this.socketTimeout, this.log, this.id);
320
+ // A server that needs IDLE restarted this often can cut the silence before the first IDLE just
321
+ // as well: the connection is never left quiet for longer than maxIdleTime
322
+ if (this.maxIdleTime && this.autoIdleDelay > this.maxIdleTime) {
323
+ this.autoIdleDelay = this.maxIdleTime;
324
+ }
317
325
  this._lastPollAt = 0;
318
326
  this._openDownloads = 0;
319
327
  this._recoveryPending = false;
@@ -333,7 +341,7 @@ class ImapFlow extends node_events_1.EventEmitter {
333
341
  // than emitting an error event to the caller.
334
342
  this._streamerErrorHandler = (err) => {
335
343
  if (['Z_BUF_ERROR', 'ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'EHOSTUNREACH'].includes(err.code)) {
336
- this.closeAfter();
344
+ this.closeConnectionAfter();
337
345
  return;
338
346
  }
339
347
  this.log.error({ err, cid: this.id });
@@ -354,7 +362,7 @@ class ImapFlow extends node_events_1.EventEmitter {
354
362
  // only awaited connect(), as an unhandled 'error' that throws.
355
363
  if (this._errorReported) {
356
364
  this.log.debug({ msg: 'Suppressed error after the connection already reported one', err, cid: this.id });
357
- this.closeAfter();
365
+ this.closeConnectionAfter();
358
366
  return;
359
367
  }
360
368
  // During a STARTTLS handshake the upgrade owns the single error path (its settle()
@@ -372,7 +380,7 @@ class ImapFlow extends node_events_1.EventEmitter {
372
380
  return;
373
381
  }
374
382
  this.upgrading = false;
375
- this.closeAfter();
383
+ this.closeConnectionAfter();
376
384
  return;
377
385
  }
378
386
  // While the initial connect promise is still pending it owns error reporting:
@@ -383,12 +391,12 @@ class ImapFlow extends node_events_1.EventEmitter {
383
391
  this.initialResolve = false;
384
392
  this.initialReject = false;
385
393
  this._errorReported = true;
386
- this.closeAfter();
394
+ this.closeConnectionAfter();
387
395
  reject(err);
388
396
  return;
389
397
  }
390
398
  this._errorReported = true;
391
- this.closeAfter();
399
+ this.closeConnectionAfter();
392
400
  this.emit('error', err);
393
401
  }
394
402
  /** @internal */
@@ -414,7 +422,7 @@ class ImapFlow extends node_events_1.EventEmitter {
414
422
  }
415
423
  if (this.writeSocket.destroyed) {
416
424
  this.log.error({ msg: 'Write socket destroyed', cid: this.id });
417
- this.close();
425
+ this.closeConnection();
418
426
  return;
419
427
  }
420
428
  // Append CRLF only to the final part of a command. When sending literals,
@@ -1264,8 +1272,8 @@ class ImapFlow extends node_events_1.EventEmitter {
1264
1272
  this.log.error({ err, cid: this.id });
1265
1273
  this.emitError(err);
1266
1274
  });
1267
- this._socketClose = this._socketClose || (() => this.close());
1268
- this._socketEnd = this._socketEnd || (() => this.close());
1275
+ this._socketClose = this._socketClose || (() => this.closeConnection());
1276
+ this._socketEnd = this._socketEnd || (() => this.closeConnection());
1269
1277
  /**
1270
1278
  * Socket timeout event handler.
1271
1279
  *
@@ -1319,7 +1327,7 @@ class ImapFlow extends node_events_1.EventEmitter {
1319
1327
  .catch(err => {
1320
1328
  this.log.warn({ msg: 'Connection recovery failed after timeout', err, cid: this.id });
1321
1329
  if (!this.isClosed) {
1322
- this.close();
1330
+ this.closeConnection();
1323
1331
  }
1324
1332
  })
1325
1333
  .finally(() => {
@@ -1630,7 +1638,7 @@ class ImapFlow extends node_events_1.EventEmitter {
1630
1638
  // is rejected there instead (with a generic TLS error rather than STARTTLS_INJECTION).
1631
1639
  const failSTARTTLSInjection = () => {
1632
1640
  let err = (0, errors_js_1.createImapError)('Server sent data after the STARTTLS response and before the TLS handshake; possible plaintext-injection attack', 'STARTTLS_INJECTION', { tlsFailed: true });
1633
- this.closeAfter();
1641
+ this.closeConnectionAfter();
1634
1642
  return err;
1635
1643
  };
1636
1644
  // Check 1: the parser saw more input already buffered right after the tagged OK
@@ -1698,7 +1706,7 @@ class ImapFlow extends node_events_1.EventEmitter {
1698
1706
  // Preserve the original error, marked as a TLS failure so callers can tell
1699
1707
  // an upgrade failure from an ordinary command failure.
1700
1708
  err.tlsFailed = true;
1701
- this.closeAfter();
1709
+ this.closeConnectionAfter();
1702
1710
  return reject(err);
1703
1711
  }
1704
1712
  resolve(result);
@@ -1888,7 +1896,7 @@ class ImapFlow extends node_events_1.EventEmitter {
1888
1896
  .catch(err => {
1889
1897
  // The transport goes with the failed attempt: the instance can not be reused, and
1890
1898
  // an open socket would only trip the inactivity watchdog later
1891
- this.closeAfter();
1899
+ this.closeConnectionAfter();
1892
1900
  if (typeof this.initialReject === 'function') {
1893
1901
  this.log.error({ err, cid: this.id });
1894
1902
  (0, tools_js_1.clearTimer)(this.greetingTimeout);
@@ -1938,7 +1946,7 @@ class ImapFlow extends node_events_1.EventEmitter {
1938
1946
  // that answers a LOGOUT is the exception: its tagged completion closes the connection.
1939
1947
  const answersLogout = !!this.currentRequest && this.currentRequest.command.toUpperCase() === 'LOGOUT';
1940
1948
  if (!this.greetingReceived || !answersLogout) {
1941
- this.closeAfter();
1949
+ this.closeConnectionAfter();
1942
1950
  }
1943
1951
  }
1944
1952
  // Drops every capability-derived field together - the counterpart of
@@ -2187,6 +2195,19 @@ class ImapFlow extends node_events_1.EventEmitter {
2187
2195
  connectionBusy() {
2188
2196
  return !!(this.currentLock || this.locks.length || this.currentRequest || this.requestQueue.length || this._openDownloads);
2189
2197
  }
2198
+ // Settles every idle({ whenFree: true }) call waiting for the connection: resolved to re-check
2199
+ // it, or rejected when the connection is gone
2200
+ /** @internal */
2201
+ settleIdleWaiters(err) {
2202
+ for (let { resolve, reject } of this._idleWaiters.splice(0)) {
2203
+ if (err) {
2204
+ reject(err);
2205
+ }
2206
+ else {
2207
+ resolve();
2208
+ }
2209
+ }
2210
+ }
2190
2211
  // Timer process-liveness policy: connection establishment and greeting deadlines keep the
2191
2212
  // process alive, because a caller is waiting on connect() to settle. Background timers
2192
2213
  // (auto-IDLE, IDLE restart, fallback polling, throttle back-off, the held-lock diagnostic) are
@@ -2194,6 +2215,9 @@ class ImapFlow extends node_events_1.EventEmitter {
2194
2215
  // explicitly on close().
2195
2216
  /** @internal */
2196
2217
  autoidle() {
2218
+ // every place the connection may have become free calls this, so it is where waiting
2219
+ // idle({ whenFree: true }) calls re-check it, also with auto-IDLE turned off
2220
+ this.settleIdleWaiters();
2197
2221
  (0, tools_js_1.clearTimer)(this.idleStartTimer);
2198
2222
  if (this.options.disableAutoIdle || !this.usable || this.state !== this.states.SELECTED) {
2199
2223
  return;
@@ -2301,7 +2325,7 @@ class ImapFlow extends node_events_1.EventEmitter {
2301
2325
  this.connectTimeout = setTimeout(() => {
2302
2326
  let err = deadline.error();
2303
2327
  this.log.error({ err, cid: this.id });
2304
- this.closeAfter();
2328
+ this.closeConnectionAfter();
2305
2329
  reject(err);
2306
2330
  }, deadline.remaining());
2307
2331
  let onConnect = () => {
@@ -2328,7 +2352,7 @@ class ImapFlow extends node_events_1.EventEmitter {
2328
2352
  }
2329
2353
  });
2330
2354
  this.log.error({ err, cid: this.id });
2331
- this.closeAfter();
2355
+ this.closeConnectionAfter();
2332
2356
  reject(err);
2333
2357
  }, this.options.greetingTimeout || GREETING_TIMEOUT);
2334
2358
  const connected = this.socket;
@@ -2382,7 +2406,7 @@ class ImapFlow extends node_events_1.EventEmitter {
2382
2406
  catch (err) {
2383
2407
  // refused up front: the connection deadline must not stay armed
2384
2408
  (0, tools_js_1.clearTimer)(this.connectTimeout);
2385
- this.closeAfter();
2409
+ this.closeConnectionAfter();
2386
2410
  this.log.error({ err, cid: this.id });
2387
2411
  reject(err);
2388
2412
  return;
@@ -2396,7 +2420,7 @@ class ImapFlow extends node_events_1.EventEmitter {
2396
2420
  this._connectErrorHandler = (err) => {
2397
2421
  (0, tools_js_1.clearTimer)(this.connectTimeout);
2398
2422
  (0, tools_js_1.clearTimer)(this.greetingTimeout);
2399
- this.closeAfter();
2423
+ this.closeConnectionAfter();
2400
2424
  this.log.error({ err, cid: this.id });
2401
2425
  reject(err);
2402
2426
  };
@@ -2414,6 +2438,9 @@ class ImapFlow extends node_events_1.EventEmitter {
2414
2438
  * await client.logout();
2415
2439
  */
2416
2440
  async logout() {
2441
+ // marked before the exchange: a server may answer with BYE and drop the socket before the
2442
+ // tagged OK, which closes the connection from the socket's side first
2443
+ this.markClosedByClient();
2417
2444
  return await this.run('LOGOUT');
2418
2445
  }
2419
2446
  /**
@@ -2422,7 +2449,21 @@ class ImapFlow extends node_events_1.EventEmitter {
2422
2449
  * caller function to proceed, and run `close()` function afterwards.
2423
2450
  */
2424
2451
  closeAfter() {
2425
- setImmediate(() => this.close());
2452
+ this.markClosedByClient();
2453
+ this.closeConnectionAfter();
2454
+ }
2455
+ // The deferred close ImapFlow itself starts (a server BYE, an error), which closedByClient leaves alone
2456
+ /** @internal */
2457
+ closeConnectionAfter() {
2458
+ this._closePending = true;
2459
+ setImmediate(() => this.closeConnection());
2460
+ }
2461
+ // The application asked for the close, unless the connection is already closed or closing
2462
+ /** @internal */
2463
+ markClosedByClient() {
2464
+ if (!this.isClosed && !this._closePending) {
2465
+ this.closedByClient = true;
2466
+ }
2426
2467
  }
2427
2468
  // Connection-scoped wrapper around the shared stamping helper; see buildConnectionError().
2428
2469
  /** @internal */
@@ -2458,6 +2499,13 @@ class ImapFlow extends node_events_1.EventEmitter {
2458
2499
  * client.close();
2459
2500
  */
2460
2501
  close() {
2502
+ this.markClosedByClient();
2503
+ this.closeConnection();
2504
+ }
2505
+ // The close itself, for the application's close() and for the closes ImapFlow starts on its own
2506
+ // (the socket ending, a write to a destroyed socket, a failed timeout recovery)
2507
+ /** @internal */
2508
+ closeConnection() {
2461
2509
  try {
2462
2510
  this.closeTimers();
2463
2511
  this.usable = false;
@@ -2501,6 +2549,9 @@ class ImapFlow extends node_events_1.EventEmitter {
2501
2549
  }
2502
2550
  this.closeRequests();
2503
2551
  this.closeLocks();
2552
+ if (this._idleWaiters.length) {
2553
+ this.settleIdleWaiters(this.createNoConnectionError(this.byeReason, { rejectedFrom: 'idleWait', command: 'IDLE' }));
2554
+ }
2504
2555
  this.closeStreams();
2505
2556
  // clear socket handlers
2506
2557
  this.clearSocketHandlers();
@@ -2941,8 +2992,10 @@ class ImapFlow extends node_events_1.EventEmitter {
2941
2992
  * return until IDLE is finished which means you would have to call some other command out of scope.
2942
2993
  *
2943
2994
  * Unlike auto-IDLE, a direct call does not wait for the connection to be free: a mailbox lock
2944
- * holder may idle inside its own lock, and idle() can not tell it from another caller.
2995
+ * holder may idle inside its own lock, and idle() can not tell it from another caller. Code that
2996
+ * holds no lock passes `{ whenFree: true }` to wait for the connection to be free first.
2945
2997
  *
2998
+ * @param options IDLE options
2946
2999
  * @returns `false` if IDLE failed, `undefined` otherwise
2947
3000
  *
2948
3001
  * @example
@@ -2950,7 +3003,13 @@ class ImapFlow extends node_events_1.EventEmitter {
2950
3003
  *
2951
3004
  * await client.idle();
2952
3005
  */
2953
- async idle() {
3006
+ async idle(options) {
3007
+ // Re-checked on every wake, as another IDLE may have started meanwhile
3008
+ while (options?.whenFree && !this.idling && this.usable && this.connectionBusy()) {
3009
+ await new Promise((resolve, reject) => {
3010
+ this._idleWaiters.push({ resolve, reject });
3011
+ });
3012
+ }
2954
3013
  if (!this.idling) {
2955
3014
  return await this.run('IDLE', this.maxIdleTime);
2956
3015
  }
@@ -1,3 +1,3 @@
1
1
  export declare const name = "imapflow";
2
- export declare const version = "2.7.0";
2
+ export declare const version = "2.9.0";
3
3
  export declare const homepage = "https://imapflow.com/";
@@ -3,5 +3,5 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.homepage = exports.version = exports.name = void 0;
5
5
  exports.name = "imapflow";
6
- exports.version = "2.7.0";
6
+ exports.version = "2.9.0";
7
7
  exports.homepage = "https://imapflow.com/";
@@ -745,6 +745,16 @@ export interface MailboxOpenOptions {
745
745
  /** QRESYNC (RFC 7162): the UIDVALIDITY known from the previous session, required with `changedSince` */
746
746
  uidValidity?: bigint | number | string | undefined;
747
747
  }
748
+ export interface IdleOptions {
749
+ /**
750
+ * Wait until the connection is free before starting IDLE: no mailbox lock held or queued, no
751
+ * command running or queued, no download open. Use it from code that does not hold a mailbox
752
+ * lock, so IDLE does not land between another caller's commands. A lock holder must not set it,
753
+ * it would wait for its own release. The returned promise rejects with `NoConnection` when the
754
+ * connection closes while waiting.
755
+ */
756
+ whenFree?: boolean | undefined;
757
+ }
748
758
  export interface MailboxLockOptions extends MailboxOpenOptions {
749
759
  /**
750
760
  * Optional timeout in milliseconds to wait for the lock to be granted.
@@ -42,7 +42,7 @@ export default async function compress(connection) {
42
42
  // STARTTLS guard does (starttls.ts records a flag, upgradeToSTARTTLS decides):
43
43
  // only the command module holds the response before its backpressure release,
44
44
  // so only it can order teardown ahead of that release.
45
- connection.closeAfter();
45
+ connection.closeConnectionAfter();
46
46
  response.next();
47
47
  throw error;
48
48
  }
@@ -4,7 +4,7 @@
4
4
  import { EventEmitter } from 'node:events';
5
5
  import { PassThrough, type Readable } from 'node:stream';
6
6
  import { AuthenticationFailure } from './errors.js';
7
- import type { AppendResponseObject, CopyResponseObject, DownloadManyOptions, DownloadManyResult, DownloadObject, DownloadNotFound, DownloadOptions, ESearchResult, FetchMessageObject, FetchOptions, FetchQueryObject, IdInfoObject, ImapFlowEvents, ImapFlowOptions, InternalLogger, ListOptions, ListResponse, ListTreeResponse, MailboxCreateResponse, MailboxDeleteResponse, MailboxLockObject, MailboxLockOptions, MailboxObject, MailboxOpenOptions, MailboxRenameResponse, MessageRange, MessageRangeOptions, NamespaceObject, NamespacesObject, QuotaResponse, SearchObject, SearchOptions, SearchReturnOption, SequenceString, StatusObject, StatusQuery, StoreOptions, TlsInfo, ImapTransportSocket } from './types.js';
7
+ import type { AppendResponseObject, CopyResponseObject, DownloadManyOptions, DownloadManyResult, DownloadObject, DownloadNotFound, DownloadOptions, ESearchResult, FetchMessageObject, FetchOptions, FetchQueryObject, IdInfoObject, ImapFlowEvents, ImapFlowOptions, InternalLogger, ListOptions, ListResponse, ListTreeResponse, MailboxCreateResponse, MailboxDeleteResponse, MailboxLockObject, MailboxLockOptions, IdleOptions, MailboxObject, MailboxOpenOptions, MailboxRenameResponse, MessageRange, MessageRangeOptions, NamespaceObject, NamespacesObject, QuotaResponse, SearchObject, SearchOptions, SearchReturnOption, SequenceString, StatusObject, StatusQuery, StoreOptions, TlsInfo, ImapTransportSocket } from './types.js';
8
8
  export type * from './types.js';
9
9
  export type { ImapFlowError } from './errors.js';
10
10
  export { AuthenticationFailure, ImapFlowErrorCode } from './errors.js';
@@ -157,8 +157,17 @@ export declare class ImapFlow extends EventEmitter {
157
157
  * what tells a server dropping an idle session from one dropping a session mid-command
158
158
  */
159
159
  idlingAtClose: boolean;
160
+ /**
161
+ * Whether the application ended the connection (`close()`, `closeAfter()`, `logout()`, disposing
162
+ * the client), `false` while it is open and when the server, the network or an error ended it.
163
+ * Tells a session the server dropped from one the application closed, also for an application
164
+ * that closes a connection only to open a new one
165
+ */
166
+ closedByClient: boolean;
160
167
  /** Whether log entries are also emitted as 'log' events, see the `emitLogs` option */
161
168
  emitLogs: boolean;
169
+ /** How often IDLE is restarted, in milliseconds (the `maxIdleTime` option), `false` for never */
170
+ maxIdleTime: number | false;
162
171
  /** Hash algorithm for the fallback message id, see `ImapFlowOptions.idHashAlgorithm` */
163
172
  idHashAlgorithm: string;
164
173
  /** The personal namespace, from the NAMESPACE command */
@@ -378,8 +387,10 @@ export declare class ImapFlow extends EventEmitter {
378
387
  * return until IDLE is finished which means you would have to call some other command out of scope.
379
388
  *
380
389
  * Unlike auto-IDLE, a direct call does not wait for the connection to be free: a mailbox lock
381
- * holder may idle inside its own lock, and idle() can not tell it from another caller.
390
+ * holder may idle inside its own lock, and idle() can not tell it from another caller. Code that
391
+ * holds no lock passes `{ whenFree: true }` to wait for the connection to be free first.
382
392
  *
393
+ * @param options IDLE options
383
394
  * @returns `false` if IDLE failed, `undefined` otherwise
384
395
  *
385
396
  * @example
@@ -387,7 +398,7 @@ export declare class ImapFlow extends EventEmitter {
387
398
  *
388
399
  * await client.idle();
389
400
  */
390
- idle(): Promise<boolean | undefined>;
401
+ idle(options?: IdleOptions | undefined): Promise<boolean | undefined>;
391
402
  /**
392
403
  * Sets flags for a message or message range
393
404
  *
@@ -259,7 +259,10 @@ export class ImapFlow extends EventEmitter {
259
259
  this.context = this.options.context;
260
260
  this.connectedAt = null;
261
261
  this.lastClientWrite = null;
262
+ this._idleWaiters = [];
262
263
  this.idlingAtClose = false;
264
+ this.closedByClient = false;
265
+ this._closePending = false;
263
266
  this.emitLogs = !!this.options.emitLogs;
264
267
  this.lo = 0;
265
268
  this.untaggedHandlers = {};
@@ -271,6 +274,11 @@ export class ImapFlow extends EventEmitter {
271
274
  this.idRequested = false;
272
275
  this.maxIdleTime = this.options.maxIdleTime || false;
273
276
  this.autoIdleDelay = normalizeAutoIdleDelay(this.options.autoIdleDelay, this.socketTimeout, this.log, this.id);
277
+ // A server that needs IDLE restarted this often can cut the silence before the first IDLE just
278
+ // as well: the connection is never left quiet for longer than maxIdleTime
279
+ if (this.maxIdleTime && this.autoIdleDelay > this.maxIdleTime) {
280
+ this.autoIdleDelay = this.maxIdleTime;
281
+ }
274
282
  this._lastPollAt = 0;
275
283
  this._openDownloads = 0;
276
284
  this._recoveryPending = false;
@@ -290,7 +298,7 @@ export class ImapFlow extends EventEmitter {
290
298
  // than emitting an error event to the caller.
291
299
  this._streamerErrorHandler = (err) => {
292
300
  if (['Z_BUF_ERROR', 'ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'EHOSTUNREACH'].includes(err.code)) {
293
- this.closeAfter();
301
+ this.closeConnectionAfter();
294
302
  return;
295
303
  }
296
304
  this.log.error({ err, cid: this.id });
@@ -311,7 +319,7 @@ export class ImapFlow extends EventEmitter {
311
319
  // only awaited connect(), as an unhandled 'error' that throws.
312
320
  if (this._errorReported) {
313
321
  this.log.debug({ msg: 'Suppressed error after the connection already reported one', err, cid: this.id });
314
- this.closeAfter();
322
+ this.closeConnectionAfter();
315
323
  return;
316
324
  }
317
325
  // During a STARTTLS handshake the upgrade owns the single error path (its settle()
@@ -329,7 +337,7 @@ export class ImapFlow extends EventEmitter {
329
337
  return;
330
338
  }
331
339
  this.upgrading = false;
332
- this.closeAfter();
340
+ this.closeConnectionAfter();
333
341
  return;
334
342
  }
335
343
  // While the initial connect promise is still pending it owns error reporting:
@@ -340,12 +348,12 @@ export class ImapFlow extends EventEmitter {
340
348
  this.initialResolve = false;
341
349
  this.initialReject = false;
342
350
  this._errorReported = true;
343
- this.closeAfter();
351
+ this.closeConnectionAfter();
344
352
  reject(err);
345
353
  return;
346
354
  }
347
355
  this._errorReported = true;
348
- this.closeAfter();
356
+ this.closeConnectionAfter();
349
357
  this.emit('error', err);
350
358
  }
351
359
  /** @internal */
@@ -371,7 +379,7 @@ export class ImapFlow extends EventEmitter {
371
379
  }
372
380
  if (this.writeSocket.destroyed) {
373
381
  this.log.error({ msg: 'Write socket destroyed', cid: this.id });
374
- this.close();
382
+ this.closeConnection();
375
383
  return;
376
384
  }
377
385
  // Append CRLF only to the final part of a command. When sending literals,
@@ -1221,8 +1229,8 @@ export class ImapFlow extends EventEmitter {
1221
1229
  this.log.error({ err, cid: this.id });
1222
1230
  this.emitError(err);
1223
1231
  });
1224
- this._socketClose = this._socketClose || (() => this.close());
1225
- this._socketEnd = this._socketEnd || (() => this.close());
1232
+ this._socketClose = this._socketClose || (() => this.closeConnection());
1233
+ this._socketEnd = this._socketEnd || (() => this.closeConnection());
1226
1234
  /**
1227
1235
  * Socket timeout event handler.
1228
1236
  *
@@ -1276,7 +1284,7 @@ export class ImapFlow extends EventEmitter {
1276
1284
  .catch(err => {
1277
1285
  this.log.warn({ msg: 'Connection recovery failed after timeout', err, cid: this.id });
1278
1286
  if (!this.isClosed) {
1279
- this.close();
1287
+ this.closeConnection();
1280
1288
  }
1281
1289
  })
1282
1290
  .finally(() => {
@@ -1587,7 +1595,7 @@ export class ImapFlow extends EventEmitter {
1587
1595
  // is rejected there instead (with a generic TLS error rather than STARTTLS_INJECTION).
1588
1596
  const failSTARTTLSInjection = () => {
1589
1597
  let err = createImapError('Server sent data after the STARTTLS response and before the TLS handshake; possible plaintext-injection attack', 'STARTTLS_INJECTION', { tlsFailed: true });
1590
- this.closeAfter();
1598
+ this.closeConnectionAfter();
1591
1599
  return err;
1592
1600
  };
1593
1601
  // Check 1: the parser saw more input already buffered right after the tagged OK
@@ -1655,7 +1663,7 @@ export class ImapFlow extends EventEmitter {
1655
1663
  // Preserve the original error, marked as a TLS failure so callers can tell
1656
1664
  // an upgrade failure from an ordinary command failure.
1657
1665
  err.tlsFailed = true;
1658
- this.closeAfter();
1666
+ this.closeConnectionAfter();
1659
1667
  return reject(err);
1660
1668
  }
1661
1669
  resolve(result);
@@ -1845,7 +1853,7 @@ export class ImapFlow extends EventEmitter {
1845
1853
  .catch(err => {
1846
1854
  // The transport goes with the failed attempt: the instance can not be reused, and
1847
1855
  // an open socket would only trip the inactivity watchdog later
1848
- this.closeAfter();
1856
+ this.closeConnectionAfter();
1849
1857
  if (typeof this.initialReject === 'function') {
1850
1858
  this.log.error({ err, cid: this.id });
1851
1859
  clearTimer(this.greetingTimeout);
@@ -1895,7 +1903,7 @@ export class ImapFlow extends EventEmitter {
1895
1903
  // that answers a LOGOUT is the exception: its tagged completion closes the connection.
1896
1904
  const answersLogout = !!this.currentRequest && this.currentRequest.command.toUpperCase() === 'LOGOUT';
1897
1905
  if (!this.greetingReceived || !answersLogout) {
1898
- this.closeAfter();
1906
+ this.closeConnectionAfter();
1899
1907
  }
1900
1908
  }
1901
1909
  // Drops every capability-derived field together - the counterpart of
@@ -2144,6 +2152,19 @@ export class ImapFlow extends EventEmitter {
2144
2152
  connectionBusy() {
2145
2153
  return !!(this.currentLock || this.locks.length || this.currentRequest || this.requestQueue.length || this._openDownloads);
2146
2154
  }
2155
+ // Settles every idle({ whenFree: true }) call waiting for the connection: resolved to re-check
2156
+ // it, or rejected when the connection is gone
2157
+ /** @internal */
2158
+ settleIdleWaiters(err) {
2159
+ for (let { resolve, reject } of this._idleWaiters.splice(0)) {
2160
+ if (err) {
2161
+ reject(err);
2162
+ }
2163
+ else {
2164
+ resolve();
2165
+ }
2166
+ }
2167
+ }
2147
2168
  // Timer process-liveness policy: connection establishment and greeting deadlines keep the
2148
2169
  // process alive, because a caller is waiting on connect() to settle. Background timers
2149
2170
  // (auto-IDLE, IDLE restart, fallback polling, throttle back-off, the held-lock diagnostic) are
@@ -2151,6 +2172,9 @@ export class ImapFlow extends EventEmitter {
2151
2172
  // explicitly on close().
2152
2173
  /** @internal */
2153
2174
  autoidle() {
2175
+ // every place the connection may have become free calls this, so it is where waiting
2176
+ // idle({ whenFree: true }) calls re-check it, also with auto-IDLE turned off
2177
+ this.settleIdleWaiters();
2154
2178
  clearTimer(this.idleStartTimer);
2155
2179
  if (this.options.disableAutoIdle || !this.usable || this.state !== this.states.SELECTED) {
2156
2180
  return;
@@ -2258,7 +2282,7 @@ export class ImapFlow extends EventEmitter {
2258
2282
  this.connectTimeout = setTimeout(() => {
2259
2283
  let err = deadline.error();
2260
2284
  this.log.error({ err, cid: this.id });
2261
- this.closeAfter();
2285
+ this.closeConnectionAfter();
2262
2286
  reject(err);
2263
2287
  }, deadline.remaining());
2264
2288
  let onConnect = () => {
@@ -2285,7 +2309,7 @@ export class ImapFlow extends EventEmitter {
2285
2309
  }
2286
2310
  });
2287
2311
  this.log.error({ err, cid: this.id });
2288
- this.closeAfter();
2312
+ this.closeConnectionAfter();
2289
2313
  reject(err);
2290
2314
  }, this.options.greetingTimeout || GREETING_TIMEOUT);
2291
2315
  const connected = this.socket;
@@ -2339,7 +2363,7 @@ export class ImapFlow extends EventEmitter {
2339
2363
  catch (err) {
2340
2364
  // refused up front: the connection deadline must not stay armed
2341
2365
  clearTimer(this.connectTimeout);
2342
- this.closeAfter();
2366
+ this.closeConnectionAfter();
2343
2367
  this.log.error({ err, cid: this.id });
2344
2368
  reject(err);
2345
2369
  return;
@@ -2353,7 +2377,7 @@ export class ImapFlow extends EventEmitter {
2353
2377
  this._connectErrorHandler = (err) => {
2354
2378
  clearTimer(this.connectTimeout);
2355
2379
  clearTimer(this.greetingTimeout);
2356
- this.closeAfter();
2380
+ this.closeConnectionAfter();
2357
2381
  this.log.error({ err, cid: this.id });
2358
2382
  reject(err);
2359
2383
  };
@@ -2371,6 +2395,9 @@ export class ImapFlow extends EventEmitter {
2371
2395
  * await client.logout();
2372
2396
  */
2373
2397
  async logout() {
2398
+ // marked before the exchange: a server may answer with BYE and drop the socket before the
2399
+ // tagged OK, which closes the connection from the socket's side first
2400
+ this.markClosedByClient();
2374
2401
  return await this.run('LOGOUT');
2375
2402
  }
2376
2403
  /**
@@ -2379,7 +2406,21 @@ export class ImapFlow extends EventEmitter {
2379
2406
  * caller function to proceed, and run `close()` function afterwards.
2380
2407
  */
2381
2408
  closeAfter() {
2382
- setImmediate(() => this.close());
2409
+ this.markClosedByClient();
2410
+ this.closeConnectionAfter();
2411
+ }
2412
+ // The deferred close ImapFlow itself starts (a server BYE, an error), which closedByClient leaves alone
2413
+ /** @internal */
2414
+ closeConnectionAfter() {
2415
+ this._closePending = true;
2416
+ setImmediate(() => this.closeConnection());
2417
+ }
2418
+ // The application asked for the close, unless the connection is already closed or closing
2419
+ /** @internal */
2420
+ markClosedByClient() {
2421
+ if (!this.isClosed && !this._closePending) {
2422
+ this.closedByClient = true;
2423
+ }
2383
2424
  }
2384
2425
  // Connection-scoped wrapper around the shared stamping helper; see buildConnectionError().
2385
2426
  /** @internal */
@@ -2415,6 +2456,13 @@ export class ImapFlow extends EventEmitter {
2415
2456
  * client.close();
2416
2457
  */
2417
2458
  close() {
2459
+ this.markClosedByClient();
2460
+ this.closeConnection();
2461
+ }
2462
+ // The close itself, for the application's close() and for the closes ImapFlow starts on its own
2463
+ // (the socket ending, a write to a destroyed socket, a failed timeout recovery)
2464
+ /** @internal */
2465
+ closeConnection() {
2418
2466
  try {
2419
2467
  this.closeTimers();
2420
2468
  this.usable = false;
@@ -2458,6 +2506,9 @@ export class ImapFlow extends EventEmitter {
2458
2506
  }
2459
2507
  this.closeRequests();
2460
2508
  this.closeLocks();
2509
+ if (this._idleWaiters.length) {
2510
+ this.settleIdleWaiters(this.createNoConnectionError(this.byeReason, { rejectedFrom: 'idleWait', command: 'IDLE' }));
2511
+ }
2461
2512
  this.closeStreams();
2462
2513
  // clear socket handlers
2463
2514
  this.clearSocketHandlers();
@@ -2898,8 +2949,10 @@ export class ImapFlow extends EventEmitter {
2898
2949
  * return until IDLE is finished which means you would have to call some other command out of scope.
2899
2950
  *
2900
2951
  * Unlike auto-IDLE, a direct call does not wait for the connection to be free: a mailbox lock
2901
- * holder may idle inside its own lock, and idle() can not tell it from another caller.
2952
+ * holder may idle inside its own lock, and idle() can not tell it from another caller. Code that
2953
+ * holds no lock passes `{ whenFree: true }` to wait for the connection to be free first.
2902
2954
  *
2955
+ * @param options IDLE options
2903
2956
  * @returns `false` if IDLE failed, `undefined` otherwise
2904
2957
  *
2905
2958
  * @example
@@ -2907,7 +2960,13 @@ export class ImapFlow extends EventEmitter {
2907
2960
  *
2908
2961
  * await client.idle();
2909
2962
  */
2910
- async idle() {
2963
+ async idle(options) {
2964
+ // Re-checked on every wake, as another IDLE may have started meanwhile
2965
+ while (options?.whenFree && !this.idling && this.usable && this.connectionBusy()) {
2966
+ await new Promise((resolve, reject) => {
2967
+ this._idleWaiters.push({ resolve, reject });
2968
+ });
2969
+ }
2911
2970
  if (!this.idling) {
2912
2971
  return await this.run('IDLE', this.maxIdleTime);
2913
2972
  }
@@ -1,3 +1,3 @@
1
1
  export declare const name = "imapflow";
2
- export declare const version = "2.7.0";
2
+ export declare const version = "2.9.0";
3
3
  export declare const homepage = "https://imapflow.com/";
@@ -1,4 +1,4 @@
1
1
  // Generated by scripts/build.js from package.json. Do not edit by hand.
2
2
  export const name = "imapflow";
3
- export const version = "2.7.0";
3
+ export const version = "2.9.0";
4
4
  export const homepage = "https://imapflow.com/";
@@ -745,6 +745,16 @@ export interface MailboxOpenOptions {
745
745
  /** QRESYNC (RFC 7162): the UIDVALIDITY known from the previous session, required with `changedSince` */
746
746
  uidValidity?: bigint | number | string | undefined;
747
747
  }
748
+ export interface IdleOptions {
749
+ /**
750
+ * Wait until the connection is free before starting IDLE: no mailbox lock held or queued, no
751
+ * command running or queued, no download open. Use it from code that does not hold a mailbox
752
+ * lock, so IDLE does not land between another caller's commands. A lock holder must not set it,
753
+ * it would wait for its own release. The returned promise rejects with `NoConnection` when the
754
+ * connection closes while waiting.
755
+ */
756
+ whenFree?: boolean | undefined;
757
+ }
748
758
  export interface MailboxLockOptions extends MailboxOpenOptions {
749
759
  /**
750
760
  * Optional timeout in milliseconds to wait for the lock to be granted.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "2.7.0",
3
+ "version": "2.9.0",
4
4
  "description": "IMAP Client for Node",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/imap-flow.js",