imapflow 1.7.2 → 1.7.4

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.2"
2
+ ".": "1.7.4"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.7.4](https://github.com/postalsys/imapflow/compare/v1.7.3...v1.7.4) (2026-08-24)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **search:** normalize search dates so invalid values cannot throw or emit NaN ([bf6c6bb](https://github.com/postalsys/imapflow/commit/bf6c6bb9c9e649ac9c28eba2c42f744b7d7d8e63)), closes [#385](https://github.com/postalsys/imapflow/issues/385)
9
+
10
+ ## [1.7.3](https://github.com/postalsys/imapflow/compare/v1.7.2...v1.7.3) (2026-08-24)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **errors:** make the rejection guard structural and stamp every connection error ([67a4afc](https://github.com/postalsys/imapflow/commit/67a4afcc06a2699ea327e98459590901f2efb24c))
16
+ * **errors:** name the rejection site on connection errors and guard the IDLE preCheck waiter ([55764f0](https://github.com/postalsys/imapflow/commit/55764f056f5aeb9771e493e9b659872f01ee7995))
17
+
3
18
  ## [1.7.2](https://github.com/postalsys/imapflow/compare/v1.7.1...v1.7.2) (2026-08-21)
4
19
 
5
20
 
@@ -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
 
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
  }
@@ -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
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  'use strict';
4
4
 
5
- const { formatDate, formatFlag, canUseFlag, isDate, isRev2Active } = require('./tools.js');
5
+ const { formatDate, formatFlag, canUseFlag, toValidDate, isRev2Active } = require('./tools.js');
6
6
 
7
7
  /**
8
8
  * Sets a boolean flag in the IMAP search attributes.
@@ -71,15 +71,25 @@ let setOpt = (attributes, term, value) => {
71
71
  *
72
72
  * @param {Array} attributes - Array to append the attribute to
73
73
  * @param {string} term - The date search term (e.g., 'BEFORE', 'SINCE')
74
- * @param {*} value - Date value to format
74
+ * @param {Date|String} value - Date value to format
75
75
  */
76
76
  let processDateField = (attributes, term, value) => {
77
- if (['BEFORE', 'SENTBEFORE'].includes(term.toUpperCase()) && isDate(value) && value.toISOString().substring(11) !== '00:00:00.000Z') {
77
+ // Normalize first. A Date brand check is not enough on its own: an invalid
78
+ // Date is still a Date and toISOString() throws on it. Normalizing here also
79
+ // means a date string behaves exactly like the equivalent Date object.
80
+ value = toValidDate(value);
81
+ if (!value) {
82
+ return;
83
+ }
84
+
85
+ if (['BEFORE', 'SENTBEFORE'].includes(term.toUpperCase()) && value.toISOString().substring(11) !== '00:00:00.000Z') {
78
86
  // Set to next day to include current day as well, othwerise BEFORE+AFTER
79
87
  // searches for the same day but different time values do not match anything
80
88
  value = new Date(value.getTime() + 24 * 3600 * 1000);
81
89
  }
82
90
 
91
+ // Still reachable after the guard above: the +24h shift can push a near-max
92
+ // Date past the representable range
83
93
  let date = formatDate(value);
84
94
  if (!date) {
85
95
  return;
@@ -338,18 +348,25 @@ module.exports.searchCompiler = (connection, query) => {
338
348
  case 'BEFORE':
339
349
  case 'SINCE':
340
350
  {
351
+ // Normalize above the capability check so the WITHIN shortcut
352
+ // and the standard path agree on what counts as a usable date
353
+ let value = toValidDate(params[term]);
354
+ if (!value) {
355
+ break;
356
+ }
357
+
341
358
  // Use WITHIN extension for better timezone handling if available
342
- if (connection.capabilities.has('WITHIN') && isDate(params[term])) {
359
+ if (connection.capabilities.has('WITHIN')) {
343
360
  // Convert to seconds ago from now
344
361
  const now = Date.now();
345
- const withinSeconds = Math.round(Math.max(0, now - params[term].getTime()) / 1000);
362
+ const withinSeconds = Math.round(Math.max(0, now - value.getTime()) / 1000);
346
363
  const withinKeyword = term.toUpperCase() === 'BEFORE' ? 'OLDER' : 'YOUNGER';
347
364
  setOpt(attributes, withinKeyword, withinSeconds.toString());
348
365
  break;
349
366
  }
350
367
 
351
368
  // Fallback to standard date search
352
- processDateField(attributes, term, params[term]);
369
+ processDateField(attributes, term, value);
353
370
  }
354
371
  break;
355
372
 
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "1.7.2",
3
+ "version": "1.7.4",
4
4
  "description": "IMAP Client for Node",
5
5
  "main": "lib/imap-flow.js",
6
6
  "types": "lib/imap-flow.d.ts",
@@ -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
 
@@ -710,6 +710,57 @@ module.exports['Search Compiler: Date with invalid value ignored'] = test => {
710
710
  test.done();
711
711
  };
712
712
 
713
+ module.exports['Search Compiler: all date keys ignore an invalid Date object'] = test => {
714
+ // An invalid Date is still a Date, so a plain Date brand check let it through:
715
+ // BEFORE/SENTBEFORE threw RangeError from toISOString(), and with WITHIN
716
+ // advertised BEFORE/SINCE compiled a literal "OLDER NaN"/"YOUNGER NaN" token
717
+ for (let capabilities of [[['IMAP4rev1', true]], [['WITHIN', true]]]) {
718
+ for (let key of ['before', 'since', 'on', 'sentBefore', 'sentOn', 'sentSince']) {
719
+ let connection = createMockConnection({ capabilities });
720
+ let compiled = searchCompiler(connection, { [key]: new Date('not-a-date') });
721
+
722
+ test.deepEqual(compiled, [], `${key} should compile to no attributes`);
723
+ }
724
+ }
725
+
726
+ test.done();
727
+ };
728
+
729
+ module.exports['Search Compiler: invalid date drops only its own criterion'] = test => {
730
+ let connection = createMockConnection();
731
+ let compiled = searchCompiler(connection, { seen: true, before: new Date('not-a-date') });
732
+
733
+ test.deepEqual(compiled, [{ type: 'ATOM', value: 'SEEN' }]);
734
+ test.done();
735
+ };
736
+
737
+ module.exports['Search Compiler: date string and Date object compile alike for BEFORE'] = test => {
738
+ let asDate = searchCompiler(createMockConnection(), { before: new Date('2023-06-15T12:30:00.000Z') });
739
+ let asString = searchCompiler(createMockConnection(), { before: '2023-06-15T12:30:00.000Z' });
740
+
741
+ // Both input forms are documented, so both must get the +24h shift that makes
742
+ // same-day BEFORE+SINCE ranges match. The string form used to skip it.
743
+ test.deepEqual(asString, asDate);
744
+ test.ok(hasAttr(asString, '16-Jun-2023'));
745
+ test.done();
746
+ };
747
+
748
+ module.exports['Search Compiler: date string and Date object compile alike for WITHIN'] = test => {
749
+ let connection = createMockConnection({ capabilities: [['WITHIN', true]] });
750
+ let recentDate = new Date(Date.now() - 3600 * 1000); // 1 hour ago
751
+ let asDate = searchCompiler(connection, { since: recentDate });
752
+ let asString = searchCompiler(connection, { since: recentDate.toISOString() });
753
+
754
+ // The string form used to skip the WITHIN shortcut and compile SINCE instead
755
+ test.ok(hasAttr(asDate, 'YOUNGER'));
756
+ test.ok(hasAttr(asString, 'YOUNGER'));
757
+ // The keyword atom is followed by its value token
758
+ let seconds = attrs => Number(attrs[1].value);
759
+ // Both are measured against Date.now() at compile time, so allow a small drift
760
+ test.ok(Math.abs(seconds(asString) - seconds(asDate)) <= 1);
761
+ test.done();
762
+ };
763
+
713
764
  // ============================================
714
765
  // KEYWORD tests
715
766
  // ============================================
@@ -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