imapflow 1.7.3 → 1.7.5
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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +14 -0
- package/lib/commands/idle.js +23 -2
- package/lib/imap-flow.js +3 -16
- package/lib/search-compiler.js +23 -6
- package/lib/tools.js +29 -0
- package/package.json +1 -1
- package/test/search-compiler-test.js +51 -0
- package/test/unhandled-rejection-test.js +62 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.7.5](https://github.com/postalsys/imapflow/compare/v1.7.4...v1.7.5) (2026-08-24)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **errors:** give an IDLE-break waiter its own rejection site ([9534551](https://github.com/postalsys/imapflow/commit/95345519fa32ee26bff239a2ac90f6b8ddccb559))
|
|
9
|
+
|
|
10
|
+
## [1.7.4](https://github.com/postalsys/imapflow/compare/v1.7.3...v1.7.4) (2026-08-24)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **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)
|
|
16
|
+
|
|
3
17
|
## [1.7.3](https://github.com/postalsys/imapflow/compare/v1.7.2...v1.7.3) (2026-08-24)
|
|
4
18
|
|
|
5
19
|
|
package/lib/commands/idle.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { guardedPromise, hasCapability, logConnectionError, unrefTimer } = require('../tools.js');
|
|
3
|
+
const { buildConnectionError, guardedPromise, hasCapability, logConnectionError, unrefTimer } = require('../tools.js');
|
|
4
4
|
|
|
5
5
|
const NOOP_INTERVAL = 2 * 60 * 1000;
|
|
6
6
|
|
|
@@ -27,6 +27,27 @@ function claimIdling(connection) {
|
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Builds the rejection a queued IDLE-break waiter receives when the IDLE command itself fails.
|
|
32
|
+
*
|
|
33
|
+
* The waiters are separate promises with separate consumers, so handing them the command's own
|
|
34
|
+
* error would report a waiter that escapes as `rejectedFrom: 'pendingRequest'` - the site where
|
|
35
|
+
* the IDLE command was rejected, not the site where the waiter was. That is precisely the
|
|
36
|
+
* attribution the markers exist to give. Every own field of the original is carried over, because
|
|
37
|
+
* a waiter rejection reaches callers through run() and they branch on `responseStatus`,
|
|
38
|
+
* `serverResponseCode` and friends; only the site marker differs, and the original travels on as
|
|
39
|
+
* `cause` so the command failure behind it stays visible.
|
|
40
|
+
*
|
|
41
|
+
* @param {Object} connection - IMAP connection instance
|
|
42
|
+
* @param {Error} err - The error that failed the IDLE command
|
|
43
|
+
* @returns {Error} Error stamped for the waiter site
|
|
44
|
+
*/
|
|
45
|
+
function preCheckWaiterError(connection, err) {
|
|
46
|
+
let error = buildConnectionError(connection.id, err.code, err.message, Object.assign({}, err, { rejectedFrom: 'preCheckWaiter' }));
|
|
47
|
+
error.cause = err;
|
|
48
|
+
return error;
|
|
49
|
+
}
|
|
50
|
+
|
|
30
51
|
/**
|
|
31
52
|
* Runs a single IDLE session on the connection.
|
|
32
53
|
*
|
|
@@ -140,7 +161,7 @@ async function runIdle(connection) {
|
|
|
140
161
|
logConnectionError(connection, 'IDLE session failed', err);
|
|
141
162
|
while (preCheckWaitQueue.length) {
|
|
142
163
|
let { reject } = preCheckWaitQueue.shift();
|
|
143
|
-
reject(err);
|
|
164
|
+
reject(preCheckWaiterError(connection, err));
|
|
144
165
|
}
|
|
145
166
|
return false;
|
|
146
167
|
} finally {
|
package/lib/imap-flow.js
CHANGED
|
@@ -43,6 +43,7 @@ const {
|
|
|
43
43
|
parseUintValue,
|
|
44
44
|
isUnsafeKey,
|
|
45
45
|
getStringList,
|
|
46
|
+
buildConnectionError,
|
|
46
47
|
guardedPromise,
|
|
47
48
|
guardedReject,
|
|
48
49
|
MAX_UINT32_DIGITS
|
|
@@ -2539,23 +2540,9 @@ class ImapFlow extends EventEmitter {
|
|
|
2539
2540
|
setImmediate(() => this.close());
|
|
2540
2541
|
}
|
|
2541
2542
|
|
|
2542
|
-
//
|
|
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.
|
|
2543
|
+
// Connection-scoped wrapper around the shared stamping helper; see buildConnectionError().
|
|
2551
2544
|
createConnectionError(code, message, meta) {
|
|
2552
|
-
|
|
2553
|
-
error.code = code;
|
|
2554
|
-
error.cid = this.id;
|
|
2555
|
-
if (meta) {
|
|
2556
|
-
Object.assign(error, meta);
|
|
2557
|
-
}
|
|
2558
|
-
return error;
|
|
2545
|
+
return buildConnectionError(this.id, code, message, meta);
|
|
2559
2546
|
}
|
|
2560
2547
|
|
|
2561
2548
|
// The standard "connection not available" error, optionally annotated with the server's BYE
|
package/lib/search-compiler.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
'use strict';
|
|
4
4
|
|
|
5
|
-
const { formatDate, formatFlag, canUseFlag,
|
|
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 {
|
|
74
|
+
* @param {Date|String} value - Date value to format
|
|
75
75
|
*/
|
|
76
76
|
let processDateField = (attributes, term, value) => {
|
|
77
|
-
|
|
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')
|
|
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 -
|
|
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,
|
|
369
|
+
processDateField(attributes, term, value);
|
|
353
370
|
}
|
|
354
371
|
break;
|
|
355
372
|
|
package/lib/tools.js
CHANGED
|
@@ -75,6 +75,35 @@ const noop = () => {};
|
|
|
75
75
|
const tools = {
|
|
76
76
|
noop,
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Builds an error describing a connection that is gone, stamped so it can be traced back to
|
|
80
|
+
* where it came from: the connection id always travels on it, and each site names itself
|
|
81
|
+
* through `meta` (`rejectedFrom`, plus the command or mailbox path it belongs to).
|
|
82
|
+
*
|
|
83
|
+
* A stack trace only records where an error was built, and close() hands a rejection to every
|
|
84
|
+
* pending request and every queued lock in the same tick, so without these an error that
|
|
85
|
+
* reaches a global unhandledRejection handler arrives with nothing that identifies the
|
|
86
|
+
* connection it came from, let alone which of the rejected promises carried it.
|
|
87
|
+
*
|
|
88
|
+
* Takes the connection id rather than the connection, so the stamping stays in one place
|
|
89
|
+
* without every caller having to be a full ImapFlow instance.
|
|
90
|
+
*
|
|
91
|
+
* @param {String} cid - Connection id
|
|
92
|
+
* @param {String} code - Error code, e.g. 'NoConnection'
|
|
93
|
+
* @param {String} message - Error message
|
|
94
|
+
* @param {Object} [meta] - Fields to stamp on the error
|
|
95
|
+
* @returns {Error} The stamped error
|
|
96
|
+
*/
|
|
97
|
+
buildConnectionError(cid, code, message, meta) {
|
|
98
|
+
const error = new Error(message);
|
|
99
|
+
error.code = code;
|
|
100
|
+
error.cid = cid;
|
|
101
|
+
if (meta) {
|
|
102
|
+
Object.assign(error, meta);
|
|
103
|
+
}
|
|
104
|
+
return error;
|
|
105
|
+
},
|
|
106
|
+
|
|
78
107
|
/**
|
|
79
108
|
* Creates a promise whose rejection is observed as soon as it exists.
|
|
80
109
|
*
|
package/package.json
CHANGED
|
@@ -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
|
// ============================================
|
|
@@ -439,6 +439,68 @@ exports['Unhandled Rejection Prevention'] = {
|
|
|
439
439
|
}, 100);
|
|
440
440
|
},
|
|
441
441
|
|
|
442
|
+
'a preCheck() waiter rejection names its own site, not the IDLE command'(test) {
|
|
443
|
+
test.expect(6);
|
|
444
|
+
|
|
445
|
+
// IDLE is never acknowledged with a "+", so preCheck() cannot send DONE and its waiter
|
|
446
|
+
// stays queued until close() tears the IDLE command down.
|
|
447
|
+
const server = createMockServer({
|
|
448
|
+
extraCapabilities: 'IDLE',
|
|
449
|
+
onCommand(socket, tag, command) {
|
|
450
|
+
if (command === 'IDLE') {
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
server.listen(0, '127.0.0.1', async () => {
|
|
457
|
+
const port = server.address().port;
|
|
458
|
+
|
|
459
|
+
const client = new ImapFlow({
|
|
460
|
+
host: '127.0.0.1',
|
|
461
|
+
port,
|
|
462
|
+
secure: false,
|
|
463
|
+
logger: false,
|
|
464
|
+
disableAutoIdle: true,
|
|
465
|
+
id: 'waiter-cid',
|
|
466
|
+
auth: {
|
|
467
|
+
user: 'test',
|
|
468
|
+
pass: 'test'
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
try {
|
|
473
|
+
await client.connect();
|
|
474
|
+
await client.mailboxOpen('INBOX');
|
|
475
|
+
|
|
476
|
+
client.idle().catch(() => {
|
|
477
|
+
// Expected: IDLE rejects when the connection is closed
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
await new Promise(r => setTimeout(r, 100));
|
|
481
|
+
|
|
482
|
+
let waiter = client.preCheck();
|
|
483
|
+
client.close();
|
|
484
|
+
|
|
485
|
+
try {
|
|
486
|
+
await waiter;
|
|
487
|
+
test.ok(false, 'the waiter should have rejected');
|
|
488
|
+
} catch (err) {
|
|
489
|
+
test.equal(err.rejectedFrom, 'preCheckWaiter', 'the waiter names its own rejection site');
|
|
490
|
+
test.equal(err.cid, 'waiter-cid', 'the waiter error carries the connection id');
|
|
491
|
+
test.equal(err.code, 'NoConnection', 'the original code is preserved for callers that branch on it');
|
|
492
|
+
test.ok(err.cause, 'the command failure travels on as the cause');
|
|
493
|
+
test.equal(err.cause.rejectedFrom, 'pendingRequest', 'the cause still names the command site');
|
|
494
|
+
test.equal(err.cause.command, 'IDLE', 'the cause still names the command');
|
|
495
|
+
}
|
|
496
|
+
} catch (err) {
|
|
497
|
+
test.ok(false, 'Unexpected error: ' + err.message);
|
|
498
|
+
} finally {
|
|
499
|
+
server.close(() => test.done());
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
},
|
|
503
|
+
|
|
442
504
|
'preCheck() waiter rejected by close() should not cause unhandled rejection'(test) {
|
|
443
505
|
test.expect(2);
|
|
444
506
|
|