imapflow 1.2.0 → 1.2.2
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/imap-flow.js +53 -7
- package/package.json +1 -1
- package/test/connection-edge-cases-test.js +246 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.2.2](https://github.com/postalsys/imapflow/compare/v1.2.1...v1.2.2) (2025-12-22)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* Reject pending mailbox locks on connection close ([d20f060](https://github.com/postalsys/imapflow/commit/d20f060faafe5bf4740bdb067008d1d8e7b31592))
|
|
9
|
+
|
|
10
|
+
## [1.2.1](https://github.com/postalsys/imapflow/compare/v1.2.0...v1.2.1) (2025-12-17)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* Defer promise rejections on connection close to prevent unhandled rejections ([e53d9a8](https://github.com/postalsys/imapflow/commit/e53d9a8e0028197a5ff3c7cda342e0c11b64f228))
|
|
16
|
+
|
|
3
17
|
## [1.2.0](https://github.com/postalsys/imapflow/compare/v1.1.1...v1.2.0) (2025-12-11)
|
|
4
18
|
|
|
5
19
|
|
package/lib/imap-flow.js
CHANGED
|
@@ -1252,7 +1252,17 @@ class ImapFlow extends EventEmitter {
|
|
|
1252
1252
|
});
|
|
1253
1253
|
}
|
|
1254
1254
|
|
|
1255
|
-
async serverBye() {
|
|
1255
|
+
async serverBye(parsed) {
|
|
1256
|
+
// Extract BYE reason from response for better error messages
|
|
1257
|
+
let reason =
|
|
1258
|
+
parsed &&
|
|
1259
|
+
parsed.attributes &&
|
|
1260
|
+
parsed.attributes
|
|
1261
|
+
.filter(val => val.type === 'TEXT')
|
|
1262
|
+
.map(val => val.value.trim())
|
|
1263
|
+
.join(' ');
|
|
1264
|
+
|
|
1265
|
+
this.byeReason = reason || 'Server closed connection';
|
|
1256
1266
|
this.untaggedHandlers.BYE = null;
|
|
1257
1267
|
this.state = this.states.LOGOUT;
|
|
1258
1268
|
}
|
|
@@ -1695,14 +1705,15 @@ class ImapFlow extends EventEmitter {
|
|
|
1695
1705
|
this.preCheck().catch(err => this.log.warn({ err, cid: this.id }));
|
|
1696
1706
|
}
|
|
1697
1707
|
|
|
1708
|
+
// Collect all pending requests to reject
|
|
1709
|
+
let pendingRequests = [];
|
|
1710
|
+
|
|
1698
1711
|
// reject command that is currently processed
|
|
1699
1712
|
if (this.currentRequest && this.requestTagMap.has(this.currentRequest.tag)) {
|
|
1700
1713
|
let request = this.requestTagMap.get(this.currentRequest.tag);
|
|
1701
1714
|
if (request) {
|
|
1702
1715
|
this.requestTagMap.delete(request.tag);
|
|
1703
|
-
|
|
1704
|
-
error.code = 'NoConnection';
|
|
1705
|
-
request.reject(error);
|
|
1716
|
+
pendingRequests.push(request);
|
|
1706
1717
|
}
|
|
1707
1718
|
this.currentRequest = false;
|
|
1708
1719
|
}
|
|
@@ -1714,13 +1725,48 @@ class ImapFlow extends EventEmitter {
|
|
|
1714
1725
|
let request = this.requestTagMap.get(req.tag);
|
|
1715
1726
|
if (request) {
|
|
1716
1727
|
this.requestTagMap.delete(request.tag);
|
|
1717
|
-
|
|
1718
|
-
error.code = 'NoConnection';
|
|
1719
|
-
request.reject(error);
|
|
1728
|
+
pendingRequests.push(request);
|
|
1720
1729
|
}
|
|
1721
1730
|
}
|
|
1722
1731
|
}
|
|
1723
1732
|
|
|
1733
|
+
// Helper to create connection error
|
|
1734
|
+
const createNoConnectionError = byeReason => {
|
|
1735
|
+
const error = new Error('Connection not available');
|
|
1736
|
+
error.code = 'NoConnection';
|
|
1737
|
+
if (byeReason) {
|
|
1738
|
+
error.reason = byeReason;
|
|
1739
|
+
}
|
|
1740
|
+
return error;
|
|
1741
|
+
};
|
|
1742
|
+
|
|
1743
|
+
// Reject pending requests via setImmediate to ensure caller's promise chain
|
|
1744
|
+
// is fully set up before rejection (prevents unhandled promise rejections)
|
|
1745
|
+
if (pendingRequests.length) {
|
|
1746
|
+
let byeReason = this.byeReason;
|
|
1747
|
+
setImmediate(() => {
|
|
1748
|
+
for (let request of pendingRequests) {
|
|
1749
|
+
request.reject(createNoConnectionError(byeReason));
|
|
1750
|
+
}
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
// Clear current lock - holder will see errors when they try operations
|
|
1755
|
+
this.currentLock = false;
|
|
1756
|
+
|
|
1757
|
+
// Reject pending mailbox locks via setImmediate for consistency
|
|
1758
|
+
if (this.locks && this.locks.length) {
|
|
1759
|
+
let byeReason = this.byeReason;
|
|
1760
|
+
let pendingLocks = this.locks.splice(0); // Take all locks and clear the array
|
|
1761
|
+
setImmediate(() => {
|
|
1762
|
+
for (let lock of pendingLocks) {
|
|
1763
|
+
if (typeof lock.reject === 'function') {
|
|
1764
|
+
lock.reject(createNoConnectionError(byeReason));
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1724
1770
|
// cleanup compression streams if they exist
|
|
1725
1771
|
if (this._inflate) {
|
|
1726
1772
|
try {
|
package/package.json
CHANGED
|
@@ -700,3 +700,249 @@ module.exports['Connection Edge: Event handlers attached before piping'] = test
|
|
|
700
700
|
|
|
701
701
|
test.done();
|
|
702
702
|
};
|
|
703
|
+
|
|
704
|
+
module.exports['Connection Edge: Pending locks rejected on close'] = test => {
|
|
705
|
+
let client = new ImapFlow({
|
|
706
|
+
host: 'imap.example.com',
|
|
707
|
+
port: 993,
|
|
708
|
+
auth: { user: 'test', pass: 'test' }
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
// Initialize locks array with pending locks
|
|
712
|
+
let rejectedErrors = [];
|
|
713
|
+
client.locks = [
|
|
714
|
+
{
|
|
715
|
+
path: 'INBOX',
|
|
716
|
+
lockId: 'lock1',
|
|
717
|
+
resolve: () => {},
|
|
718
|
+
reject: err => rejectedErrors.push(err)
|
|
719
|
+
},
|
|
720
|
+
{
|
|
721
|
+
path: 'Sent',
|
|
722
|
+
lockId: 'lock2',
|
|
723
|
+
resolve: () => {},
|
|
724
|
+
reject: err => rejectedErrors.push(err)
|
|
725
|
+
}
|
|
726
|
+
];
|
|
727
|
+
|
|
728
|
+
// Mock socket to allow close() to run
|
|
729
|
+
client.socket = { destroyed: false, destroy: () => {} };
|
|
730
|
+
client.usable = true;
|
|
731
|
+
|
|
732
|
+
// Call close
|
|
733
|
+
client.close();
|
|
734
|
+
|
|
735
|
+
// Rejections happen via setImmediate, so check after next tick
|
|
736
|
+
setImmediate(() => {
|
|
737
|
+
test.equal(rejectedErrors.length, 2, 'All pending locks should be rejected');
|
|
738
|
+
test.equal(rejectedErrors[0].code, 'NoConnection', 'Error should have NoConnection code');
|
|
739
|
+
test.equal(rejectedErrors[1].code, 'NoConnection', 'Error should have NoConnection code');
|
|
740
|
+
test.equal(client.locks.length, 0, 'Locks array should be cleared');
|
|
741
|
+
test.done();
|
|
742
|
+
});
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
module.exports['Connection Edge: Lock rejection includes byeReason'] = test => {
|
|
746
|
+
let client = new ImapFlow({
|
|
747
|
+
host: 'imap.example.com',
|
|
748
|
+
port: 993,
|
|
749
|
+
auth: { user: 'test', pass: 'test' }
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
// Set byeReason before close
|
|
753
|
+
client.byeReason = 'Server shutting down';
|
|
754
|
+
|
|
755
|
+
let rejectedError = null;
|
|
756
|
+
client.locks = [
|
|
757
|
+
{
|
|
758
|
+
path: 'INBOX',
|
|
759
|
+
lockId: 'lock1',
|
|
760
|
+
resolve: () => {},
|
|
761
|
+
reject: err => {
|
|
762
|
+
rejectedError = err;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
];
|
|
766
|
+
|
|
767
|
+
client.socket = { destroyed: false, destroy: () => {} };
|
|
768
|
+
client.usable = true;
|
|
769
|
+
|
|
770
|
+
client.close();
|
|
771
|
+
|
|
772
|
+
setImmediate(() => {
|
|
773
|
+
test.ok(rejectedError, 'Lock should be rejected');
|
|
774
|
+
test.equal(rejectedError.code, 'NoConnection');
|
|
775
|
+
test.equal(rejectedError.reason, 'Server shutting down', 'byeReason should be included');
|
|
776
|
+
test.done();
|
|
777
|
+
});
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
module.exports['Connection Edge: currentLock cleared on close'] = test => {
|
|
781
|
+
let client = new ImapFlow({
|
|
782
|
+
host: 'imap.example.com',
|
|
783
|
+
port: 993,
|
|
784
|
+
auth: { user: 'test', pass: 'test' }
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
// Simulate an active lock
|
|
788
|
+
client.currentLock = {
|
|
789
|
+
path: 'INBOX',
|
|
790
|
+
lockId: 'active-lock',
|
|
791
|
+
release: () => {}
|
|
792
|
+
};
|
|
793
|
+
client.locks = [];
|
|
794
|
+
|
|
795
|
+
client.socket = { destroyed: false, destroy: () => {} };
|
|
796
|
+
client.usable = true;
|
|
797
|
+
|
|
798
|
+
client.close();
|
|
799
|
+
|
|
800
|
+
test.equal(client.currentLock, false, 'currentLock should be cleared');
|
|
801
|
+
test.done();
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
module.exports['Connection Edge: Lock rejection handles missing reject function'] = test => {
|
|
805
|
+
let client = new ImapFlow({
|
|
806
|
+
host: 'imap.example.com',
|
|
807
|
+
port: 993,
|
|
808
|
+
auth: { user: 'test', pass: 'test' }
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
// Create lock with missing reject function (edge case)
|
|
812
|
+
let validRejected = false;
|
|
813
|
+
client.locks = [
|
|
814
|
+
{
|
|
815
|
+
path: 'INBOX',
|
|
816
|
+
lockId: 'lock1',
|
|
817
|
+
resolve: () => {}
|
|
818
|
+
// No reject function
|
|
819
|
+
},
|
|
820
|
+
{
|
|
821
|
+
path: 'Sent',
|
|
822
|
+
lockId: 'lock2',
|
|
823
|
+
resolve: () => {},
|
|
824
|
+
reject: () => {
|
|
825
|
+
validRejected = true;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
];
|
|
829
|
+
|
|
830
|
+
client.socket = { destroyed: false, destroy: () => {} };
|
|
831
|
+
client.usable = true;
|
|
832
|
+
|
|
833
|
+
// Should not throw
|
|
834
|
+
test.doesNotThrow(() => {
|
|
835
|
+
client.close();
|
|
836
|
+
}, 'Should handle missing reject function gracefully');
|
|
837
|
+
|
|
838
|
+
setImmediate(() => {
|
|
839
|
+
test.ok(validRejected, 'Valid lock should still be rejected');
|
|
840
|
+
test.equal(client.locks.length, 0, 'Locks array should be cleared');
|
|
841
|
+
test.done();
|
|
842
|
+
});
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
module.exports['Connection Edge: Close with empty locks array'] = test => {
|
|
846
|
+
let client = new ImapFlow({
|
|
847
|
+
host: 'imap.example.com',
|
|
848
|
+
port: 993,
|
|
849
|
+
auth: { user: 'test', pass: 'test' }
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
client.locks = [];
|
|
853
|
+
client.currentLock = false;
|
|
854
|
+
client.socket = { destroyed: false, destroy: () => {} };
|
|
855
|
+
client.usable = true;
|
|
856
|
+
|
|
857
|
+
// Should not throw with empty locks
|
|
858
|
+
test.doesNotThrow(() => {
|
|
859
|
+
client.close();
|
|
860
|
+
}, 'Should handle empty locks array');
|
|
861
|
+
|
|
862
|
+
test.equal(client.currentLock, false);
|
|
863
|
+
test.done();
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
module.exports['Connection Edge: Lock rejection is deferred via setImmediate'] = test => {
|
|
867
|
+
let client = new ImapFlow({
|
|
868
|
+
host: 'imap.example.com',
|
|
869
|
+
port: 993,
|
|
870
|
+
auth: { user: 'test', pass: 'test' }
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
let rejectionTime = null;
|
|
874
|
+
let closeTime = null;
|
|
875
|
+
|
|
876
|
+
client.locks = [
|
|
877
|
+
{
|
|
878
|
+
path: 'INBOX',
|
|
879
|
+
lockId: 'lock1',
|
|
880
|
+
resolve: () => {},
|
|
881
|
+
reject: () => {
|
|
882
|
+
rejectionTime = Date.now();
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
];
|
|
886
|
+
|
|
887
|
+
client.socket = { destroyed: false, destroy: () => {} };
|
|
888
|
+
client.usable = true;
|
|
889
|
+
|
|
890
|
+
client.close();
|
|
891
|
+
closeTime = Date.now();
|
|
892
|
+
|
|
893
|
+
// Rejection should not have happened yet (synchronously)
|
|
894
|
+
test.equal(rejectionTime, null, 'Rejection should be deferred');
|
|
895
|
+
|
|
896
|
+
setImmediate(() => {
|
|
897
|
+
test.ok(rejectionTime !== null, 'Rejection should happen after setImmediate');
|
|
898
|
+
test.ok(rejectionTime >= closeTime, 'Rejection should happen after close()');
|
|
899
|
+
test.done();
|
|
900
|
+
});
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
module.exports['Connection Edge: Pending requests and locks both rejected on close'] = test => {
|
|
904
|
+
let client = new ImapFlow({
|
|
905
|
+
host: 'imap.example.com',
|
|
906
|
+
port: 993,
|
|
907
|
+
auth: { user: 'test', pass: 'test' }
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
let requestRejected = false;
|
|
911
|
+
let lockRejected = false;
|
|
912
|
+
|
|
913
|
+
// Add pending request - must be in requestQueue and requestTagMap
|
|
914
|
+
let request = {
|
|
915
|
+
tag: 'A001',
|
|
916
|
+
reject: err => {
|
|
917
|
+
requestRejected = true;
|
|
918
|
+
test.equal(err.code, 'NoConnection');
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
client.requestTagMap = new Map();
|
|
922
|
+
client.requestTagMap.set('A001', request);
|
|
923
|
+
client.requestQueue = [request];
|
|
924
|
+
|
|
925
|
+
// Add pending lock
|
|
926
|
+
client.locks = [
|
|
927
|
+
{
|
|
928
|
+
path: 'INBOX',
|
|
929
|
+
lockId: 'lock1',
|
|
930
|
+
resolve: () => {},
|
|
931
|
+
reject: err => {
|
|
932
|
+
lockRejected = true;
|
|
933
|
+
test.equal(err.code, 'NoConnection');
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
];
|
|
937
|
+
|
|
938
|
+
client.socket = { destroyed: false, destroy: () => {} };
|
|
939
|
+
client.usable = true;
|
|
940
|
+
|
|
941
|
+
client.close();
|
|
942
|
+
|
|
943
|
+
setImmediate(() => {
|
|
944
|
+
test.ok(requestRejected, 'Pending request should be rejected');
|
|
945
|
+
test.ok(lockRejected, 'Pending lock should be rejected');
|
|
946
|
+
test.done();
|
|
947
|
+
});
|
|
948
|
+
};
|