imapflow 1.3.4 → 1.3.6
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/.github/codeql/codeql-config.yml +12 -0
- package/.github/workflows/codeql.yml +102 -0
- package/.github/workflows/stale.yml +5 -0
- package/.github/workflows/test.yml +3 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/SECURITY.md +88 -0
- package/SECURITY.txt +27 -0
- package/lib/commands/starttls.js +3 -0
- package/lib/commands/store.js +1 -1
- package/lib/handler/imap-stream.js +53 -6
- package/lib/handler/token-parser.js +7 -0
- package/lib/imap-flow.d.ts +11 -0
- package/lib/imap-flow.js +217 -28
- package/lib/proxy-connection.js +26 -1
- package/lib/tools.js +8 -2
- package/package.json +2 -2
- package/test/commands-integration-test.js +45 -0
- package/test/connection-edge-cases-test.js +206 -1
- package/test/imap-stream-edge-cases-test.js +126 -0
- package/test/proxy-connection-test.js +86 -0
- package/test/reliability-improvements-test.js +88 -0
- package/test/starttls-injection-test.js +181 -0
- package/test/token-parser-test.js +18 -0
- package/test/tools-test.js +42 -0
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { ImapFlow } = require('../lib/imap-flow');
|
|
4
4
|
const { EventEmitter } = require('events');
|
|
5
|
+
const net = require('net');
|
|
5
6
|
|
|
6
7
|
// Edge Cases Tests
|
|
7
8
|
|
|
@@ -1067,6 +1068,8 @@ module.exports['Connection Edge: unbind removes socket listeners and returns soc
|
|
|
1067
1068
|
test.ok(result.writeSocket, 'Should return writeSocket');
|
|
1068
1069
|
test.equal(result.readSocket, client.socket);
|
|
1069
1070
|
test.equal(result.writeSocket, client.socket);
|
|
1071
|
+
// Non-compression path: the raw socket is the same object as read/write.
|
|
1072
|
+
test.equal(result.socket, client.socket);
|
|
1070
1073
|
|
|
1071
1074
|
test.done();
|
|
1072
1075
|
};
|
|
@@ -1103,6 +1106,72 @@ module.exports['Connection Edge: unbind with inflate stream'] = test => {
|
|
|
1103
1106
|
test.done();
|
|
1104
1107
|
};
|
|
1105
1108
|
|
|
1109
|
+
module.exports['Connection Edge: unbind survives post-handoff socket error (compression)'] = test => {
|
|
1110
|
+
let client = new ImapFlow({
|
|
1111
|
+
host: 'imap.example.com',
|
|
1112
|
+
port: 993,
|
|
1113
|
+
auth: { user: 'test', pass: 'test' }
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
// Mock the compression topology: raw socket plus a separate PassThrough-like
|
|
1117
|
+
// writeSocket, an inflate read stream and a deflate stream. The writeSocket
|
|
1118
|
+
// and deflate error forwarders re-emit onto the raw socket, exactly like compress().
|
|
1119
|
+
client.socket = new EventEmitter();
|
|
1120
|
+
client.socket.unpipe = () => {};
|
|
1121
|
+
|
|
1122
|
+
client.writeSocket = new EventEmitter();
|
|
1123
|
+
client.writeSocket.on('error', err => {
|
|
1124
|
+
if (client.socket) {
|
|
1125
|
+
client.socket.emit('error', err);
|
|
1126
|
+
}
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
client._inflate = new EventEmitter();
|
|
1130
|
+
client._inflate.unpipe = () => {};
|
|
1131
|
+
|
|
1132
|
+
client._deflate = new EventEmitter();
|
|
1133
|
+
client._deflate.on('error', err => {
|
|
1134
|
+
if (client.socket) {
|
|
1135
|
+
client.socket.emit('error', err);
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
client.setSocketHandlers();
|
|
1140
|
+
|
|
1141
|
+
let result = client.unbind();
|
|
1142
|
+
|
|
1143
|
+
// Explicit contract: raw socket is now exposed alongside read/write sockets.
|
|
1144
|
+
test.equal(result.socket, client.socket, 'Should expose the raw socket');
|
|
1145
|
+
test.equal(result.readSocket, client._inflate);
|
|
1146
|
+
test.equal(result.writeSocket, client.writeSocket);
|
|
1147
|
+
|
|
1148
|
+
// ImapFlow's own _socketError must be detached from writeSocket; only the
|
|
1149
|
+
// forwarder closure remains.
|
|
1150
|
+
test.equal(client.writeSocket.listenerCount('error'), 1);
|
|
1151
|
+
|
|
1152
|
+
// Emitting 'error' on a listener-less EventEmitter throws synchronously, so
|
|
1153
|
+
// each of these would crash the host before the fix. After unbind() the
|
|
1154
|
+
// benign listener on the orphaned raw socket must swallow them all.
|
|
1155
|
+
test.doesNotThrow(() => {
|
|
1156
|
+
client.socket.emit('error', Object.assign(new Error('upstream reset'), { code: 'ECONNRESET' }));
|
|
1157
|
+
}, 'Direct socket error after unbind must not throw');
|
|
1158
|
+
|
|
1159
|
+
test.doesNotThrow(() => {
|
|
1160
|
+
client.writeSocket.emit('error', new Error('write failure'));
|
|
1161
|
+
}, 'writeSocket error forwarded to the raw socket must not throw');
|
|
1162
|
+
|
|
1163
|
+
test.doesNotThrow(() => {
|
|
1164
|
+
client._deflate.emit('error', new Error('deflate failure'));
|
|
1165
|
+
}, 'deflate error forwarded to the raw socket must not throw');
|
|
1166
|
+
|
|
1167
|
+
// close() must remain safe to call after unbind().
|
|
1168
|
+
test.doesNotThrow(() => {
|
|
1169
|
+
client.close();
|
|
1170
|
+
}, 'close() after unbind must not throw');
|
|
1171
|
+
|
|
1172
|
+
test.done();
|
|
1173
|
+
};
|
|
1174
|
+
|
|
1106
1175
|
module.exports['Connection Edge: resolveRange with number input'] = async test => {
|
|
1107
1176
|
let client = new ImapFlow({
|
|
1108
1177
|
host: 'imap.example.com',
|
|
@@ -1471,7 +1540,14 @@ async function setupCompressedClient() {
|
|
|
1471
1540
|
mockSocket.destroy = () => {};
|
|
1472
1541
|
mockSocket.destroyed = false;
|
|
1473
1542
|
client.socket = mockSocket;
|
|
1474
|
-
|
|
1543
|
+
// Mock streamer that tracks destruction like a real stream, so close() drives it into
|
|
1544
|
+
// the destroyed state the post-close guards actually check.
|
|
1545
|
+
client.streamer = Object.assign(new EventEmitter(), {
|
|
1546
|
+
destroyed: false,
|
|
1547
|
+
destroy() {
|
|
1548
|
+
this.destroyed = true;
|
|
1549
|
+
}
|
|
1550
|
+
});
|
|
1475
1551
|
|
|
1476
1552
|
client.run = async command => {
|
|
1477
1553
|
if (command === 'COMPRESS') {
|
|
@@ -1562,6 +1638,33 @@ module.exports['Connection Edge: compress _deflate error after close does not cr
|
|
|
1562
1638
|
test.done();
|
|
1563
1639
|
};
|
|
1564
1640
|
|
|
1641
|
+
module.exports['Connection Edge: compress _inflate error after close does not crash'] = async test => {
|
|
1642
|
+
let { client } = await setupCompressedClient();
|
|
1643
|
+
|
|
1644
|
+
let inflate = client._inflate;
|
|
1645
|
+
|
|
1646
|
+
// close() removes the streamer 'error' listener and destroys the streamer.
|
|
1647
|
+
// A late inflate error must not be forwarded into the destroyed streamer
|
|
1648
|
+
// (which would throw an unhandled 'error' and crash the process).
|
|
1649
|
+
client.close();
|
|
1650
|
+
|
|
1651
|
+
// Confirm we actually reached the post-close state the guard targets.
|
|
1652
|
+
test.ok(client.streamer.destroyed, 'streamer is destroyed after close()');
|
|
1653
|
+
|
|
1654
|
+
// Re-attach an 'error' listener so listenerCount('error') > 0: this isolates the
|
|
1655
|
+
// `!streamer.destroyed` term of the guard. If forwarding still happened, this listener
|
|
1656
|
+
// would throw and escape doesNotThrow — proving the destroyed-check is what suppresses it.
|
|
1657
|
+
client.streamer.on('error', () => {
|
|
1658
|
+
throw new Error('inflate error must not be forwarded into a destroyed streamer');
|
|
1659
|
+
});
|
|
1660
|
+
|
|
1661
|
+
test.doesNotThrow(() => {
|
|
1662
|
+
inflate.emit('error', new Error('late inflate error'));
|
|
1663
|
+
});
|
|
1664
|
+
|
|
1665
|
+
test.done();
|
|
1666
|
+
};
|
|
1667
|
+
|
|
1565
1668
|
// ============================================
|
|
1566
1669
|
// Mailbox Lock Tests
|
|
1567
1670
|
// ============================================
|
|
@@ -1655,3 +1758,105 @@ module.exports['Connection Edge: multiple locks rejected when no connection'] =
|
|
|
1655
1758
|
}
|
|
1656
1759
|
test.done();
|
|
1657
1760
|
};
|
|
1761
|
+
|
|
1762
|
+
// ============================================
|
|
1763
|
+
// reader() survives a write() throw without stalling the stream
|
|
1764
|
+
// ============================================
|
|
1765
|
+
|
|
1766
|
+
module.exports['Connection Edge: reader survives write throw on + continuation'] = async test => {
|
|
1767
|
+
let client = new ImapFlow({
|
|
1768
|
+
host: 'imap.example.com',
|
|
1769
|
+
port: 993,
|
|
1770
|
+
auth: { user: 'test', pass: 'test' },
|
|
1771
|
+
logger: false
|
|
1772
|
+
});
|
|
1773
|
+
|
|
1774
|
+
client.currentRequest = false;
|
|
1775
|
+
client.commandParts = [Buffer.from('literal-bytes')];
|
|
1776
|
+
|
|
1777
|
+
let writeAttempted = false;
|
|
1778
|
+
client.write = () => {
|
|
1779
|
+
writeAttempted = true;
|
|
1780
|
+
throw new Error('write boom');
|
|
1781
|
+
};
|
|
1782
|
+
|
|
1783
|
+
let nextCalled = false;
|
|
1784
|
+
let done = false;
|
|
1785
|
+
client.streamer.read = () => {
|
|
1786
|
+
if (done) {
|
|
1787
|
+
return null;
|
|
1788
|
+
}
|
|
1789
|
+
done = true;
|
|
1790
|
+
return {
|
|
1791
|
+
payload: Buffer.from('+ Ready'),
|
|
1792
|
+
literals: [],
|
|
1793
|
+
next: () => {
|
|
1794
|
+
nextCalled = true;
|
|
1795
|
+
}
|
|
1796
|
+
};
|
|
1797
|
+
};
|
|
1798
|
+
|
|
1799
|
+
let rejected = false;
|
|
1800
|
+
await client.reader().catch(() => {
|
|
1801
|
+
rejected = true;
|
|
1802
|
+
});
|
|
1803
|
+
|
|
1804
|
+
test.ok(writeAttempted, 'the literal-continuation write path was exercised');
|
|
1805
|
+
test.equal(rejected, false, 'reader did not reject on write throw');
|
|
1806
|
+
test.ok(nextCalled, 'data.next() was still called so the stream is not stalled');
|
|
1807
|
+
test.done();
|
|
1808
|
+
};
|
|
1809
|
+
|
|
1810
|
+
// ============================================
|
|
1811
|
+
// BYE greeting surfaces its reason on the connect rejection
|
|
1812
|
+
// ============================================
|
|
1813
|
+
|
|
1814
|
+
module.exports['Connection Edge: BYE greeting surfaces reason on connect rejection'] = async test => {
|
|
1815
|
+
// Deterministic ordering: the server ends the socket only AFTER the client has parsed the
|
|
1816
|
+
// BYE and set byeReason, instead of racing a fixed 50ms timer that could flake on slow CI.
|
|
1817
|
+
let signalByeParsed;
|
|
1818
|
+
let byeParsed = new Promise(resolve => {
|
|
1819
|
+
signalByeParsed = resolve;
|
|
1820
|
+
});
|
|
1821
|
+
|
|
1822
|
+
let server = net.createServer(socket => {
|
|
1823
|
+
socket.on('error', () => {});
|
|
1824
|
+
socket.write('* BYE Server too busy\r\n');
|
|
1825
|
+
byeParsed.then(() => socket.end());
|
|
1826
|
+
});
|
|
1827
|
+
|
|
1828
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
1829
|
+
let port = server.address().port;
|
|
1830
|
+
|
|
1831
|
+
let client = new ImapFlow({
|
|
1832
|
+
host: '127.0.0.1',
|
|
1833
|
+
port,
|
|
1834
|
+
secure: false,
|
|
1835
|
+
disableAutoIdle: true,
|
|
1836
|
+
logger: false,
|
|
1837
|
+
auth: { user: 'test', pass: 'test' }
|
|
1838
|
+
});
|
|
1839
|
+
client.on('error', () => {});
|
|
1840
|
+
|
|
1841
|
+
// Signal once the client's BYE handler has run, so byeReason is guaranteed set before close().
|
|
1842
|
+
let origServerBye = client.serverBye.bind(client);
|
|
1843
|
+
client.serverBye = async parsed => {
|
|
1844
|
+
await origServerBye(parsed);
|
|
1845
|
+
signalByeParsed();
|
|
1846
|
+
};
|
|
1847
|
+
|
|
1848
|
+
let connectErr = null;
|
|
1849
|
+
try {
|
|
1850
|
+
await client.connect();
|
|
1851
|
+
test.ok(false, 'connect() should reject after a BYE greeting');
|
|
1852
|
+
} catch (err) {
|
|
1853
|
+
connectErr = err;
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
test.ok(connectErr, 'connect() rejected');
|
|
1857
|
+
test.equal(connectErr.reason, 'Server too busy', 'BYE reason surfaced on the rejection');
|
|
1858
|
+
|
|
1859
|
+
client.close();
|
|
1860
|
+
server.close();
|
|
1861
|
+
test.done();
|
|
1862
|
+
};
|
|
@@ -131,6 +131,64 @@ module.exports['LiteralTooLarge error'] = test => {
|
|
|
131
131
|
stream.write(Buffer.from('A APPEND {1073741825}\r\n'));
|
|
132
132
|
};
|
|
133
133
|
|
|
134
|
+
module.exports['LiteralTooLarge error honors configured maxLiteralSize'] = test => {
|
|
135
|
+
const cap = 1024; // 1KB cap
|
|
136
|
+
const stream = new ImapStream({ cid: 'test', maxLiteralSize: cap });
|
|
137
|
+
|
|
138
|
+
stream.on('error', err => {
|
|
139
|
+
test.equal(err.code, 'LiteralTooLarge', 'error code should be LiteralTooLarge');
|
|
140
|
+
test.equal(err.maxSize, cap, 'maxSize should reflect the configured cap');
|
|
141
|
+
test.equal(err.literalSize, 2048, 'literalSize should be the offending value');
|
|
142
|
+
stream.destroy();
|
|
143
|
+
test.done();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
stream.write(Buffer.from('A APPEND {2048}\r\n'));
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
module.exports['maxLiteralSize: 0 is honored (not swallowed into the default)'] = test => {
|
|
150
|
+
// Regression: `this.options.maxLiteralSize || MAX_LITERAL_SIZE` turned an explicit 0 into
|
|
151
|
+
// the 1GB default. An explicit 0 must mean "reject any non-empty literal".
|
|
152
|
+
test.expect(2);
|
|
153
|
+
|
|
154
|
+
const stream = new ImapStream({ cid: 'test', maxLiteralSize: 0 });
|
|
155
|
+
test.equal(stream.maxLiteralSize, 0, 'an explicit 0 cap is preserved, not replaced by the default');
|
|
156
|
+
|
|
157
|
+
stream.on('error', err => {
|
|
158
|
+
test.equal(err.code, 'LiteralTooLarge', 'a 1-byte literal exceeds the 0 cap');
|
|
159
|
+
stream.destroy();
|
|
160
|
+
test.done();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
stream.write(Buffer.from('A APPEND {1}\r\n'));
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
module.exports['Literal within configured maxLiteralSize parses cleanly'] = test => {
|
|
167
|
+
// Require both literal assertions to actually run: 'end' fires even if the parser never
|
|
168
|
+
// emits the command, so without expect() a dropped-literal regression would pass green.
|
|
169
|
+
test.expect(2);
|
|
170
|
+
|
|
171
|
+
const stream = new ImapStream({ cid: 'test', maxLiteralSize: 1024 });
|
|
172
|
+
const literal = Buffer.alloc(512, 0x61); // 512 * 'a'
|
|
173
|
+
|
|
174
|
+
stream.on('readable', () => {
|
|
175
|
+
let cmd;
|
|
176
|
+
while ((cmd = stream.read()) !== null) {
|
|
177
|
+
test.equal(cmd.literals.length, 1, 'should have one literal');
|
|
178
|
+
test.equal(cmd.literals[0].length, 512, 'literal length should be 512');
|
|
179
|
+
cmd.next();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
stream.on('error', err => test.ifError(err));
|
|
184
|
+
|
|
185
|
+
stream.on('end', () => test.done());
|
|
186
|
+
|
|
187
|
+
stream.write(Buffer.from('A APPEND {512}\r\n'));
|
|
188
|
+
stream.write(literal);
|
|
189
|
+
stream.end(Buffer.from('\r\n'));
|
|
190
|
+
};
|
|
191
|
+
|
|
134
192
|
module.exports['Incomplete line continued in next chunk'] = test => {
|
|
135
193
|
runStreamTest(
|
|
136
194
|
test,
|
|
@@ -266,3 +324,71 @@ module.exports['logRaw option triggers trace logging'] = test => {
|
|
|
266
324
|
|
|
267
325
|
stream.end(Buffer.from('A CMD\r\n'));
|
|
268
326
|
};
|
|
327
|
+
|
|
328
|
+
module.exports['Adjacent literals with marker at line start'] = test => {
|
|
329
|
+
// After the first literal's data (12345) is consumed, parsing resumes at the very start of
|
|
330
|
+
// a line that is itself a literal marker ({3}). The marker begins at byte 0 of the resumed
|
|
331
|
+
// line, which the backward scan must still recognize. Previously the loop bound skipped
|
|
332
|
+
// index 0, so the second literal was silently dropped.
|
|
333
|
+
runStreamTest(
|
|
334
|
+
test,
|
|
335
|
+
cmd => {
|
|
336
|
+
test.equal(cmd.literals.length, 2, 'both adjacent literals must be extracted');
|
|
337
|
+
test.equal(cmd.literals[0].toString(), '12345', 'first literal content');
|
|
338
|
+
test.equal(cmd.literals[1].toString(), 'ABC', 'second literal content');
|
|
339
|
+
},
|
|
340
|
+
async stream => {
|
|
341
|
+
stream.end(Buffer.from('A LOGIN {5}\r\n12345{3}\r\nABC\r\n'));
|
|
342
|
+
},
|
|
343
|
+
1
|
|
344
|
+
);
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
module.exports['Line length cap rejects oversized line'] = test => {
|
|
348
|
+
// A server that never sends a line terminator must not grow the line buffer without bound.
|
|
349
|
+
const stream = new ImapStream({ cid: 'test', maxLineLength: 16 });
|
|
350
|
+
let errored = false;
|
|
351
|
+
|
|
352
|
+
stream.on('error', err => {
|
|
353
|
+
errored = true;
|
|
354
|
+
test.equal(err.code, 'LineTooLarge', 'error code should be LineTooLarge');
|
|
355
|
+
test.equal(err.maxSize, 16, 'error should report the configured cap');
|
|
356
|
+
stream.destroy();
|
|
357
|
+
test.done();
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
stream.on('end', () => {
|
|
361
|
+
if (!errored) {
|
|
362
|
+
test.ok(false, 'expected a LineTooLarge error');
|
|
363
|
+
test.done();
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// 24 bytes, no LF, written across chunks -> exceeds the 16 byte cap.
|
|
368
|
+
stream.write(Buffer.from('AAAAAAAA'));
|
|
369
|
+
stream.write(Buffer.from('BBBBBBBB'));
|
|
370
|
+
stream.write(Buffer.from('CCCCCCCC'));
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
module.exports['Line length cap allows line within limit'] = test => {
|
|
374
|
+
// A normal line under the configured cap must still parse cleanly.
|
|
375
|
+
const stream = new ImapStream({ cid: 'test', maxLineLength: 32 });
|
|
376
|
+
let payloads = [];
|
|
377
|
+
|
|
378
|
+
stream.on('readable', () => {
|
|
379
|
+
let cmd;
|
|
380
|
+
while ((cmd = stream.read()) !== null) {
|
|
381
|
+
payloads.push(cmd.payload.toString());
|
|
382
|
+
cmd.next();
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
stream.on('error', err => test.ifError(err));
|
|
387
|
+
|
|
388
|
+
stream.on('end', () => {
|
|
389
|
+
test.deepEqual(payloads, ['A NOOP'], 'line under the cap should parse');
|
|
390
|
+
test.done();
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
stream.end(Buffer.from('A NOOP\r\n'));
|
|
394
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const proxyquire = require('proxyquire').noCallThru();
|
|
4
|
+
const { EventEmitter } = require('events');
|
|
4
5
|
|
|
5
6
|
// Mock socket object
|
|
6
7
|
const createMockSocket = () => ({
|
|
@@ -10,6 +11,16 @@ const createMockSocket = () => ({
|
|
|
10
11
|
destroy: () => {}
|
|
11
12
|
});
|
|
12
13
|
|
|
14
|
+
// Real EventEmitter-backed mock socket, for tests that need listener tracking / emit.
|
|
15
|
+
const createMockEmitterSocket = () => Object.assign(new EventEmitter(), { write() {}, end() {}, destroy() {} });
|
|
16
|
+
|
|
17
|
+
// Asserts proxyConnection returned the socket and guarded it with an error listener.
|
|
18
|
+
const assertEarlyErrorHandler = (test, socket, mockSocket) => {
|
|
19
|
+
test.equal(socket, mockSocket);
|
|
20
|
+
test.ok(socket.listenerCount('error') >= 1, 'proxy socket carries an error listener before being returned');
|
|
21
|
+
test.doesNotThrow(() => socket.emit('error', new Error('early proxy error')), 'an early proxy socket error does not throw');
|
|
22
|
+
};
|
|
23
|
+
|
|
13
24
|
// Mock logger
|
|
14
25
|
const createMockLogger = () => {
|
|
15
26
|
const logs = { info: [], error: [] };
|
|
@@ -539,3 +550,78 @@ module.exports['Proxy Connection: SOCKS with username only'] = async test => {
|
|
|
539
550
|
await proxyConnection(logger, 'socks5://testuser@proxy.example.com:1080', '192.168.1.1', 993);
|
|
540
551
|
test.done();
|
|
541
552
|
};
|
|
553
|
+
|
|
554
|
+
// ============================================
|
|
555
|
+
// Early error handler (no unhandled 'error' before ImapFlow takes over)
|
|
556
|
+
// ============================================
|
|
557
|
+
|
|
558
|
+
module.exports['Proxy Connection: HTTP socket has early error handler before return'] = async test => {
|
|
559
|
+
const mockSocket = createMockEmitterSocket();
|
|
560
|
+
const logger = createMockLogger();
|
|
561
|
+
|
|
562
|
+
const { proxyConnection } = proxyquire('../lib/proxy-connection', {
|
|
563
|
+
'nodemailer/lib/smtp-connection/http-proxy-client': (url, port, host, cb) => {
|
|
564
|
+
cb(null, mockSocket);
|
|
565
|
+
},
|
|
566
|
+
socks: { SocksClient: {} },
|
|
567
|
+
dns: { promises: { resolve: async () => ['127.0.0.1'] } },
|
|
568
|
+
net: { isIP: () => true }
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
const socket = await proxyConnection(logger, 'http://proxy.example.com:8080', '192.168.1.1', 993);
|
|
572
|
+
|
|
573
|
+
assertEarlyErrorHandler(test, socket, mockSocket);
|
|
574
|
+
test.done();
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
module.exports['Proxy Connection: SOCKS socket has early error handler before return'] = async test => {
|
|
578
|
+
const mockSocket = createMockEmitterSocket();
|
|
579
|
+
const logger = createMockLogger();
|
|
580
|
+
|
|
581
|
+
const { proxyConnection } = proxyquire('../lib/proxy-connection', {
|
|
582
|
+
'nodemailer/lib/smtp-connection/http-proxy-client': () => {},
|
|
583
|
+
socks: {
|
|
584
|
+
SocksClient: {
|
|
585
|
+
createConnection: async () => ({ socket: mockSocket })
|
|
586
|
+
}
|
|
587
|
+
},
|
|
588
|
+
dns: { promises: { resolve: async () => ['127.0.0.1'] } },
|
|
589
|
+
net: { isIP: () => true }
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
const socket = await proxyConnection(logger, 'socks5://proxy.example.com:1080', '192.168.1.1', 993);
|
|
593
|
+
|
|
594
|
+
assertEarlyErrorHandler(test, socket, mockSocket);
|
|
595
|
+
test.done();
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
// detachEarlyErrorHandler must remove the guard once the caller takes ownership of the socket,
|
|
599
|
+
// so it no longer swallows/logs errors meant for the new owner's handlers.
|
|
600
|
+
module.exports['Proxy Connection: detachEarlyErrorHandler removes the early guard'] = async test => {
|
|
601
|
+
const mockSocket = createMockEmitterSocket();
|
|
602
|
+
const logger = createMockLogger();
|
|
603
|
+
|
|
604
|
+
const { proxyConnection, detachEarlyErrorHandler } = proxyquire('../lib/proxy-connection', {
|
|
605
|
+
'nodemailer/lib/smtp-connection/http-proxy-client': (url, port, host, cb) => {
|
|
606
|
+
cb(null, mockSocket);
|
|
607
|
+
},
|
|
608
|
+
socks: { SocksClient: {} },
|
|
609
|
+
dns: { promises: { resolve: async () => ['127.0.0.1'] } },
|
|
610
|
+
net: { isIP: () => true }
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
const socket = await proxyConnection(logger, 'http://proxy.example.com:8080', '192.168.1.1', 993);
|
|
614
|
+
test.ok(socket.listenerCount('error') >= 1, 'early error handler attached on return');
|
|
615
|
+
test.ok(typeof socket._earlyErrorHandler === 'function', 'handler reference stored on the socket');
|
|
616
|
+
|
|
617
|
+
detachEarlyErrorHandler(socket);
|
|
618
|
+
|
|
619
|
+
test.equal(socket.listenerCount('error'), 0, 'early error handler removed after detach');
|
|
620
|
+
test.equal(socket._earlyErrorHandler, null, 'stored handler reference cleared');
|
|
621
|
+
|
|
622
|
+
// Detaching again (or on a bare socket) must be a safe no-op.
|
|
623
|
+
test.doesNotThrow(() => detachEarlyErrorHandler(socket));
|
|
624
|
+
test.doesNotThrow(() => detachEarlyErrorHandler(createMockEmitterSocket()));
|
|
625
|
+
|
|
626
|
+
test.done();
|
|
627
|
+
};
|
|
@@ -368,3 +368,91 @@ module.exports['Reliability: decoder emit(error) does not crash when user has no
|
|
|
368
368
|
|
|
369
369
|
test.done();
|
|
370
370
|
};
|
|
371
|
+
|
|
372
|
+
// ============================================================================
|
|
373
|
+
// Throttle back-off timer is tracked and abortable on close()
|
|
374
|
+
// ============================================================================
|
|
375
|
+
|
|
376
|
+
// Feed a single throttling BAD response into reader() once, then null.
|
|
377
|
+
const stubThrottleResponse = (client, backoffMs) => {
|
|
378
|
+
let request = { tag: 'A001', command: 'FETCH', resolve: () => {}, reject: () => {} };
|
|
379
|
+
client.requestTagMap = new Map([['A001', request]]);
|
|
380
|
+
|
|
381
|
+
let done = false;
|
|
382
|
+
client.streamer.read = () => {
|
|
383
|
+
if (done) {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
done = true;
|
|
387
|
+
return {
|
|
388
|
+
payload: Buffer.from(`A001 BAD Request is throttled. Suggested Backoff Time: ${backoffMs} milliseconds`),
|
|
389
|
+
literals: [],
|
|
390
|
+
next: () => {}
|
|
391
|
+
};
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
return request;
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
module.exports['Reliability: throttle back-off aborts promptly on close()'] = async test => {
|
|
398
|
+
let client = new ImapFlow({
|
|
399
|
+
host: 'imap.example.com',
|
|
400
|
+
port: 993,
|
|
401
|
+
auth: { user: 'test', pass: 'test' },
|
|
402
|
+
logger: false
|
|
403
|
+
});
|
|
404
|
+
client.socket = { destroyed: false, destroy: () => {} };
|
|
405
|
+
client.writeSocket = client.socket;
|
|
406
|
+
|
|
407
|
+
let rejected = null;
|
|
408
|
+
let request = stubThrottleResponse(client, 300000); // 5 min back-off
|
|
409
|
+
request.reject = err => {
|
|
410
|
+
rejected = err;
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
let start = Date.now();
|
|
414
|
+
let readerDone = client.reader().catch(() => {});
|
|
415
|
+
|
|
416
|
+
// Let reader() reach the (tracked) back-off wait.
|
|
417
|
+
await new Promise(r => setTimeout(r, 50));
|
|
418
|
+
test.ok(client._throttleTimer, 'back-off timer is tracked while waiting');
|
|
419
|
+
|
|
420
|
+
client.close();
|
|
421
|
+
await new Promise(r => setImmediate(r));
|
|
422
|
+
|
|
423
|
+
test.ok(rejected, 'request rejected promptly after close()');
|
|
424
|
+
test.equal(rejected.code, 'NoConnection', 'rejected with connection error, not ETHROTTLE');
|
|
425
|
+
test.equal(client._throttleTimer, null, 'throttle timer cleared on close()');
|
|
426
|
+
test.ok(Date.now() - start < 5000, 'settled well under the 5-minute cap');
|
|
427
|
+
|
|
428
|
+
await readerDone;
|
|
429
|
+
test.done();
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
module.exports['Reliability: throttle back-off still rejects ETHROTTLE on normal expiry'] = async test => {
|
|
433
|
+
let client = new ImapFlow({
|
|
434
|
+
host: 'imap.example.com',
|
|
435
|
+
port: 993,
|
|
436
|
+
auth: { user: 'test', pass: 'test' },
|
|
437
|
+
logger: false
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
let rejected = null;
|
|
441
|
+
let request = stubThrottleResponse(client, 50); // 50ms back-off
|
|
442
|
+
request.reject = err => {
|
|
443
|
+
rejected = err;
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
let readerDone = client.reader().catch(() => {});
|
|
447
|
+
|
|
448
|
+
await new Promise(r => setTimeout(r, 250));
|
|
449
|
+
|
|
450
|
+
test.ok(rejected, 'request rejected after the back-off elapses');
|
|
451
|
+
test.equal(rejected.code, 'ETHROTTLE', 'normal expiry still rejects ETHROTTLE');
|
|
452
|
+
test.equal(rejected.throttleReset, 50, 'throttleReset preserved');
|
|
453
|
+
test.equal(client._throttleTimer, null, 'throttle timer cleared after normal expiry');
|
|
454
|
+
|
|
455
|
+
await readerDone;
|
|
456
|
+
client.close();
|
|
457
|
+
test.done();
|
|
458
|
+
};
|