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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +16 -0
- package/lib/commands/fetch.js +1 -1
- package/lib/commands/idle.js +4 -2
- package/lib/imap-flow.d.ts +13 -5
- package/lib/imap-flow.js +58 -77
- package/lib/tools.js +70 -7
- package/package.json +2 -2
- package/test/fetch-generator-test.js +2 -0
- package/test/imapflow-test.js +2 -0
- package/test/tools-test.js +113 -0
- package/test/unhandled-rejection-test.js +92 -0
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
|
|
package/lib/commands/fetch.js
CHANGED
|
@@ -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++;
|
package/lib/commands/idle.js
CHANGED
|
@@ -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
|
-
|
|
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.d.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2567
|
-
//
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
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
|
-
|
|
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
|
|
2634
|
-
//
|
|
2635
|
-
//
|
|
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
|
-
//
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
//
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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
|
-
|
|
803
|
-
|
|
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.
|
|
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.
|
|
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
|
|
package/test/imapflow-test.js
CHANGED
|
@@ -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
|
|
package/test/tools-test.js
CHANGED
|
@@ -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
|
|