imapflow 1.3.1 → 1.3.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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/lib/commands/namespace.js +9 -4
- package/lib/imap-flow.d.ts +22 -1
- package/lib/imap-flow.js +119 -13
- package/package.json +9 -9
- package/test/commands-integration-test.js +179 -0
- package/test/reliability-improvements-test.js +370 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.3.3](https://github.com/postalsys/imapflow/compare/v1.3.2...v1.3.3) (2026-04-29)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* handle NIL delimiter in NAMESPACE response (RFC 2342) ([#351](https://github.com/postalsys/imapflow/issues/351)) ([10bda2a](https://github.com/postalsys/imapflow/commit/10bda2a1d398dc7bc6ea0ede50b1ae48e4b28cf7))
|
|
9
|
+
|
|
10
|
+
## [1.3.2](https://github.com/postalsys/imapflow/compare/v1.3.1...v1.3.2) (2026-04-17)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* Bumped deps ([7b45f61](https://github.com/postalsys/imapflow/commit/7b45f6173b8e942d477612ade86296bac2bcaa79))
|
|
16
|
+
* harden mailbox-lock and error-propagation paths ([7b87d96](https://github.com/postalsys/imapflow/commit/7b87d96f280fa79b3e34b223ba3aa708f2e9bac1))
|
|
17
|
+
|
|
3
18
|
## [1.3.1](https://github.com/postalsys/imapflow/compare/v1.3.0...v1.3.1) (2026-04-08)
|
|
4
19
|
|
|
5
20
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Requests NAMESPACE info from the server.
|
|
5
5
|
*
|
|
6
6
|
* @param {Object} connection - IMAP connection instance
|
|
7
|
-
* @returns {Promise<{prefix: string, delimiter: string}|{error: boolean, status: string, text: string}>} The primary personal namespace, or an error object on failure
|
|
7
|
+
* @returns {Promise<{prefix: string, delimiter: string|null}|{error: boolean, status: string, text: string}>} The primary personal namespace, or an error object on failure
|
|
8
8
|
*/
|
|
9
9
|
module.exports = async connection => {
|
|
10
10
|
if (![connection.states.AUTHENTICATED, connection.states.SELECTED].includes(connection.state)) {
|
|
@@ -114,7 +114,7 @@ async function getListPrefix(connection) {
|
|
|
114
114
|
* Parses namespace information from an IMAP NAMESPACE response attribute.
|
|
115
115
|
*
|
|
116
116
|
* @param {Array} attribute - Namespace attribute array from the server response
|
|
117
|
-
* @returns {Array<{prefix: string, delimiter: string}>|boolean} Array of namespace entries, or false if empty
|
|
117
|
+
* @returns {Array<{prefix: string, delimiter: string|null}>|boolean} Array of namespace entries, or false if empty
|
|
118
118
|
*/
|
|
119
119
|
function getNamsepaceInfo(attribute) {
|
|
120
120
|
if (!attribute || !attribute.length) {
|
|
@@ -122,10 +122,15 @@ function getNamsepaceInfo(attribute) {
|
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
return attribute
|
|
125
|
-
.filter(
|
|
125
|
+
.filter(
|
|
126
|
+
entry =>
|
|
127
|
+
// RFC 2342 §5 allows the delimiter to be NIL when the namespace has
|
|
128
|
+
// no hierarchy. The token parser emits a literal `null` for NIL.
|
|
129
|
+
entry.length >= 2 && entry[0] && typeof entry[0].value === 'string' && (entry[1] === null || (entry[1] && typeof entry[1].value === 'string'))
|
|
130
|
+
)
|
|
126
131
|
.map(entry => {
|
|
127
132
|
let prefix = entry[0].value;
|
|
128
|
-
let delimiter = entry[1].value;
|
|
133
|
+
let delimiter = entry[1] === null ? null : entry[1].value;
|
|
129
134
|
|
|
130
135
|
// Append the delimiter to the prefix if it doesn't already end with one,
|
|
131
136
|
// so callers can construct full paths by simply concatenating prefix + name.
|
package/lib/imap-flow.d.ts
CHANGED
|
@@ -60,6 +60,12 @@ export interface ImapFlowOptions {
|
|
|
60
60
|
greetingTimeout?: number;
|
|
61
61
|
/** How long to wait for socket inactivity before timing out the connection. Defaults to 5 minutes */
|
|
62
62
|
socketTimeout?: number;
|
|
63
|
+
/**
|
|
64
|
+
* Threshold in milliseconds for warning that a mailbox lock has been held
|
|
65
|
+
* for a long time (diagnostic for forgotten release() calls). Defaults to
|
|
66
|
+
* 30 minutes. Set to 0 or false to disable.
|
|
67
|
+
*/
|
|
68
|
+
maxLockHoldTime?: number | false;
|
|
63
69
|
/** If true, uses TLS. If false, uses cleartext. If not set, upgrades to TLS if available */
|
|
64
70
|
doSTARTTLS?: boolean;
|
|
65
71
|
/** Custom instance ID string for logs */
|
|
@@ -549,6 +555,21 @@ export interface MailboxOpenOptions {
|
|
|
549
555
|
description?: string;
|
|
550
556
|
}
|
|
551
557
|
|
|
558
|
+
export interface MailboxLockOptions extends MailboxOpenOptions {
|
|
559
|
+
/**
|
|
560
|
+
* Optional timeout in milliseconds to wait for the lock to be granted.
|
|
561
|
+
* If the lock cannot be acquired within this time, the promise rejects
|
|
562
|
+
* with an error whose `code` is `'LockTimeout'`. Defaults to no timeout.
|
|
563
|
+
*/
|
|
564
|
+
acquireTimeout?: number;
|
|
565
|
+
/**
|
|
566
|
+
* Per-call override for the threshold after which a held lock triggers
|
|
567
|
+
* a warning log entry. Overrides the ImapFlow constructor option of the
|
|
568
|
+
* same name. Set to 0 or false to disable for this lock only.
|
|
569
|
+
*/
|
|
570
|
+
maxLockHoldTime?: number | false;
|
|
571
|
+
}
|
|
572
|
+
|
|
552
573
|
export interface ExpungeEvent {
|
|
553
574
|
/** Mailbox path */
|
|
554
575
|
path: string;
|
|
@@ -783,7 +804,7 @@ export class ImapFlow extends EventEmitter {
|
|
|
783
804
|
}>;
|
|
784
805
|
|
|
785
806
|
/** Opens a mailbox if not already open and returns a lock */
|
|
786
|
-
getMailboxLock(path: string | string[], options?:
|
|
807
|
+
getMailboxLock(path: string | string[], options?: MailboxLockOptions): Promise<MailboxLockObject>;
|
|
787
808
|
|
|
788
809
|
/** Returns byte counters for the current connection, optionally resets them */
|
|
789
810
|
stats(reset?: boolean): { sent: number; received: number };
|
package/lib/imap-flow.js
CHANGED
|
@@ -48,6 +48,12 @@ const UPGRADE_TIMEOUT = 10 * 1000;
|
|
|
48
48
|
|
|
49
49
|
const SOCKET_TIMEOUT = 5 * 60 * 1000;
|
|
50
50
|
|
|
51
|
+
// Default threshold for warning that a mailbox lock has been held for a long
|
|
52
|
+
// time. Intended to catch forgotten release() calls, not legitimate long ops
|
|
53
|
+
// (e.g. fetching hundreds of thousands of messages). Configurable via the
|
|
54
|
+
// ImapFlow constructor option `maxLockHoldTime`. Set to 0 or false to disable.
|
|
55
|
+
const HELD_LOCK_WARN_MS = 30 * 60 * 1000;
|
|
56
|
+
|
|
51
57
|
const states = {
|
|
52
58
|
NOT_AUTHENTICATED: 0x01,
|
|
53
59
|
AUTHENTICATED: 0x02,
|
|
@@ -670,7 +676,11 @@ class ImapFlow extends EventEmitter {
|
|
|
670
676
|
// 1. During IDLE or AUTHENTICATE, where a custom handler (onPlusTag) processes it
|
|
671
677
|
// 2. During literal data transfer, where we send the next queued literal chunk
|
|
672
678
|
if (parsed.tag === '+' && this.currentRequest && this.currentRequest.options && typeof this.currentRequest.options.onPlusTag === 'function') {
|
|
673
|
-
|
|
679
|
+
try {
|
|
680
|
+
await this.currentRequest.options.onPlusTag(parsed);
|
|
681
|
+
} catch (err) {
|
|
682
|
+
this.log.warn({ err, cid: this.id });
|
|
683
|
+
}
|
|
674
684
|
data.next();
|
|
675
685
|
continue;
|
|
676
686
|
}
|
|
@@ -688,7 +698,11 @@ class ImapFlow extends EventEmitter {
|
|
|
688
698
|
if (section && section.length && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
|
|
689
699
|
let sectionHandler = this.getSectionHandler(section[0].value.toUpperCase().trim());
|
|
690
700
|
if (sectionHandler) {
|
|
691
|
-
|
|
701
|
+
try {
|
|
702
|
+
await sectionHandler(section.slice(1));
|
|
703
|
+
} catch (err) {
|
|
704
|
+
this.log.warn({ err, cid: this.id });
|
|
705
|
+
}
|
|
692
706
|
}
|
|
693
707
|
}
|
|
694
708
|
|
|
@@ -1209,6 +1223,12 @@ class ImapFlow extends EventEmitter {
|
|
|
1209
1223
|
});
|
|
1210
1224
|
|
|
1211
1225
|
if (upgraded && this.expectCapabilityUpdate) {
|
|
1226
|
+
// After STARTTLS the server may advertise a different capability set
|
|
1227
|
+
// (e.g., LOGINDISABLED removed, new AUTH= methods). Clear the pre-TLS
|
|
1228
|
+
// map before re-fetching so stale capabilities cannot leak through
|
|
1229
|
+
// if the CAPABILITY response is delayed or absent.
|
|
1230
|
+
this.capabilities.clear();
|
|
1231
|
+
this.authCapabilities.clear();
|
|
1212
1232
|
await this.run('CAPABILITY');
|
|
1213
1233
|
}
|
|
1214
1234
|
|
|
@@ -1841,12 +1861,21 @@ class ImapFlow extends EventEmitter {
|
|
|
1841
1861
|
request.reject(createNoConnectionError(byeReason));
|
|
1842
1862
|
}
|
|
1843
1863
|
|
|
1844
|
-
// Clear current lock - holder will see errors when they try operations
|
|
1864
|
+
// Clear current lock - holder will see errors when they try operations.
|
|
1865
|
+
// Also clear the held-lock diagnostic timer so it doesn't fire post-close.
|
|
1866
|
+
if (this.currentLock && this.currentLock.heldWarnTimer) {
|
|
1867
|
+
clearTimeout(this.currentLock.heldWarnTimer);
|
|
1868
|
+
this.currentLock.heldWarnTimer = null;
|
|
1869
|
+
}
|
|
1845
1870
|
this.currentLock = false;
|
|
1846
1871
|
|
|
1847
1872
|
if (this.locks && this.locks.length) {
|
|
1848
1873
|
let pendingLocks = this.locks.splice(0); // Take all locks and clear the array
|
|
1849
1874
|
for (let lock of pendingLocks) {
|
|
1875
|
+
if (lock.acquireTimer) {
|
|
1876
|
+
clearTimeout(lock.acquireTimer);
|
|
1877
|
+
lock.acquireTimer = null;
|
|
1878
|
+
}
|
|
1850
1879
|
if (typeof lock.reject === 'function') {
|
|
1851
1880
|
lock.reject(createNoConnectionError(byeReason));
|
|
1852
1881
|
}
|
|
@@ -3180,6 +3209,15 @@ class ImapFlow extends EventEmitter {
|
|
|
3180
3209
|
if (meta.charset && !['ascii', 'usascii', 'utf8'].includes(meta.charset.toLowerCase().replace(/[^a-z0-9]+/g, ''))) {
|
|
3181
3210
|
try {
|
|
3182
3211
|
let decoder = getDecoder(meta.charset);
|
|
3212
|
+
// Safety listener attached first so the decoder always has at least
|
|
3213
|
+
// one 'error' listener. Prevents Node.js from throwing
|
|
3214
|
+
// ERR_UNHANDLED_ERROR if a later pipe setup step throws and leaves
|
|
3215
|
+
// the source-forwarding closure attached without a downstream
|
|
3216
|
+
// listener wired up. Any real listener the caller attaches still
|
|
3217
|
+
// fires in addition to this one.
|
|
3218
|
+
decoder.on('error', err => {
|
|
3219
|
+
this.log.warn({ err, charset: meta.charset, cid: this.id });
|
|
3220
|
+
});
|
|
3183
3221
|
output.on('error', err => {
|
|
3184
3222
|
decoder.emit('error', err);
|
|
3185
3223
|
});
|
|
@@ -3516,32 +3554,79 @@ class ImapFlow extends EventEmitter {
|
|
|
3516
3554
|
// Process all locks in queue until empty
|
|
3517
3555
|
let processedCount = 0;
|
|
3518
3556
|
while (this.locks.length > 0) {
|
|
3557
|
+
// Mutex invariant: at most one lock may be held at a time.
|
|
3558
|
+
// If a lock is already granted, stop processing; release() will
|
|
3559
|
+
// clear currentLock and reschedule us to pick up the next queued lock.
|
|
3560
|
+
if (this.currentLock) {
|
|
3561
|
+
break;
|
|
3562
|
+
}
|
|
3563
|
+
|
|
3519
3564
|
// Yield to event loop periodically to prevent CPU blocking
|
|
3520
3565
|
processedCount++;
|
|
3521
3566
|
if (processedCount % 5 === 0) {
|
|
3522
3567
|
await new Promise(resolve => setImmediate(resolve));
|
|
3523
3568
|
}
|
|
3524
3569
|
|
|
3570
|
+
const lock = this.locks.shift();
|
|
3571
|
+
const { resolve, reject, path, options, lockId } = lock;
|
|
3572
|
+
|
|
3573
|
+
// From here on the grant/reject path owns the outcome; the acquire
|
|
3574
|
+
// timer must not race with resolution.
|
|
3575
|
+
if (lock.acquireTimer) {
|
|
3576
|
+
clearTimeout(lock.acquireTimer);
|
|
3577
|
+
lock.acquireTimer = null;
|
|
3578
|
+
}
|
|
3579
|
+
|
|
3580
|
+
const armHeldTimer = () => {
|
|
3581
|
+
let threshold = Number(options.maxLockHoldTime ?? this.options.maxLockHoldTime ?? HELD_LOCK_WARN_MS);
|
|
3582
|
+
if (!threshold || threshold <= 0) {
|
|
3583
|
+
return;
|
|
3584
|
+
}
|
|
3585
|
+
lock.heldAt = Date.now();
|
|
3586
|
+
lock.heldWarnTimer = setTimeout(() => {
|
|
3587
|
+
lock.heldWarnTimer = null;
|
|
3588
|
+
this.log.warn({
|
|
3589
|
+
msg: 'Mailbox lock held for a long time',
|
|
3590
|
+
lockId: lock.lockId,
|
|
3591
|
+
path,
|
|
3592
|
+
heldFor: Date.now() - lock.heldAt,
|
|
3593
|
+
...(options.description && { description: options.description }),
|
|
3594
|
+
cid: this.id
|
|
3595
|
+
});
|
|
3596
|
+
}, threshold);
|
|
3597
|
+
};
|
|
3598
|
+
|
|
3599
|
+
// release() is captured per-lock. It must only clear this.currentLock
|
|
3600
|
+
// if the caller still owns it — otherwise a stale release (after a
|
|
3601
|
+
// disconnect replaced the lock, or a double-release from user code)
|
|
3602
|
+
// would clear the new holder's lock and allow concurrent access.
|
|
3525
3603
|
const release = () => {
|
|
3526
|
-
if (this.currentLock) {
|
|
3604
|
+
if (this.currentLock === lock) {
|
|
3605
|
+
if (lock.heldWarnTimer) {
|
|
3606
|
+
clearTimeout(lock.heldWarnTimer);
|
|
3607
|
+
lock.heldWarnTimer = null;
|
|
3608
|
+
}
|
|
3527
3609
|
this.log.trace({
|
|
3528
3610
|
msg: 'Mailbox lock released',
|
|
3529
|
-
lockId:
|
|
3611
|
+
lockId: lock.lockId,
|
|
3530
3612
|
path: this.mailbox && this.mailbox.path,
|
|
3531
3613
|
pending: this.locks.length,
|
|
3532
3614
|
idling: this.idling
|
|
3533
3615
|
});
|
|
3534
3616
|
this.currentLock = false;
|
|
3617
|
+
// Use setImmediate to avoid stack overflow
|
|
3618
|
+
setImmediate(() => {
|
|
3619
|
+
this.processLocks().catch(err => this.log.error({ err, cid: this.id }));
|
|
3620
|
+
});
|
|
3621
|
+
} else {
|
|
3622
|
+
this.log.trace({
|
|
3623
|
+
msg: 'Ignoring stale lock release',
|
|
3624
|
+
lockId: lock.lockId,
|
|
3625
|
+
cid: this.id
|
|
3626
|
+
});
|
|
3535
3627
|
}
|
|
3536
|
-
// Use setImmediate to avoid stack overflow
|
|
3537
|
-
setImmediate(() => {
|
|
3538
|
-
this.processLocks().catch(err => this.log.error({ err, cid: this.id }));
|
|
3539
|
-
});
|
|
3540
3628
|
};
|
|
3541
3629
|
|
|
3542
|
-
const lock = this.locks.shift();
|
|
3543
|
-
const { resolve, reject, path, options, lockId } = lock;
|
|
3544
|
-
|
|
3545
3630
|
if (!this.usable || !this.socket || this.socket.destroyed) {
|
|
3546
3631
|
this.log.trace({ msg: 'Failed to acquire mailbox lock', path, lockId, idling: this.idling });
|
|
3547
3632
|
let error = new Error('Connection not available');
|
|
@@ -3560,6 +3645,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3560
3645
|
...(options.description && { description: options.description })
|
|
3561
3646
|
});
|
|
3562
3647
|
this.currentLock = lock;
|
|
3648
|
+
armHeldTimer();
|
|
3563
3649
|
resolve({ path, release });
|
|
3564
3650
|
break; // Stop processing; next lock waits for release()
|
|
3565
3651
|
}
|
|
@@ -3575,6 +3661,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3575
3661
|
...(options.description && { description: options.description })
|
|
3576
3662
|
});
|
|
3577
3663
|
this.currentLock = lock;
|
|
3664
|
+
armHeldTimer();
|
|
3578
3665
|
resolve({ path, release });
|
|
3579
3666
|
break; // Wait for this lock to be released
|
|
3580
3667
|
} catch (err) {
|
|
@@ -3659,7 +3746,26 @@ class ImapFlow extends EventEmitter {
|
|
|
3659
3746
|
});
|
|
3660
3747
|
|
|
3661
3748
|
let lockPromise = new Promise((resolve, reject) => {
|
|
3662
|
-
|
|
3749
|
+
let lockEntry = { resolve, reject, path, options, lockId };
|
|
3750
|
+
this.locks.push(lockEntry);
|
|
3751
|
+
|
|
3752
|
+
// Opt-in acquire timeout: if the lock has not been granted within
|
|
3753
|
+
// acquireTimeout ms, remove it from the queue and reject. Only
|
|
3754
|
+
// affects queued (pending) locks — once granted, the timer is cleared.
|
|
3755
|
+
if (Number(options.acquireTimeout) > 0) {
|
|
3756
|
+
lockEntry.acquireTimer = setTimeout(() => {
|
|
3757
|
+
lockEntry.acquireTimer = null;
|
|
3758
|
+
const idx = this.locks.indexOf(lockEntry);
|
|
3759
|
+
if (idx !== -1) {
|
|
3760
|
+
this.locks.splice(idx, 1);
|
|
3761
|
+
let err = new Error('Timed out waiting for mailbox lock');
|
|
3762
|
+
err.code = 'LockTimeout';
|
|
3763
|
+
err.lockId = lockEntry.lockId;
|
|
3764
|
+
reject(err);
|
|
3765
|
+
}
|
|
3766
|
+
}, Number(options.acquireTimeout));
|
|
3767
|
+
}
|
|
3768
|
+
|
|
3663
3769
|
this.processLocks().catch(err => reject(err));
|
|
3664
3770
|
});
|
|
3665
3771
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "imapflow",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.3",
|
|
4
4
|
"description": "IMAP Client for Node",
|
|
5
5
|
"main": "lib/imap-flow.js",
|
|
6
6
|
"types": "lib/imap-flow.d.ts",
|
|
@@ -28,28 +28,28 @@
|
|
|
28
28
|
"homepage": "https://imapflow.com/",
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@eslint/js": "10.0.1",
|
|
31
|
-
"@types/node": "25.
|
|
31
|
+
"@types/node": "25.6.0",
|
|
32
32
|
"c8": "11.0.0",
|
|
33
|
-
"eslint": "10.2.
|
|
33
|
+
"eslint": "10.2.1",
|
|
34
34
|
"eslint-config-nodemailer": "1.2.0",
|
|
35
35
|
"eslint-config-prettier": "10.1.8",
|
|
36
|
-
"grunt": "1.6.
|
|
36
|
+
"grunt": "1.6.2",
|
|
37
37
|
"grunt-cli": "1.5.0",
|
|
38
38
|
"grunt-contrib-nodeunit": "5.0.0",
|
|
39
39
|
"grunt-eslint": "26.0.0",
|
|
40
|
-
"prettier": "3.8.
|
|
40
|
+
"prettier": "3.8.3",
|
|
41
41
|
"proxyquire": "^2.1.3",
|
|
42
|
-
"typescript": "6.0.
|
|
42
|
+
"typescript": "6.0.3"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@zone-eu/mailsplit": "5.4.
|
|
45
|
+
"@zone-eu/mailsplit": "5.4.9",
|
|
46
46
|
"encoding-japanese": "2.2.0",
|
|
47
47
|
"iconv-lite": "0.7.2",
|
|
48
48
|
"libbase64": "1.3.0",
|
|
49
49
|
"libmime": "5.3.8",
|
|
50
50
|
"libqp": "2.1.1",
|
|
51
|
-
"nodemailer": "8.0.
|
|
51
|
+
"nodemailer": "8.0.7",
|
|
52
52
|
"pino": "10.3.1",
|
|
53
|
-
"socks": "2.8.
|
|
53
|
+
"socks": "2.8.8"
|
|
54
54
|
}
|
|
55
55
|
}
|
|
@@ -6457,6 +6457,185 @@ module.exports['Commands: namespace handles NIL namespaces'] = async test => {
|
|
|
6457
6457
|
test.done();
|
|
6458
6458
|
};
|
|
6459
6459
|
|
|
6460
|
+
module.exports['Commands: namespace handles NIL delimiter (RFC 2342)'] = async test => {
|
|
6461
|
+
// RFC 2342 §5: NIL delimiter means the namespace has no hierarchy.
|
|
6462
|
+
// The token parser emits a literal `null` for NIL.
|
|
6463
|
+
|
|
6464
|
+
const connection = createMockConnection({
|
|
6465
|
+
state: 2,
|
|
6466
|
+
capabilities: new Map([['NAMESPACE', true]]),
|
|
6467
|
+
exec: async (cmd, args, opts) => {
|
|
6468
|
+
if (opts && opts.untagged && opts.untagged.NAMESPACE) {
|
|
6469
|
+
await opts.untagged.NAMESPACE({
|
|
6470
|
+
attributes: [
|
|
6471
|
+
[
|
|
6472
|
+
[{ value: '' }, { value: '/' }],
|
|
6473
|
+
[{ value: '#hidden' }, null]
|
|
6474
|
+
],
|
|
6475
|
+
null,
|
|
6476
|
+
null
|
|
6477
|
+
]
|
|
6478
|
+
});
|
|
6479
|
+
}
|
|
6480
|
+
return { next: () => {} };
|
|
6481
|
+
}
|
|
6482
|
+
});
|
|
6483
|
+
|
|
6484
|
+
const result = await namespaceCommand(connection);
|
|
6485
|
+
|
|
6486
|
+
// Guard against handler crash so a regression surfaces as a failed
|
|
6487
|
+
// assertion rather than fatal-halting the whole nodeunit suite.
|
|
6488
|
+
if (result && result.error) {
|
|
6489
|
+
test.ok(false, 'namespace command returned error sentinel — likely crashed on NIL delimiter');
|
|
6490
|
+
test.done();
|
|
6491
|
+
return;
|
|
6492
|
+
}
|
|
6493
|
+
if (!Array.isArray(connection.namespaces && connection.namespaces.personal)) {
|
|
6494
|
+
test.ok(false, 'connection.namespaces.personal is not an array (handler crashed mid-untagged-callback)');
|
|
6495
|
+
test.done();
|
|
6496
|
+
return;
|
|
6497
|
+
}
|
|
6498
|
+
|
|
6499
|
+
test.equal(connection.namespaces.personal.length, 2, 'both personal entries should be parsed');
|
|
6500
|
+
|
|
6501
|
+
test.equal(connection.namespaces.personal[0].prefix, '');
|
|
6502
|
+
test.equal(connection.namespaces.personal[0].delimiter, '/');
|
|
6503
|
+
|
|
6504
|
+
// NIL-delimiter entry preserved with delimiter:null rather than dropped silently.
|
|
6505
|
+
test.equal(connection.namespaces.personal[1].prefix, '#hidden');
|
|
6506
|
+
test.equal(connection.namespaces.personal[1].delimiter, null);
|
|
6507
|
+
|
|
6508
|
+
// Default namespace pointer resolves to the first valid personal entry.
|
|
6509
|
+
test.equal(connection.namespace.prefix, '');
|
|
6510
|
+
test.equal(connection.namespace.delimiter, '/');
|
|
6511
|
+
|
|
6512
|
+
test.done();
|
|
6513
|
+
};
|
|
6514
|
+
|
|
6515
|
+
module.exports['Commands: namespace handles NIL delimiter as only personal entry'] = async test => {
|
|
6516
|
+
// Edge case: only a NIL-delimiter entry in the personal section.
|
|
6517
|
+
// connection.namespace must still be set and usable downstream.
|
|
6518
|
+
|
|
6519
|
+
const connection = createMockConnection({
|
|
6520
|
+
state: 2,
|
|
6521
|
+
capabilities: new Map([['NAMESPACE', true]]),
|
|
6522
|
+
exec: async (cmd, args, opts) => {
|
|
6523
|
+
if (opts && opts.untagged && opts.untagged.NAMESPACE) {
|
|
6524
|
+
await opts.untagged.NAMESPACE({
|
|
6525
|
+
attributes: [[[{ value: '#hidden' }, null]], null, null]
|
|
6526
|
+
});
|
|
6527
|
+
}
|
|
6528
|
+
return { next: () => {} };
|
|
6529
|
+
}
|
|
6530
|
+
});
|
|
6531
|
+
|
|
6532
|
+
const result = await namespaceCommand(connection);
|
|
6533
|
+
|
|
6534
|
+
if (result && result.error) {
|
|
6535
|
+
test.ok(false, 'namespace command returned error sentinel — likely crashed on NIL delimiter');
|
|
6536
|
+
test.done();
|
|
6537
|
+
return;
|
|
6538
|
+
}
|
|
6539
|
+
if (!Array.isArray(connection.namespaces && connection.namespaces.personal)) {
|
|
6540
|
+
test.ok(false, 'connection.namespaces.personal is not an array (handler crashed mid-untagged-callback)');
|
|
6541
|
+
test.done();
|
|
6542
|
+
return;
|
|
6543
|
+
}
|
|
6544
|
+
|
|
6545
|
+
test.equal(connection.namespaces.personal.length, 1);
|
|
6546
|
+
test.equal(connection.namespaces.personal[0].prefix, '#hidden');
|
|
6547
|
+
test.equal(connection.namespaces.personal[0].delimiter, null);
|
|
6548
|
+
|
|
6549
|
+
test.ok(connection.namespace);
|
|
6550
|
+
test.equal(connection.namespace.prefix, '#hidden');
|
|
6551
|
+
test.equal(connection.namespace.delimiter, null);
|
|
6552
|
+
|
|
6553
|
+
test.done();
|
|
6554
|
+
};
|
|
6555
|
+
|
|
6556
|
+
module.exports['Commands: namespace handles NIL delimiter in other and shared sections'] = async test => {
|
|
6557
|
+
// NIL delimiter is also valid in the `other` and `shared` sections,
|
|
6558
|
+
// not just `personal`.
|
|
6559
|
+
|
|
6560
|
+
const connection = createMockConnection({
|
|
6561
|
+
state: 2,
|
|
6562
|
+
capabilities: new Map([['NAMESPACE', true]]),
|
|
6563
|
+
exec: async (cmd, args, opts) => {
|
|
6564
|
+
if (opts && opts.untagged && opts.untagged.NAMESPACE) {
|
|
6565
|
+
await opts.untagged.NAMESPACE({
|
|
6566
|
+
attributes: [[[{ value: '' }, { value: '/' }]], [[{ value: 'Other Users/' }, null]], [[{ value: 'Public/' }, null]]]
|
|
6567
|
+
});
|
|
6568
|
+
}
|
|
6569
|
+
return { next: () => {} };
|
|
6570
|
+
}
|
|
6571
|
+
});
|
|
6572
|
+
|
|
6573
|
+
const result = await namespaceCommand(connection);
|
|
6574
|
+
|
|
6575
|
+
if (result && result.error) {
|
|
6576
|
+
test.ok(false, 'namespace command returned error sentinel — likely crashed on NIL delimiter');
|
|
6577
|
+
test.done();
|
|
6578
|
+
return;
|
|
6579
|
+
}
|
|
6580
|
+
|
|
6581
|
+
test.ok(Array.isArray(connection.namespaces.other), 'other should be an array');
|
|
6582
|
+
test.equal(connection.namespaces.other.length, 1);
|
|
6583
|
+
test.equal(connection.namespaces.other[0].prefix, 'Other Users/');
|
|
6584
|
+
test.equal(connection.namespaces.other[0].delimiter, null);
|
|
6585
|
+
|
|
6586
|
+
test.ok(Array.isArray(connection.namespaces.shared), 'shared should be an array');
|
|
6587
|
+
test.equal(connection.namespaces.shared.length, 1);
|
|
6588
|
+
test.equal(connection.namespaces.shared[0].prefix, 'Public/');
|
|
6589
|
+
test.equal(connection.namespaces.shared[0].delimiter, null);
|
|
6590
|
+
|
|
6591
|
+
test.done();
|
|
6592
|
+
};
|
|
6593
|
+
|
|
6594
|
+
module.exports['Commands: namespace skips malformed entries without crashing'] = async test => {
|
|
6595
|
+
// The filter must reject entries that don't match either the
|
|
6596
|
+
// (string-prefix, string-delimiter) or (string-prefix, NIL) shape,
|
|
6597
|
+
// so a single broken entry from a buggy server doesn't poison
|
|
6598
|
+
// the whole namespace list or crash the handler.
|
|
6599
|
+
|
|
6600
|
+
const connection = createMockConnection({
|
|
6601
|
+
state: 2,
|
|
6602
|
+
capabilities: new Map([['NAMESPACE', true]]),
|
|
6603
|
+
exec: async (cmd, args, opts) => {
|
|
6604
|
+
if (opts && opts.untagged && opts.untagged.NAMESPACE) {
|
|
6605
|
+
await opts.untagged.NAMESPACE({
|
|
6606
|
+
attributes: [
|
|
6607
|
+
[
|
|
6608
|
+
[{ value: '' }, { value: '/' }], // valid
|
|
6609
|
+
[{ value: '#hidden' }, null], // valid (NIL delimiter)
|
|
6610
|
+
[{ value: 'broken' }], // too short — rejected
|
|
6611
|
+
[null, { value: '/' }], // null prefix — rejected
|
|
6612
|
+
[{ value: 123 }, { value: '/' }] // non-string prefix — rejected
|
|
6613
|
+
],
|
|
6614
|
+
null,
|
|
6615
|
+
null
|
|
6616
|
+
]
|
|
6617
|
+
});
|
|
6618
|
+
}
|
|
6619
|
+
return { next: () => {} };
|
|
6620
|
+
}
|
|
6621
|
+
});
|
|
6622
|
+
|
|
6623
|
+
const result = await namespaceCommand(connection);
|
|
6624
|
+
|
|
6625
|
+
if (result && result.error) {
|
|
6626
|
+
test.ok(false, 'namespace command returned error sentinel — likely crashed on malformed entry');
|
|
6627
|
+
test.done();
|
|
6628
|
+
return;
|
|
6629
|
+
}
|
|
6630
|
+
|
|
6631
|
+
test.equal(connection.namespaces.personal.length, 2, 'only the two well-formed entries should be kept');
|
|
6632
|
+
test.equal(connection.namespaces.personal[0].prefix, '');
|
|
6633
|
+
test.equal(connection.namespaces.personal[1].prefix, '#hidden');
|
|
6634
|
+
test.equal(connection.namespaces.personal[1].delimiter, null);
|
|
6635
|
+
|
|
6636
|
+
test.done();
|
|
6637
|
+
};
|
|
6638
|
+
|
|
6460
6639
|
module.exports['Commands: namespace handles multiple personal namespaces'] = async test => {
|
|
6461
6640
|
const connection = createMockConnection({
|
|
6462
6641
|
state: 2,
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Tests for reliability/stability improvements: lock identity, acquireTimeout,
|
|
4
|
+
// maxLockHoldTime diagnostic, capability-clear-on-STARTTLS, and related
|
|
5
|
+
// cleanup paths.
|
|
6
|
+
|
|
7
|
+
const { ImapFlow } = require('../lib/imap-flow');
|
|
8
|
+
const iconv = require('iconv-lite');
|
|
9
|
+
|
|
10
|
+
// Helper: client with stubbed socket/usable state so the fast path of
|
|
11
|
+
// getMailboxLock() can grant locks synchronously (no network I/O).
|
|
12
|
+
const makeClient = (overrides = {}) => {
|
|
13
|
+
let client = new ImapFlow({
|
|
14
|
+
host: 'imap.example.com',
|
|
15
|
+
port: 993,
|
|
16
|
+
auth: { user: 'test', pass: 'test' },
|
|
17
|
+
logger: false,
|
|
18
|
+
...overrides
|
|
19
|
+
});
|
|
20
|
+
client.socket = { destroyed: false, destroy: () => {} };
|
|
21
|
+
client.usable = true;
|
|
22
|
+
client.mailbox = { path: 'INBOX', readOnly: false };
|
|
23
|
+
return client;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Helper: wait for any queued setImmediate callbacks (processLocks reschedules
|
|
27
|
+
// itself via setImmediate after a release).
|
|
28
|
+
const drain = () => new Promise(resolve => setImmediate(resolve));
|
|
29
|
+
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// release() identity check
|
|
32
|
+
// ============================================================================
|
|
33
|
+
|
|
34
|
+
module.exports['Reliability: stale release() does not clear replacement lock'] = async test => {
|
|
35
|
+
let client = makeClient();
|
|
36
|
+
|
|
37
|
+
// Grant L1 via fast path
|
|
38
|
+
let lockA = await client.getMailboxLock('INBOX');
|
|
39
|
+
let staleRelease = lockA.release;
|
|
40
|
+
|
|
41
|
+
// Release L1 normally
|
|
42
|
+
staleRelease();
|
|
43
|
+
await drain();
|
|
44
|
+
test.equal(client.currentLock, false, 'L1 released');
|
|
45
|
+
|
|
46
|
+
// Grant L2
|
|
47
|
+
let lockB = await client.getMailboxLock('INBOX');
|
|
48
|
+
test.ok(client.currentLock, 'L2 active');
|
|
49
|
+
let currentBeforeStale = client.currentLock;
|
|
50
|
+
|
|
51
|
+
// Stale call — must NOT clear L2's hold
|
|
52
|
+
staleRelease();
|
|
53
|
+
test.equal(client.currentLock, currentBeforeStale, 'L2 still active after stale release');
|
|
54
|
+
|
|
55
|
+
lockB.release();
|
|
56
|
+
await drain();
|
|
57
|
+
test.done();
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
module.exports['Reliability: double release() is idempotent'] = async test => {
|
|
61
|
+
let client = makeClient();
|
|
62
|
+
|
|
63
|
+
let lock = await client.getMailboxLock('INBOX');
|
|
64
|
+
|
|
65
|
+
// First release clears
|
|
66
|
+
lock.release();
|
|
67
|
+
await drain();
|
|
68
|
+
test.equal(client.currentLock, false);
|
|
69
|
+
|
|
70
|
+
// Second release is a no-op — must not throw, must not affect state
|
|
71
|
+
test.doesNotThrow(() => lock.release());
|
|
72
|
+
test.equal(client.currentLock, false);
|
|
73
|
+
|
|
74
|
+
test.done();
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// ============================================================================
|
|
78
|
+
// acquireTimeout
|
|
79
|
+
// ============================================================================
|
|
80
|
+
|
|
81
|
+
module.exports['Reliability: acquireTimeout rejects with LockTimeout code'] = async test => {
|
|
82
|
+
let client = makeClient();
|
|
83
|
+
|
|
84
|
+
// Hold the first lock so the next one queues
|
|
85
|
+
let lockA = await client.getMailboxLock('INBOX');
|
|
86
|
+
|
|
87
|
+
let start = Date.now();
|
|
88
|
+
try {
|
|
89
|
+
await client.getMailboxLock('INBOX', { acquireTimeout: 30 });
|
|
90
|
+
test.ok(false, 'Should have timed out');
|
|
91
|
+
} catch (err) {
|
|
92
|
+
let elapsed = Date.now() - start;
|
|
93
|
+
test.equal(err.code, 'LockTimeout');
|
|
94
|
+
test.ok(err.message.includes('Timed out'));
|
|
95
|
+
test.ok(typeof err.lockId === 'number');
|
|
96
|
+
test.ok(elapsed >= 25, `expected to wait ~30ms, got ${elapsed}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Original lock must still be held
|
|
100
|
+
test.ok(client.currentLock, 'L1 still held after L2 timeout');
|
|
101
|
+
|
|
102
|
+
lockA.release();
|
|
103
|
+
await drain();
|
|
104
|
+
test.done();
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
module.exports['Reliability: acquireTimeout cleared when lock is granted'] = async test => {
|
|
108
|
+
let client = makeClient();
|
|
109
|
+
|
|
110
|
+
// Fast path grants immediately; timer never fires
|
|
111
|
+
let lock = await client.getMailboxLock('INBOX', { acquireTimeout: 50 });
|
|
112
|
+
test.ok(lock);
|
|
113
|
+
test.ok(client.currentLock, 'L1 granted');
|
|
114
|
+
|
|
115
|
+
// Wait past the timeout — no rejection should occur post-grant, no dangling timer
|
|
116
|
+
await new Promise(r => setTimeout(r, 80));
|
|
117
|
+
test.ok(client.currentLock, 'Still held after timer would have fired');
|
|
118
|
+
|
|
119
|
+
lock.release();
|
|
120
|
+
await drain();
|
|
121
|
+
test.done();
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
module.exports['Reliability: acquireTimeout cleared on close()'] = async test => {
|
|
125
|
+
let client = makeClient();
|
|
126
|
+
// Hold L1 so L2 queues
|
|
127
|
+
let lockA = await client.getMailboxLock('INBOX');
|
|
128
|
+
|
|
129
|
+
let rejectedCode = null;
|
|
130
|
+
client.getMailboxLock('INBOX', { acquireTimeout: 10_000 }).catch(err => {
|
|
131
|
+
rejectedCode = err.code;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Immediately close — pending lock should reject with NoConnection (not LockTimeout),
|
|
135
|
+
// and its acquireTimer should be cleared so it never fires afterward.
|
|
136
|
+
client.close();
|
|
137
|
+
|
|
138
|
+
await drain();
|
|
139
|
+
await new Promise(r => setTimeout(r, 30));
|
|
140
|
+
test.equal(rejectedCode, 'NoConnection', 'Pending lock rejects with NoConnection on close');
|
|
141
|
+
|
|
142
|
+
// Keep reference so linter doesn't complain
|
|
143
|
+
test.ok(lockA);
|
|
144
|
+
test.done();
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// ============================================================================
|
|
148
|
+
// maxLockHoldTime diagnostic
|
|
149
|
+
// ============================================================================
|
|
150
|
+
|
|
151
|
+
module.exports['Reliability: maxLockHoldTime warning fires when lock held past threshold'] = async test => {
|
|
152
|
+
let warnings = [];
|
|
153
|
+
let client = makeClient();
|
|
154
|
+
client.log.warn = obj => warnings.push(obj);
|
|
155
|
+
|
|
156
|
+
let lock = await client.getMailboxLock('INBOX', { maxLockHoldTime: 30 });
|
|
157
|
+
await new Promise(r => setTimeout(r, 60));
|
|
158
|
+
|
|
159
|
+
let hit = warnings.find(w => w && w.msg === 'Mailbox lock held for a long time');
|
|
160
|
+
test.ok(hit, 'Warning log must fire');
|
|
161
|
+
test.ok(typeof hit.heldFor === 'number' && hit.heldFor >= 25);
|
|
162
|
+
|
|
163
|
+
lock.release();
|
|
164
|
+
await drain();
|
|
165
|
+
test.done();
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
module.exports['Reliability: maxLockHoldTime=0 disables the warning'] = async test => {
|
|
169
|
+
let warnings = [];
|
|
170
|
+
let client = makeClient();
|
|
171
|
+
client.log.warn = obj => warnings.push(obj);
|
|
172
|
+
|
|
173
|
+
let lock = await client.getMailboxLock('INBOX', { maxLockHoldTime: 0 });
|
|
174
|
+
await new Promise(r => setTimeout(r, 30));
|
|
175
|
+
|
|
176
|
+
test.ok(!warnings.some(w => w && w.msg === 'Mailbox lock held for a long time'), 'No warn when disabled');
|
|
177
|
+
|
|
178
|
+
lock.release();
|
|
179
|
+
await drain();
|
|
180
|
+
test.done();
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
module.exports['Reliability: maxLockHoldTime=false disables the warning'] = async test => {
|
|
184
|
+
let warnings = [];
|
|
185
|
+
let client = makeClient();
|
|
186
|
+
client.log.warn = obj => warnings.push(obj);
|
|
187
|
+
|
|
188
|
+
let lock = await client.getMailboxLock('INBOX', { maxLockHoldTime: false });
|
|
189
|
+
await new Promise(r => setTimeout(r, 30));
|
|
190
|
+
|
|
191
|
+
test.ok(!warnings.some(w => w && w.msg === 'Mailbox lock held for a long time'));
|
|
192
|
+
|
|
193
|
+
lock.release();
|
|
194
|
+
await drain();
|
|
195
|
+
test.done();
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
module.exports['Reliability: per-call maxLockHoldTime overrides constructor option'] = async test => {
|
|
199
|
+
let warnings = [];
|
|
200
|
+
// Constructor sets a long threshold; per-call sets a short one
|
|
201
|
+
let client = makeClient({ maxLockHoldTime: 10_000 });
|
|
202
|
+
client.log.warn = obj => warnings.push(obj);
|
|
203
|
+
|
|
204
|
+
let lock = await client.getMailboxLock('INBOX', { maxLockHoldTime: 20 });
|
|
205
|
+
await new Promise(r => setTimeout(r, 50));
|
|
206
|
+
|
|
207
|
+
test.ok(warnings.some(w => w && w.msg === 'Mailbox lock held for a long time'), 'Per-call override must take effect');
|
|
208
|
+
|
|
209
|
+
lock.release();
|
|
210
|
+
await drain();
|
|
211
|
+
test.done();
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
module.exports['Reliability: held-lock timer cleared on release (does not fire after)'] = async test => {
|
|
215
|
+
let warnings = [];
|
|
216
|
+
let client = makeClient();
|
|
217
|
+
client.log.warn = obj => warnings.push(obj);
|
|
218
|
+
|
|
219
|
+
let lock = await client.getMailboxLock('INBOX', { maxLockHoldTime: 50 });
|
|
220
|
+
lock.release();
|
|
221
|
+
await drain();
|
|
222
|
+
|
|
223
|
+
// Wait longer than the threshold — no warning should appear because release cleared it
|
|
224
|
+
await new Promise(r => setTimeout(r, 80));
|
|
225
|
+
test.ok(!warnings.some(w => w && w.msg === 'Mailbox lock held for a long time'));
|
|
226
|
+
|
|
227
|
+
test.done();
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
module.exports['Reliability: held-lock timer cleared on close()'] = async test => {
|
|
231
|
+
let warnings = [];
|
|
232
|
+
let client = makeClient();
|
|
233
|
+
client.log.warn = obj => warnings.push(obj);
|
|
234
|
+
|
|
235
|
+
let lock = await client.getMailboxLock('INBOX', { maxLockHoldTime: 30 });
|
|
236
|
+
test.ok(lock);
|
|
237
|
+
client.close();
|
|
238
|
+
|
|
239
|
+
await new Promise(r => setTimeout(r, 60));
|
|
240
|
+
test.ok(!warnings.some(w => w && w.msg === 'Mailbox lock held for a long time'));
|
|
241
|
+
|
|
242
|
+
test.done();
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
// ============================================================================
|
|
246
|
+
// STARTTLS capability reset
|
|
247
|
+
// ============================================================================
|
|
248
|
+
|
|
249
|
+
module.exports['Reliability: STARTTLS code path clears capabilities before re-fetch'] = async test => {
|
|
250
|
+
// Verify the clear() calls happen by driving the same branch directly.
|
|
251
|
+
let client = new ImapFlow({
|
|
252
|
+
host: 'imap.example.com',
|
|
253
|
+
port: 993,
|
|
254
|
+
auth: { user: 'test', pass: 'test' },
|
|
255
|
+
logger: false
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
client.capabilities.set('LOGINDISABLED', true);
|
|
259
|
+
client.capabilities.set('STARTTLS', true);
|
|
260
|
+
client.authCapabilities.set('AUTH=PLAIN', false);
|
|
261
|
+
|
|
262
|
+
// Stub the run() that would re-fetch CAPABILITY post-TLS
|
|
263
|
+
let ran = [];
|
|
264
|
+
client.run = async command => {
|
|
265
|
+
ran.push(command);
|
|
266
|
+
return true;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
client.expectCapabilityUpdate = true;
|
|
270
|
+
// Execute the same statements that the STARTTLS-success branch runs
|
|
271
|
+
// (guards the behavior contract: caches are cleared before re-fetch)
|
|
272
|
+
if (client.expectCapabilityUpdate) {
|
|
273
|
+
client.capabilities.clear();
|
|
274
|
+
client.authCapabilities.clear();
|
|
275
|
+
await client.run('CAPABILITY');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
test.equal(client.capabilities.size, 0, 'capabilities map cleared');
|
|
279
|
+
test.equal(client.authCapabilities.size, 0, 'authCapabilities map cleared');
|
|
280
|
+
test.deepEqual(ran, ['CAPABILITY']);
|
|
281
|
+
|
|
282
|
+
test.done();
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// ============================================================================
|
|
286
|
+
// Handler try/catch (smoke tests via direct invocation)
|
|
287
|
+
// ============================================================================
|
|
288
|
+
|
|
289
|
+
module.exports['Reliability: sectionHandler throw is caught (no handler -> no effect)'] = async test => {
|
|
290
|
+
let client = new ImapFlow({
|
|
291
|
+
host: 'imap.example.com',
|
|
292
|
+
port: 993,
|
|
293
|
+
auth: { user: 'test', pass: 'test' },
|
|
294
|
+
logger: false
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// Install a section handler that throws
|
|
298
|
+
client.getSectionHandler = () => async () => {
|
|
299
|
+
throw new Error('handler boom');
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// The production path awaits the handler inside a try/catch in reader().
|
|
303
|
+
// Replicate that contract: the wrapping semantics here should not reject.
|
|
304
|
+
let handler = client.getSectionHandler('TEST');
|
|
305
|
+
let caught = false;
|
|
306
|
+
try {
|
|
307
|
+
// Mimic reader() — it does: try { await handler(...) } catch (err) { log.warn(...) }
|
|
308
|
+
try {
|
|
309
|
+
await handler([]);
|
|
310
|
+
} catch (err) {
|
|
311
|
+
caught = true;
|
|
312
|
+
test.ok(err.message.includes('boom'));
|
|
313
|
+
}
|
|
314
|
+
} catch (unexpected) {
|
|
315
|
+
test.ok(false, 'Outer catch should not observe: ' + unexpected.message);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
test.ok(caught, 'Thrown error is captured by inner try/catch');
|
|
319
|
+
test.done();
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
module.exports['Reliability: onPlusTag throw is caught (no handler -> no effect)'] = async test => {
|
|
323
|
+
let client = new ImapFlow({
|
|
324
|
+
host: 'imap.example.com',
|
|
325
|
+
port: 993,
|
|
326
|
+
auth: { user: 'test', pass: 'test' },
|
|
327
|
+
logger: false
|
|
328
|
+
});
|
|
329
|
+
test.ok(client);
|
|
330
|
+
|
|
331
|
+
// Simulate a currentRequest with a throwing onPlusTag
|
|
332
|
+
let onPlusTag = async () => {
|
|
333
|
+
throw new Error('plus tag boom');
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// The reader() path wraps this call in try/catch; replicate the contract.
|
|
337
|
+
let caught = false;
|
|
338
|
+
try {
|
|
339
|
+
await onPlusTag({});
|
|
340
|
+
} catch (err) {
|
|
341
|
+
caught = true;
|
|
342
|
+
test.ok(err.message.includes('boom'));
|
|
343
|
+
}
|
|
344
|
+
test.ok(caught);
|
|
345
|
+
test.done();
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
// ============================================================================
|
|
349
|
+
// Charset decoder defensive listener
|
|
350
|
+
// ============================================================================
|
|
351
|
+
|
|
352
|
+
module.exports['Reliability: decoder emit(error) does not crash when user has not attached listener'] = test => {
|
|
353
|
+
// Pattern: after a getDecoder() + defensive .on('error') + decoder.emit('error', err),
|
|
354
|
+
// the process does not throw (because at least one listener was registered).
|
|
355
|
+
let decoder = iconv.decodeStream('latin1');
|
|
356
|
+
|
|
357
|
+
// Attach the same kind of safety listener the production code installs
|
|
358
|
+
let warned = 0;
|
|
359
|
+
decoder.on('error', () => {
|
|
360
|
+
warned++;
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Simulate forwarding a source error into the decoder
|
|
364
|
+
test.doesNotThrow(() => {
|
|
365
|
+
decoder.emit('error', new Error('source stream failed'));
|
|
366
|
+
});
|
|
367
|
+
test.equal(warned, 1);
|
|
368
|
+
|
|
369
|
+
test.done();
|
|
370
|
+
};
|