imapflow 1.7.1 → 1.7.3

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.7.1"
2
+ ".": "1.7.3"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.7.3](https://github.com/postalsys/imapflow/compare/v1.7.2...v1.7.3) (2026-08-24)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **errors:** make the rejection guard structural and stamp every connection error ([67a4afc](https://github.com/postalsys/imapflow/commit/67a4afcc06a2699ea327e98459590901f2efb24c))
9
+ * **errors:** name the rejection site on connection errors and guard the IDLE preCheck waiter ([55764f0](https://github.com/postalsys/imapflow/commit/55764f056f5aeb9771e493e9b659872f01ee7995))
10
+
11
+ ## [1.7.2](https://github.com/postalsys/imapflow/compare/v1.7.1...v1.7.2) (2026-08-21)
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * **envelope:** stop inventing an address from a NIL host field ([253a7b0](https://github.com/postalsys/imapflow/commit/253a7b0747ebe3c59fb177d46eb9843da6b5e91e))
17
+ * **types:** accept string[] paths in status, getQuota, append, copy and move ([92f3607](https://github.com/postalsys/imapflow/commit/92f360749fd9518532f0d908bf246fb9402f2eaa)), closes [#382](https://github.com/postalsys/imapflow/issues/382)
18
+
3
19
  ## [1.7.1](https://github.com/postalsys/imapflow/compare/v1.7.0...v1.7.1) (2026-08-14)
4
20
 
5
21
 
@@ -254,7 +254,7 @@ module.exports = async (connection, range, query, options) => {
254
254
  // the FETCH on a connection that is already gone.
255
255
  let aborted = await connection.throttleWait(delay);
256
256
  if (aborted) {
257
- throw connection.createNoConnectionError(connection.byeReason);
257
+ throw connection.createNoConnectionError(connection.byeReason, { rejectedFrom: 'throttleAbort', command: 'FETCH' });
258
258
  }
259
259
 
260
260
  retryCount++;
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { hasCapability, logConnectionError, unrefTimer } = require('../tools.js');
3
+ const { guardedPromise, hasCapability, logConnectionError, unrefTimer } = require('../tools.js');
4
4
 
5
5
  const NOOP_INTERVAL = 2 * 60 * 1000;
6
6
 
@@ -85,7 +85,9 @@ async function runIdle(connection) {
85
85
  // Public interface for breaking IDLE. Returns a promise that resolves when
86
86
  // IDLE is actually broken and the connection is free for other commands.
87
87
  let connectionPreCheck = () => {
88
- let handler = new Promise((resolve, reject) => {
88
+ // Guarded: the catch block below rejects every queued waiter synchronously while
89
+ // close() tears the connection down. See guardedPromise().
90
+ let handler = guardedPromise((resolve, reject) => {
89
91
  preCheckWaitQueue.push({ resolve, reject });
90
92
  });
91
93
 
@@ -787,7 +787,7 @@ export class ImapFlow extends EventEmitter {
787
787
  close(): void;
788
788
 
789
789
  /** Returns current quota */
790
- getQuota(path?: string): Promise<QuotaResponse | false>;
790
+ getQuota(path?: string | string[]): Promise<QuotaResponse | false>;
791
791
 
792
792
  /** Lists available mailboxes as an Array */
793
793
  list(options?: ListOptions): Promise<ListResponse[]>;
@@ -821,7 +821,7 @@ export class ImapFlow extends EventEmitter {
821
821
 
822
822
  /** Requests the status of the indicated mailbox */
823
823
  status(
824
- path: string,
824
+ path: string | string[],
825
825
  query: {
826
826
  messages?: boolean;
827
827
  recent?: boolean;
@@ -855,13 +855,21 @@ export class ImapFlow extends EventEmitter {
855
855
  messageDelete(range: SequenceString | number[] | SearchObject, options?: { uid?: boolean }): Promise<boolean>;
856
856
 
857
857
  /** Appends a new message to a mailbox */
858
- append(path: string, content: string | Buffer, flags?: string[], idate?: Date | string): Promise<AppendResponseObject | false>;
858
+ append(path: string | string[], content: string | Buffer, flags?: string[], idate?: Date | string): Promise<AppendResponseObject | false>;
859
859
 
860
860
  /** Copies messages from current mailbox to destination mailbox */
861
- messageCopy(range: SequenceString | number[] | SearchObject, destination: string, options?: { uid?: boolean }): Promise<CopyResponseObject | false>;
861
+ messageCopy(
862
+ range: SequenceString | number[] | SearchObject,
863
+ destination: string | string[],
864
+ options?: { uid?: boolean }
865
+ ): Promise<CopyResponseObject | false>;
862
866
 
863
867
  /** Moves messages from current mailbox to destination mailbox */
864
- messageMove(range: SequenceString | number[] | SearchObject, destination: string, options?: { uid?: boolean }): Promise<CopyResponseObject | false>;
868
+ messageMove(
869
+ range: SequenceString | number[] | SearchObject,
870
+ destination: string | string[],
871
+ options?: { uid?: boolean }
872
+ ): Promise<CopyResponseObject | false>;
865
873
 
866
874
  /** Search messages from the currently opened mailbox — returns number[] (backward-compatible) */
867
875
  search(query: SearchObject, options?: { uid?: boolean }): Promise<number[] | false>;
package/lib/imap-flow.js CHANGED
@@ -43,13 +43,13 @@ const {
43
43
  parseUintValue,
44
44
  isUnsafeKey,
45
45
  getStringList,
46
+ guardedPromise,
47
+ guardedReject,
46
48
  MAX_UINT32_DIGITS
47
49
  } = require('./tools');
48
50
 
49
51
  const imapCommands = require('./imap-commands.js');
50
52
 
51
- const noop = () => {};
52
-
53
53
  const GREETING_TIMEOUT = 16 * 1000;
54
54
  const UPGRADE_TIMEOUT = 10 * 1000;
55
55
 
@@ -677,16 +677,12 @@ class ImapFlow extends EventEmitter {
677
677
  write(chunk) {
678
678
  if (!this.socket || this.socket.destroyed) {
679
679
  // do not write after connection end or logout
680
- const error = new Error('Socket is already closed');
681
- error.code = 'NoConnection';
682
- throw error;
680
+ throw this.createConnectionError('NoConnection', 'Socket is already closed', { rejectedFrom: 'writeNoSocket' });
683
681
  }
684
682
 
685
683
  if (this.state === this.states.LOGOUT) {
686
684
  // should not happen
687
- const error = new Error('Can not send data after logged out');
688
- error.code = 'StateLogout';
689
- throw error;
685
+ throw this.createConnectionError('StateLogout', 'Can not send data after logged out', { rejectedFrom: 'writeAfterLogout' });
690
686
  }
691
687
 
692
688
  if (this.writeSocket.destroyed) {
@@ -771,9 +767,7 @@ class ImapFlow extends EventEmitter {
771
767
  let request = this.requestTagMap.get(data.tag);
772
768
  if (request) {
773
769
  this.requestTagMap.delete(request.tag);
774
- const error = new Error('Connection not available');
775
- error.code = 'NoConnection';
776
- request.reject(error);
770
+ request.reject(this.createNoConnectionError(false, { rejectedFrom: 'sendAfterLogout', command: request.command }));
777
771
  }
778
772
  }
779
773
  return;
@@ -864,26 +858,20 @@ class ImapFlow extends EventEmitter {
864
858
 
865
859
  exec(command, attributes, options) {
866
860
  if (this.state === this.states.LOGOUT || this.isClosed) {
867
- const error = new Error('Connection not available');
868
- error.code = 'NoConnection';
869
- let p = Promise.reject(error);
870
- p.catch(noop);
871
- return p;
861
+ return guardedReject(this.createNoConnectionError(false, { rejectedFrom: 'execClosed', command }));
872
862
  }
873
863
 
874
864
  if (!this.socket || this.socket.destroyed) {
875
- let error = new Error('Connection closed');
876
- error.code = 'EConnectionClosed';
877
- let p = Promise.reject(error);
878
- p.catch(noop);
879
- return p;
865
+ return guardedReject(this.createConnectionError('EConnectionClosed', 'Connection closed', { rejectedFrom: 'execNoSocket', command }));
880
866
  }
881
867
 
882
868
  let tag = (++this.tagCounter).toString(16).toUpperCase();
883
869
 
884
870
  options = options || {};
885
871
 
886
- let promise = new Promise((resolve, reject) => {
872
+ // Guarded: close() rejects this request synchronously, possibly before the caller has
873
+ // attached its handler. See guardedPromise().
874
+ return guardedPromise((resolve, reject) => {
887
875
  this.requestTagMap.set(tag, { command, attributes, options, resolve, reject });
888
876
  this.requestQueue.push({ tag, command, attributes, options });
889
877
  // trySend() settles dispatch failures itself, by rejecting the affected
@@ -891,13 +879,6 @@ class ImapFlow extends EventEmitter {
891
879
  // dispatch machinery itself can never surface as a floating rejection.
892
880
  this.trySend().catch(err => logConnectionError(this, 'Failed to dispatch command', err));
893
881
  });
894
-
895
- // Prevent unhandled promise rejection if close() rejects this request
896
- // synchronously before the caller's handler is attached. The rejection
897
- // still propagates normally to the caller's await/.catch().
898
- promise.catch(noop);
899
-
900
- return promise;
901
882
  }
902
883
 
903
884
  // Resolves an untagged server response to the keyword it is dispatched on. IMAP untagged
@@ -1366,7 +1347,7 @@ class ImapFlow extends EventEmitter {
1366
1347
  // Connection closed during back-off: reject promptly with a
1367
1348
  // connection error (carrying any server BYE reason) instead of
1368
1349
  // waiting out the throttle delay.
1369
- request.reject(this.createNoConnectionError(this.byeReason));
1350
+ request.reject(this.createNoConnectionError(this.byeReason, { rejectedFrom: 'throttleAbort', command: request.command }));
1370
1351
  break;
1371
1352
  }
1372
1353
  }
@@ -1865,9 +1846,7 @@ class ImapFlow extends EventEmitter {
1865
1846
  try {
1866
1847
  /* c8 ignore start */ // race: connection closed during the TLS handshake window
1867
1848
  if (this.isClosed) {
1868
- let err = new Error('Connection closed during TLS upgrade');
1869
- err.code = 'NoConnection';
1870
- return settle(err);
1849
+ return settle(this.createNoConnectionError(false, { rejectedFrom: 'tlsUpgrade' }));
1871
1850
  }
1872
1851
  /* c8 ignore stop */
1873
1852
 
@@ -2433,7 +2412,8 @@ class ImapFlow extends EventEmitter {
2433
2412
  }
2434
2413
  }
2435
2414
 
2436
- let connectPromise = new Promise((resolve, reject) => {
2415
+ // Guarded: close() rejects a pending connect() synchronously. See guardedPromise().
2416
+ let connectPromise = guardedPromise((resolve, reject) => {
2437
2417
  // Whatever the proxy phase already used is gone from the budget
2438
2418
  this.connectTimeout = setTimeout(() => {
2439
2419
  let err = deadline.error();
@@ -2533,10 +2513,6 @@ class ImapFlow extends EventEmitter {
2533
2513
  this.socket.on('error', this._connectErrorHandler);
2534
2514
  });
2535
2515
 
2536
- // Prevent unhandled promise rejection if close() rejects the connect
2537
- // promise synchronously. The rejection still propagates to the caller.
2538
- connectPromise.catch(noop);
2539
-
2540
2516
  await connectPromise;
2541
2517
  }
2542
2518
 
@@ -2563,11 +2539,29 @@ class ImapFlow extends EventEmitter {
2563
2539
  setImmediate(() => this.close());
2564
2540
  }
2565
2541
 
2566
- // Builds the standard "connection not available" error, optionally annotated with the
2567
- // server's BYE reason. Single source of truth so every NoConnection rejection is consistent.
2568
- createNoConnectionError(byeReason) {
2569
- const error = new Error('Connection not available');
2570
- error.code = 'NoConnection';
2542
+ // Builds an error describing a connection that is gone. Every such error is stamped here so
2543
+ // the stamping cannot drift between sites: the connection id always travels on it, and each
2544
+ // site names itself through `meta` (`rejectedFrom`, plus the command or mailbox path it
2545
+ // belongs to).
2546
+ //
2547
+ // A stack trace only records where an error was built, and close() hands a rejection to every
2548
+ // pending request and every queued lock in the same tick, so without these an error that
2549
+ // reaches a global unhandledRejection handler arrives with nothing that identifies the
2550
+ // connection it came from, let alone which of the rejected promises carried it.
2551
+ createConnectionError(code, message, meta) {
2552
+ const error = new Error(message);
2553
+ error.code = code;
2554
+ error.cid = this.id;
2555
+ if (meta) {
2556
+ Object.assign(error, meta);
2557
+ }
2558
+ return error;
2559
+ }
2560
+
2561
+ // The standard "connection not available" error, optionally annotated with the server's BYE
2562
+ // reason. Single source of truth so every NoConnection rejection is consistent.
2563
+ createNoConnectionError(byeReason, meta) {
2564
+ const error = this.createConnectionError('NoConnection', 'Connection not available', meta);
2571
2565
  if (byeReason) {
2572
2566
  error.reason = byeReason;
2573
2567
  }
@@ -2612,9 +2606,7 @@ class ImapFlow extends EventEmitter {
2612
2606
  if (typeof this._upgradeReject === 'function') {
2613
2607
  let reject = this._upgradeReject;
2614
2608
  this._upgradeReject = null;
2615
- let err = new Error('Connection closed during TLS upgrade');
2616
- err.code = 'NoConnection';
2617
- reject(err);
2609
+ reject(this.createNoConnectionError(false, { rejectedFrom: 'upgrade' }));
2618
2610
  }
2619
2611
 
2620
2612
  if (typeof this.initialReject === 'function' && !this.options.verifyOnly) {
@@ -2630,9 +2622,9 @@ class ImapFlow extends EventEmitter {
2630
2622
  if (this.byeReason) {
2631
2623
  err.reason = this.byeReason;
2632
2624
  }
2633
- // Synchronous rejection is safe: connectPromise.catch(noop) is already
2634
- // attached, so the rejection is observed immediately. close() is synchronous,
2635
- // so all cleanup completes before any microtask rejection handler runs.
2625
+ // Synchronous rejection is safe: connectPromise was built by guardedPromise(),
2626
+ // so the rejection is already observed. close() is synchronous, so all cleanup
2627
+ // completes before any microtask rejection handler runs.
2636
2628
  reject(err);
2637
2629
  }
2638
2630
 
@@ -2690,18 +2682,14 @@ class ImapFlow extends EventEmitter {
2690
2682
  }
2691
2683
  }
2692
2684
 
2693
- // Helper to create connection error (delegates to the shared builder)
2694
- const createNoConnectionError = byeReason => this.createNoConnectionError(byeReason);
2695
-
2696
- // Reject pending requests and locks synchronously. Each exec() and
2697
- // getMailboxLock() promise already has .catch(noop) attached, so the
2698
- // rejection is observed immediately and will not trigger
2699
- // unhandledRejection. close() is synchronous, so all remaining cleanup
2700
- // runs before any microtask rejection handler fires.
2685
+ // Reject pending requests and locks synchronously. Every promise rejected here was
2686
+ // built by guardedPromise(), so its rejection is already observed and cannot trigger
2687
+ // unhandledRejection. close() is synchronous, so all remaining cleanup runs before
2688
+ // any microtask rejection handler fires.
2701
2689
  let byeReason = this.byeReason;
2702
2690
 
2703
2691
  for (let request of pendingRequests) {
2704
- request.reject(createNoConnectionError(byeReason));
2692
+ request.reject(this.createNoConnectionError(byeReason, { rejectedFrom: 'pendingRequest', command: request.command }));
2705
2693
  }
2706
2694
 
2707
2695
  // Clear current lock - holder will see errors when they try operations.
@@ -2720,7 +2708,7 @@ class ImapFlow extends EventEmitter {
2720
2708
  lock.acquireTimer = null;
2721
2709
  }
2722
2710
  if (typeof lock.reject === 'function') {
2723
- lock.reject(createNoConnectionError(byeReason));
2711
+ lock.reject(this.createNoConnectionError(byeReason, { rejectedFrom: 'mailboxLock', path: lock.path }));
2724
2712
  }
2725
2713
  }
2726
2714
  }
@@ -2848,7 +2836,7 @@ class ImapFlow extends EventEmitter {
2848
2836
  /**
2849
2837
  * Returns current quota
2850
2838
  *
2851
- * @param {String} [path] Optional mailbox path if you want to check quota for specific folder
2839
+ * @param {string|array} [path] Optional mailbox path if you want to check quota for specific folder. If value is an array then it is joined using current delimiter symbols. Namespace prefix is added automatically if required.
2852
2840
  * @returns {Promise<QuotaResponse|Boolean>} Quota information or `false` if QUOTA extension is not supported or requested path does not exist
2853
2841
  *
2854
2842
  * @example
@@ -3101,7 +3089,7 @@ class ImapFlow extends EventEmitter {
3101
3089
  /**
3102
3090
  * Requests the status of the indicated mailbox. Only requested status values will be returned.
3103
3091
  *
3104
- * @param {String} path mailbox path to check for (unicode string)
3092
+ * @param {string|array} path mailbox path to check for (unicode string). If value is an array then it is joined using current delimiter symbols. Namespace prefix is added automatically if required.
3105
3093
  * @param {Object} query defines requested status items
3106
3094
  * @param {Boolean} query.messages if `true` request count of messages
3107
3095
  * @param {Boolean} query.recent if `true` request count of messages with \\Recent tag
@@ -3389,7 +3377,7 @@ class ImapFlow extends EventEmitter {
3389
3377
  /**
3390
3378
  * Appends a new message to a mailbox
3391
3379
  *
3392
- * @param {String} path Mailbox path to upload the message to (unicode string)
3380
+ * @param {string|array} path Mailbox path to upload the message to (unicode string). If value is an array then it is joined using current delimiter symbols. Namespace prefix is added automatically if required.
3393
3381
  * @param {string|Buffer} content RFC822 formatted email message
3394
3382
  * @param {string[]} [flags] an array of flags to be set for the uploaded message
3395
3383
  * @param {Date|string} [idate=now] internal date to be set for the message
@@ -3415,7 +3403,7 @@ class ImapFlow extends EventEmitter {
3415
3403
  * Copies messages from current mailbox to destination mailbox
3416
3404
  *
3417
3405
  * @param {SequenceString | Number[] | SearchObject} range Range of messages to copy
3418
- * @param {String} destination Mailbox path to copy the messages to
3406
+ * @param {string|array} destination Mailbox path to copy the messages to. If value is an array then it is joined using current delimiter symbols. Namespace prefix is added automatically if required.
3419
3407
  * @param {Object} [options]
3420
3408
  * @param {Boolean} [options.uid] If `true` then uses UID {@link SequenceString} instead of sequence numbers
3421
3409
  * @returns {Promise<CopyResponseObject>} info about copies messages
@@ -3439,7 +3427,7 @@ class ImapFlow extends EventEmitter {
3439
3427
  * Moves messages from current mailbox to destination mailbox
3440
3428
  *
3441
3429
  * @param {SequenceString | Number[] | SearchObject} range Range of messages to move
3442
- * @param {String} destination Mailbox path to move the messages to
3430
+ * @param {string|array} destination Mailbox path to move the messages to. If value is an array then it is joined using current delimiter symbols. Namespace prefix is added automatically if required.
3443
3431
  * @param {Object} [options]
3444
3432
  * @param {Boolean} [options.uid] If `true` then uses UID {@link SequenceString} instead of sequence numbers
3445
3433
  * @returns {Promise<CopyResponseObject>} info about moved messages
@@ -3720,9 +3708,7 @@ class ImapFlow extends EventEmitter {
3720
3708
  lastRes = res;
3721
3709
 
3722
3710
  if (this.isClosed || !this.socket || this.socket.destroyed) {
3723
- let error = new Error('Connection closed');
3724
- error.code = 'EConnectionClosed';
3725
- throw error;
3711
+ throw this.createConnectionError('EConnectionClosed', 'Connection closed', { rejectedFrom: 'fetchStream', command: 'FETCH' });
3726
3712
  }
3727
3713
 
3728
3714
  yield res.response;
@@ -4427,7 +4413,7 @@ class ImapFlow extends EventEmitter {
4427
4413
  }
4428
4414
 
4429
4415
  if (!this.socket || this.socket.destroyed) {
4430
- throw this.createNoConnectionError();
4416
+ throw this.createNoConnectionError(false, { rejectedFrom: 'noSocket', command });
4431
4417
  }
4432
4418
 
4433
4419
  clearTimeout(this.idleStartTimer);
@@ -4470,7 +4456,7 @@ class ImapFlow extends EventEmitter {
4470
4456
  }
4471
4457
 
4472
4458
  if (!this.socket || this.socket.destroyed) {
4473
- throw this.createNoConnectionError();
4459
+ throw this.createNoConnectionError(false, { rejectedFrom: 'noSocket', command });
4474
4460
  }
4475
4461
 
4476
4462
  let handler = this.commands.get(command);
@@ -4587,9 +4573,7 @@ class ImapFlow extends EventEmitter {
4587
4573
 
4588
4574
  if (!this.usable || !this.socket || this.socket.destroyed) {
4589
4575
  this.log.trace({ msg: 'Failed to acquire mailbox lock', path, lockId, idling: this.idling });
4590
- let error = new Error('Connection not available');
4591
- error.code = 'NoConnection';
4592
- reject(error);
4576
+ reject(this.createNoConnectionError(false, { rejectedFrom: 'mailboxLock', path }));
4593
4577
  continue; // Process next lock in queue
4594
4578
  }
4595
4579
 
@@ -4713,7 +4697,8 @@ class ImapFlow extends EventEmitter {
4713
4697
  : null
4714
4698
  });
4715
4699
 
4716
- let lockPromise = new Promise((resolve, reject) => {
4700
+ // Guarded: close() rejects every queued lock synchronously. See guardedPromise().
4701
+ let lockPromise = guardedPromise((resolve, reject) => {
4717
4702
  let lockEntry = { resolve, reject, path, options, lockId };
4718
4703
  this.locks.push(lockEntry);
4719
4704
 
@@ -4737,10 +4722,6 @@ class ImapFlow extends EventEmitter {
4737
4722
  this.processLocks().catch(err => reject(err));
4738
4723
  });
4739
4724
 
4740
- // Prevent unhandled promise rejection if close() rejects this lock
4741
- // synchronously. The rejection still propagates to the caller.
4742
- lockPromise.catch(noop);
4743
-
4744
4725
  return lockPromise;
4745
4726
  }
4746
4727
 
package/lib/tools.js CHANGED
@@ -69,7 +69,48 @@ class AuthenticationFailure extends Error {
69
69
  authenticationFailed = true;
70
70
  }
71
71
 
72
+ // Deliberate no-op, used as the observer a guarded promise attaches to its own rejection.
73
+ const noop = () => {};
74
+
72
75
  const tools = {
76
+ noop,
77
+
78
+ /**
79
+ * Creates a promise whose rejection is observed as soon as it exists.
80
+ *
81
+ * close() rejects every promise it owns - the in-flight and queued commands, the pending
82
+ * connect(), the queued mailbox locks, the waiters for an IDLE break - synchronously, from a
83
+ * socket event. A consumer that only reaches its `await` a microtask later has not attached a
84
+ * handler yet at the moment Node decides whether the rejection was observed, and the whole
85
+ * worker dies on the resulting unhandledRejection. The pre-attached observer settles that
86
+ * question; the rejection still propagates normally to whoever awaits the returned promise.
87
+ *
88
+ * Creation and guarding are one call because splitting them is what actually goes wrong: the
89
+ * guard was hand-attached at three of the four sites and the fourth (the IDLE-break waiter)
90
+ * went unguarded, on exactly the path a server BYE takes.
91
+ *
92
+ * @param {Function} executor - Promise executor, (resolve, reject) => {}
93
+ * @returns {Promise} The promise, with its rejection already observed
94
+ */
95
+ guardedPromise(executor) {
96
+ let promise = new Promise(executor);
97
+ promise.catch(noop);
98
+ return promise;
99
+ },
100
+
101
+ /**
102
+ * The already-rejected form of guardedPromise(), for a call that has to hand back a rejected
103
+ * promise rather than throw.
104
+ *
105
+ * @param {Error} error - Rejection reason
106
+ * @returns {Promise} Rejected promise, with its rejection already observed
107
+ */
108
+ guardedReject(error) {
109
+ let promise = Promise.reject(error);
110
+ promise.catch(noop);
111
+ return promise;
112
+ },
113
+
73
114
  /**
74
115
  * Detaches a background timer from the event loop, so it cannot keep the process alive on its
75
116
  * own. Applied to every background timer (auto-IDLE, IDLE restart, fallback polling, throttle
@@ -764,6 +805,17 @@ const tools = {
764
805
  return name;
765
806
  },
766
807
 
808
+ /**
809
+ * Decodes an ENVELOPE text field for display: encoded words first, then the
810
+ * surrounding quotes some servers leave in place.
811
+ *
812
+ * @param {String} value - Raw field value from an ENVELOPE response
813
+ * @returns {String} Decoded, unquoted text
814
+ */
815
+ decodeText(value) {
816
+ return tools.processName(libmime.decodeWords(value));
817
+ },
818
+
767
819
  /**
768
820
  * Parses a raw IMAP ENVELOPE response into a structured envelope object.
769
821
  *
@@ -795,14 +847,25 @@ const tools = {
795
847
  // throwing on the dereference and dropping the message
796
848
  return false;
797
849
  }
798
- let address = (getStrValue(addr[2]) || '') + '@' + (getStrValue(addr[3]) || '');
799
- if (address === '@') {
800
- address = '';
850
+
851
+ let name = tools.decodeText(getStrValue(addr[0]));
852
+ let mailbox = getStrValue(addr[2]) || '';
853
+ let host = getStrValue(addr[3]) || '';
854
+
855
+ if (!host) {
856
+ // RFC 9051 7.5.2: a NIL host field marks RFC 5322 group syntax, it is not
857
+ // an empty domain. A non-NIL mailbox then holds the group name phrase, a
858
+ // NIL one closes the group. Joining the fields anyway would invent an
859
+ // address that never appeared in the message, eg. "undisclosed-recipients@",
860
+ // so surface the group name as a display name and leave the address empty.
861
+ // End-of-group markers carry neither and the filter below drops them.
862
+ // The mirror case, a NIL mailbox with a host, is left alone on purpose:
863
+ // the grammar gives it no meaning, so a server sending it is simply
864
+ // malformed rather than signalling anything we could act on.
865
+ return { name: name || (mailbox && tools.decodeText(mailbox)), address: '' };
801
866
  }
802
- return {
803
- name: tools.processName(libmime.decodeWords(getStrValue(addr[0]))),
804
- address
805
- };
867
+
868
+ return { name, address: `${mailbox}@${host}` };
806
869
  })
807
870
  .filter(addr => addr && (addr.name || addr.address));
808
871
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "1.7.1",
3
+ "version": "1.7.3",
4
4
  "description": "IMAP Client for Node",
5
5
  "main": "lib/imap-flow.js",
6
6
  "types": "lib/imap-flow.d.ts",
@@ -32,7 +32,7 @@
32
32
  "@eslint/js": "10.0.1",
33
33
  "@types/node": "26.2.0",
34
34
  "c8": "12.0.0",
35
- "eslint": "10.8.1",
35
+ "eslint": "10.9.0",
36
36
  "eslint-config-nodemailer": "1.2.0",
37
37
  "eslint-config-prettier": "10.1.8",
38
38
  "grunt": "1.6.3",
@@ -5,10 +5,12 @@ const { ImapFlow } = require('../lib/imap-flow');
5
5
  // Helper: create a minimal mock context with the fetch() generator bound to it.
6
6
  // The `run` override controls how untagged FETCH responses are delivered.
7
7
  const createFetchContext = runOverride => ({
8
+ id: 'test-cid',
8
9
  mailbox: { path: 'INBOX', exists: 10 },
9
10
  isClosed: false,
10
11
  socket: { destroyed: false },
11
12
  resolveRange: async range => range,
13
+ createConnectionError: ImapFlow.prototype.createConnectionError,
12
14
  run: runOverride
13
15
  });
14
16
 
@@ -47,10 +47,12 @@ module.exports['Create imapflow instance with custom logger'] = async test => {
47
47
  // Helpers for testing prototype methods on a mock context
48
48
  // ---------------------------------------------------------------------------
49
49
  const createFetchContext = runOverride => ({
50
+ id: 'test-cid',
50
51
  mailbox: { path: 'INBOX', exists: 10 },
51
52
  isClosed: false,
52
53
  socket: { destroyed: false },
53
54
  resolveRange: async range => range,
55
+ createConnectionError: ImapFlow.prototype.createConnectionError,
54
56
  run: runOverride
55
57
  });
56
58
 
@@ -716,6 +716,25 @@ module.exports['Tools: processName with short quoted'] = test => {
716
716
  test.done();
717
717
  };
718
718
 
719
+ // ============================================
720
+ // decodeText tests
721
+ // ============================================
722
+
723
+ module.exports['Tools: decodeText decodes encoded words and strips quotes'] = test => {
724
+ test.equal(tools.decodeText('=?utf-8?Q?T=C3=B5nu?='), 'Tõnu');
725
+ test.equal(tools.decodeText('"=?utf-8?Q?T=C3=B5nu?="'), 'Tõnu');
726
+ test.equal(tools.decodeText('Plain Name'), 'Plain Name');
727
+ test.done();
728
+ };
729
+
730
+ module.exports['Tools: decodeText tolerates missing values'] = test => {
731
+ // getStrValue returns false for a NIL envelope field
732
+ test.equal(tools.decodeText(false), '');
733
+ test.equal(tools.decodeText(null), '');
734
+ test.equal(tools.decodeText(undefined), '');
735
+ test.done();
736
+ };
737
+
719
738
  // ============================================
720
739
  // getFolderTree tests
721
740
  // ============================================
@@ -885,6 +904,100 @@ module.exports['Tools: parseEnvelope with empty address parts'] = test => {
885
904
  test.done();
886
905
  };
887
906
 
907
+ module.exports['Tools: parseEnvelope keeps group syntax out of the address'] = test => {
908
+ // RFC 9051 7.5.2: a NIL host marks group syntax, so "undisclosed-recipients:;" must
909
+ // not turn into the invented address "undisclosed-recipients@"
910
+ let entry = [
911
+ null, // date
912
+ null, // subject
913
+ [], // from
914
+ [], // sender
915
+ [], // reply-to
916
+ [
917
+ [null, null, { value: 'undisclosed-recipients' }, null], // start of group
918
+ [null, null, null, null] // end of group
919
+ ], // to
920
+ [], // cc
921
+ [], // bcc
922
+ null, // in-reply-to
923
+ null // message-id
924
+ ];
925
+
926
+ let result = tools.parseEnvelope(entry);
927
+ // The end-of-group marker carries neither name nor address and is dropped
928
+ test.deepEqual(result.to, [{ name: 'undisclosed-recipients', address: '' }]);
929
+ test.done();
930
+ };
931
+
932
+ module.exports['Tools: parseEnvelope keeps group members alongside the markers'] = test => {
933
+ let entry = [
934
+ null, // date
935
+ null, // subject
936
+ [], // from
937
+ [], // sender
938
+ [], // reply-to
939
+ [
940
+ [null, null, { value: 'Team' }, null], // start of group
941
+ [{ value: 'Member One' }, null, { value: 'one' }, { value: 'example.com' }],
942
+ [{ value: 'Member Two' }, null, { value: 'two' }, { value: 'example.com' }],
943
+ [null, null, null, null] // end of group
944
+ ], // to
945
+ [], // cc
946
+ [], // bcc
947
+ null, // in-reply-to
948
+ null // message-id
949
+ ];
950
+
951
+ let result = tools.parseEnvelope(entry);
952
+ test.deepEqual(result.to, [
953
+ { name: 'Team', address: '' },
954
+ { name: 'Member One', address: 'one@example.com' },
955
+ { name: 'Member Two', address: 'two@example.com' }
956
+ ]);
957
+ test.done();
958
+ };
959
+
960
+ module.exports['Tools: parseEnvelope does not join a NIL host onto a mailbox'] = test => {
961
+ // Some servers parse a malformed header such as
962
+ // "To: user@example.com user@example.com" into a personal name plus a mailbox
963
+ // with a NIL host. Joining those produced the invalid address "example.com@".
964
+ let entry = [
965
+ null, // date
966
+ null, // subject
967
+ [], // from
968
+ [], // sender
969
+ [], // reply-to
970
+ [[{ value: 'user@example.com user@' }, null, { value: 'example.com' }, null]], // to
971
+ [], // cc
972
+ [], // bcc
973
+ null, // in-reply-to
974
+ null // message-id
975
+ ];
976
+
977
+ let result = tools.parseEnvelope(entry);
978
+ test.deepEqual(result.to, [{ name: 'user@example.com user@', address: '' }]);
979
+ test.done();
980
+ };
981
+
982
+ module.exports['Tools: parseEnvelope decodes an encoded group name'] = test => {
983
+ let entry = [
984
+ null, // date
985
+ null, // subject
986
+ [], // from
987
+ [], // sender
988
+ [], // reply-to
989
+ [[null, null, { value: '=?utf-8?Q?T=C3=B5ny?=' }, null]], // to
990
+ [], // cc
991
+ [], // bcc
992
+ null, // in-reply-to
993
+ null // message-id
994
+ ];
995
+
996
+ let result = tools.parseEnvelope(entry);
997
+ test.deepEqual(result.to, [{ name: 'Tõny', address: '' }]);
998
+ test.done();
999
+ };
1000
+
888
1001
  // ============================================
889
1002
  // getStructuredParams tests
890
1003
  // ============================================
@@ -408,6 +408,98 @@ exports['Unhandled Rejection Prevention'] = {
408
408
  });
409
409
  },
410
410
 
411
+ 'close() stamps the rejection site, command and connection id on the error'(test) {
412
+ test.expect(5);
413
+
414
+ const client = new ImapFlow({
415
+ host: '127.0.0.1',
416
+ port: 1,
417
+ secure: false,
418
+ logger: false,
419
+ id: 'test-cid'
420
+ });
421
+
422
+ client.state = client.states.AUTHENTICATED;
423
+ client.socket = new net.Socket();
424
+
425
+ const detector = installRejectionDetector(test);
426
+
427
+ let promise = client.exec('NOOP');
428
+ client.close();
429
+
430
+ setTimeout(() => {
431
+ detector.check();
432
+ promise.catch(err => {
433
+ test.equal(err.code, 'NoConnection', 'caller should receive NoConnection error');
434
+ test.equal(err.rejectedFrom, 'pendingRequest', 'error should name the rejection site');
435
+ test.equal(err.command, 'NOOP', 'error should name the command it belonged to');
436
+ test.equal(err.cid, 'test-cid', 'error should carry the connection id');
437
+ test.done();
438
+ });
439
+ }, 100);
440
+ },
441
+
442
+ 'preCheck() waiter rejected by close() should not cause unhandled rejection'(test) {
443
+ test.expect(2);
444
+
445
+ // Never acknowledge IDLE with a "+" continuation. preCheck() can only send DONE once
446
+ // the server has acknowledged, so its waiter stays queued until close() tears the
447
+ // IDLE command down and runIdle() rejects everything still waiting.
448
+ const server = createMockServer({
449
+ extraCapabilities: 'IDLE',
450
+ onCommand(socket, tag, command) {
451
+ if (command === 'IDLE') {
452
+ return true;
453
+ }
454
+ }
455
+ });
456
+
457
+ server.listen(0, '127.0.0.1', async () => {
458
+ const port = server.address().port;
459
+
460
+ const client = new ImapFlow({
461
+ host: '127.0.0.1',
462
+ port,
463
+ secure: false,
464
+ logger: false,
465
+ disableAutoIdle: true,
466
+ auth: {
467
+ user: 'test',
468
+ pass: 'test'
469
+ }
470
+ });
471
+
472
+ const detector = installRejectionDetector(test);
473
+
474
+ try {
475
+ await client.connect();
476
+ await client.mailboxOpen('INBOX');
477
+
478
+ client.idle().catch(() => {
479
+ // Expected: IDLE rejects when the connection is closed
480
+ });
481
+
482
+ // Let the IDLE command reach the server and install preCheck()
483
+ await new Promise(r => setTimeout(r, 100));
484
+ test.equal(typeof client.preCheck, 'function', 'IDLE should have installed preCheck()');
485
+
486
+ // Request an IDLE break and drop the returned promise on the floor, so the
487
+ // queued waiter has no handler of its own when close() rejects it
488
+ client.preCheck();
489
+
490
+ client.close();
491
+
492
+ await new Promise(r => setTimeout(r, 100));
493
+ detector.check();
494
+ } catch (err) {
495
+ detector.check();
496
+ test.ok(false, 'Unexpected error: ' + err.message);
497
+ } finally {
498
+ server.close(() => test.done());
499
+ }
500
+ });
501
+ },
502
+
411
503
  'BAD response to FETCH should not cause unhandled rejection (Death 2)'(test) {
412
504
  test.expect(3);
413
505