imapflow 1.5.0 → 1.6.1

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.
Files changed (36) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +22 -0
  3. package/CLAUDE.md +1 -1
  4. package/lib/commands/idle.js +197 -104
  5. package/lib/commands/list.js +15 -7
  6. package/lib/commands/quota.js +3 -0
  7. package/lib/commands/select.js +5 -0
  8. package/lib/commands/status.js +4 -0
  9. package/lib/connection-deadline.js +98 -0
  10. package/lib/handler/imap-compiler.js +8 -5
  11. package/lib/handler/imap-stream.js +141 -50
  12. package/lib/handler/limits.js +43 -0
  13. package/lib/handler/token-parser.js +31 -1
  14. package/lib/imap-flow.d.ts +33 -5
  15. package/lib/imap-flow.js +583 -281
  16. package/lib/proxy-connection.js +393 -98
  17. package/lib/special-use.js +660 -51
  18. package/lib/tools.js +17 -0
  19. package/package.json +2 -2
  20. package/test/commands-branches-test.js +17 -1
  21. package/test/commands-integration-test.js +24 -2
  22. package/test/fixtures/fake-timers.js +115 -0
  23. package/test/handler-branches-test.js +0 -25
  24. package/test/idle-polling-test.js +349 -0
  25. package/test/imap-flow-compress-test.js +12 -0
  26. package/test/imap-flow-coverage-test.js +3 -3
  27. package/test/imap-flow-internals-test.js +23 -0
  28. package/test/imap-flow-proxy-paths-test.js +151 -0
  29. package/test/imap-flow-secure-test.js +159 -0
  30. package/test/imap-flow-server-test.js +186 -0
  31. package/test/parser-limits-test.js +274 -0
  32. package/test/proxy-connection-test.js +553 -442
  33. package/test/reliability-improvements-test.js +87 -0
  34. package/test/special-use-test.js +337 -0
  35. package/test/tag-correlation-test.js +333 -0
  36. package/test/timer-policy-test.js +214 -0
@@ -1,27 +1,80 @@
1
1
  'use strict';
2
2
 
3
- const proxyquire = require('proxyquire').noCallThru();
3
+ // Proxy connection tests.
4
+ //
5
+ // ImapFlow owns HTTP/HTTPS CONNECT negotiation and applies one connection-wide deadline to DNS,
6
+ // proxy negotiation and the transport handshake. The DNS policy differs per proxy protocol, so
7
+ // these tests pin what each mode is handed:
8
+ // * HTTP/HTTPS - the destination hostname reaches the proxy unresolved
9
+ // * SOCKS4 - destination hostnames are resolved locally to IPv4
10
+ // * SOCKS4a - destination hostnames are preserved for remote DNS
11
+ // * SOCKS5 - destination hostnames are preserved, IP literals pass through
12
+ // and that ImapFlow never resolves a proxy endpoint itself.
13
+
14
+ const net = require('net');
4
15
  const { EventEmitter } = require('events');
16
+ const proxyquire = require('proxyquire');
17
+ const { ConnectionDeadline } = require('../lib/connection-deadline');
18
+
19
+ // Socket stand-in for the stubbed net/tls connect calls. Tests drive it by emitting 'data'.
20
+ const createFakeSocket = () => {
21
+ const socket = new EventEmitter();
22
+ socket.writes = [];
23
+ socket.unshifted = [];
24
+ socket.destroyed = false;
25
+ socket.write = chunk => {
26
+ socket.writes.push(chunk.toString('binary'));
27
+ return true;
28
+ };
29
+ socket.unshift = chunk => socket.unshifted.push(chunk.toString('binary'));
30
+ socket.destroy = () => {
31
+ socket.destroyed = true;
32
+ };
33
+ socket.setTimeout = () => {};
34
+ socket.end = () => {};
35
+ socket.paused = false;
36
+ socket.pause = () => {
37
+ socket.paused = true;
38
+ return socket;
39
+ };
40
+ socket.resume = () => {
41
+ socket.paused = false;
42
+ return socket;
43
+ };
44
+ return socket;
45
+ };
5
46
 
6
- // Mock socket object
7
- const createMockSocket = () => ({
8
- on: () => {},
9
- write: () => {},
10
- end: () => {},
11
- destroy: () => {}
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() {} });
47
+ // Builds a connect() stub that records its options and hands out fake sockets.
48
+ const createConnectStub =
49
+ ({ calls, sockets, autoConnect = true }) =>
50
+ (options, onConnected) => {
51
+ calls.push(options);
52
+ const socket = createFakeSocket();
53
+ sockets.push(socket);
54
+ if (autoConnect) {
55
+ setImmediate(() => onConnected());
56
+ }
57
+ return socket;
58
+ };
16
59
 
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
- };
60
+ // dns stub that fails loudly: no proxy mode may resolve an endpoint through ImapFlow itself.
61
+ const createDnsStub = (resolve4Result, onCall) => ({
62
+ promises: {
63
+ resolve4: async hostname => {
64
+ if (onCall) {
65
+ onCall('resolve4', hostname);
66
+ }
67
+ return resolve4Result || [];
68
+ },
69
+ resolve: async hostname => {
70
+ if (onCall) {
71
+ onCall('resolve', hostname);
72
+ }
73
+ return resolve4Result || [];
74
+ }
75
+ }
76
+ });
23
77
 
24
- // Mock logger
25
78
  const createMockLogger = () => {
26
79
  const logs = { info: [], error: [] };
27
80
  return {
@@ -31,586 +84,566 @@ const createMockLogger = () => {
31
84
  };
32
85
  };
33
86
 
87
+ const tick = () => new Promise(resolve => setImmediate(resolve));
88
+
89
+ // Loads proxy-connection with stubbed transports. `net` keeps its real address helpers so
90
+ // isIP/isIPv6 behave normally.
91
+ const loadProxyModule = ({ netConnect, tlsConnect, socksCreateConnection, dnsStub } = {}) =>
92
+ proxyquire('../lib/proxy-connection', {
93
+ net: Object.assign({}, net, netConnect ? { connect: netConnect } : {}),
94
+ tls: tlsConnect ? { connect: tlsConnect } : { connect: () => createFakeSocket() },
95
+ socks: { SocksClient: { createConnection: socksCreateConnection || (async () => ({ socket: createFakeSocket() })) } },
96
+ dns: dnsStub || createDnsStub()
97
+ });
98
+
99
+ const respondOk = socket => socket.emit('data', Buffer.from('HTTP/1.1 200 Connection established\r\n\r\n'));
100
+
101
+ // Loads the module with a stubbed transport and returns everything an HTTP CONNECT test needs:
102
+ // the recorded connect options, the fake sockets handed out, and a capturing logger. Mirrors
103
+ // socksCase() further down.
104
+ const httpCase = ({ secureProxy = false, autoConnect = true, dnsStub } = {}) => {
105
+ const calls = [];
106
+ const sockets = [];
107
+ const connectStub = createConnectStub({ calls, sockets, autoConnect });
108
+ const { proxyConnection, detachEarlyErrorHandler } = loadProxyModule({
109
+ [secureProxy ? 'tlsConnect' : 'netConnect']: connectStub,
110
+ dnsStub
111
+ });
112
+ return { proxyConnection, detachEarlyErrorHandler, calls, sockets, logger: createMockLogger() };
113
+ };
114
+
34
115
  // ============================================
35
- // HTTP Proxy Tests
116
+ // HTTP / HTTPS CONNECT
36
117
  // ============================================
37
118
 
38
- module.exports['Proxy Connection: HTTP proxy success'] = async test => {
39
- const mockSocket = createMockSocket();
40
- const logger = createMockLogger();
41
-
42
- const { proxyConnection } = proxyquire('../lib/proxy-connection', {
43
- 'nodemailer/lib/smtp-connection/http-proxy-client': (url, port, host, cb) => {
44
- cb(null, mockSocket);
45
- },
46
- socks: { SocksClient: {} },
47
- dns: { promises: { resolve: async () => ['127.0.0.1'] } },
48
- net: { isIP: () => true }
119
+ module.exports['Proxy Connection: HTTP CONNECT preserves the destination hostname'] = async test => {
120
+ let dnsCalls = [];
121
+ const { proxyConnection, calls, sockets, logger } = httpCase({
122
+ dnsStub: createDnsStub([], (method, hostname) => dnsCalls.push([method, hostname]))
49
123
  });
50
124
 
51
- const socket = await proxyConnection(logger, 'http://proxy.example.com:8080', '192.168.1.1', 993);
125
+ const promise = proxyConnection(logger, 'http://proxy.example.com:8080', 'mail.example.com', 993);
126
+ await tick();
127
+
128
+ test.deepEqual(calls[0], { host: 'proxy.example.com', port: 8080 }, 'the proxy endpoint hostname is passed unresolved');
129
+ test.ok(sockets[0].writes[0].startsWith('CONNECT mail.example.com:993 HTTP/1.1\r\n'), 'the request line carries the unresolved destination');
130
+ test.ok(sockets[0].writes[0].includes('Host: mail.example.com:993\r\n'), 'the Host header carries the unresolved destination');
131
+ test.deepEqual(dnsCalls, [], 'ImapFlow performs no DNS lookup of its own for an HTTP proxy');
52
132
 
53
- test.equal(socket, mockSocket);
133
+ respondOk(sockets[0]);
134
+ const socket = await promise;
135
+
136
+ test.equal(socket, sockets[0], 'the tunnelled socket is returned');
137
+ test.ok(socket.listenerCount('error') >= 1, 'the returned socket carries an early error listener');
54
138
  test.equal(logger._logs.info.length, 1);
55
139
  test.ok(logger._logs.info[0].msg.includes('HTTP proxy'));
56
140
  test.done();
57
141
  };
58
142
 
59
- module.exports['Proxy Connection: HTTPS proxy success'] = async test => {
60
- const mockSocket = createMockSocket();
61
- const logger = createMockLogger();
143
+ module.exports['Proxy Connection: HTTP CONNECT brackets an IPv6 destination'] = async test => {
144
+ const { proxyConnection, sockets, logger } = httpCase();
62
145
 
63
- const { proxyConnection } = proxyquire('../lib/proxy-connection', {
64
- 'nodemailer/lib/smtp-connection/http-proxy-client': (url, port, host, cb) => {
65
- cb(null, mockSocket);
66
- },
67
- socks: { SocksClient: {} },
68
- dns: { promises: { resolve: async () => ['127.0.0.1'] } },
69
- net: { isIP: () => true }
70
- });
146
+ const promise = proxyConnection(logger, 'http://proxy.example.com:8080', '2001:db8::5', 993);
147
+ await tick();
71
148
 
72
- const socket = await proxyConnection(logger, 'https://proxy.example.com:8080', '192.168.1.1', 993);
149
+ const request = sockets[0].writes[0];
150
+ test.ok(request.startsWith('CONNECT [2001:db8::5]:993 HTTP/1.1\r\n'), 'the request line uses a bracketed authority');
151
+ test.ok(request.includes('Host: [2001:db8::5]:993\r\n'), 'the Host header uses a bracketed authority');
73
152
 
74
- test.equal(socket, mockSocket);
75
- test.equal(logger._logs.info.length, 1);
153
+ respondOk(sockets[0]);
154
+ await promise;
76
155
  test.done();
77
156
  };
78
157
 
79
- module.exports['Proxy Connection: HTTP proxy with password hides it in logs'] = async test => {
80
- const mockSocket = createMockSocket();
81
- const logger = createMockLogger();
158
+ module.exports['Proxy Connection: HTTP CONNECT does not double-bracket an IPv6 destination'] = async test => {
159
+ const { proxyConnection, sockets, logger } = httpCase();
82
160
 
83
- const { proxyConnection } = proxyquire('../lib/proxy-connection', {
84
- 'nodemailer/lib/smtp-connection/http-proxy-client': (url, port, host, cb) => {
85
- cb(null, mockSocket);
86
- },
87
- socks: { SocksClient: {} },
88
- dns: { promises: { resolve: async () => ['127.0.0.1'] } },
89
- net: { isIP: () => true }
90
- });
161
+ const promise = proxyConnection(logger, 'http://proxy.example.com:8080', '[2001:db8::5]', 993);
162
+ await tick();
91
163
 
92
- await proxyConnection(logger, 'http://user:secret123@proxy.example.com:8080', '192.168.1.1', 993);
164
+ test.ok(sockets[0].writes[0].startsWith('CONNECT [2001:db8::5]:993 HTTP/1.1\r\n'), 'an already bracketed literal stays single-bracketed');
93
165
 
94
- test.equal(logger._logs.info.length, 1);
95
- test.ok(!logger._logs.info[0].proxyUrl.includes('secret123'));
96
- test.ok(logger._logs.info[0].proxyUrl.includes('(hidden)'));
166
+ respondOk(sockets[0]);
167
+ await promise;
97
168
  test.done();
98
169
  };
99
170
 
100
- module.exports['Proxy Connection: HTTP proxy failure'] = async test => {
101
- const logger = createMockLogger();
102
- const testError = new Error('Connection refused');
171
+ module.exports['Proxy Connection: HTTP proxy endpoint given as an IPv6 URL uses a bare literal'] = async test => {
172
+ const { proxyConnection, calls, sockets, logger } = httpCase();
103
173
 
104
- const { proxyConnection } = proxyquire('../lib/proxy-connection', {
105
- 'nodemailer/lib/smtp-connection/http-proxy-client': (url, port, host, cb) => {
106
- cb(testError);
107
- },
108
- socks: { SocksClient: {} },
109
- dns: { promises: { resolve: async () => ['127.0.0.1'] } },
110
- net: { isIP: () => true }
111
- });
174
+ const promise = proxyConnection(logger, 'http://[2001:db8::1]:8080', 'mail.example.com', 993);
175
+ await tick();
112
176
 
113
- try {
114
- await proxyConnection(logger, 'http://proxy.example.com:8080', '192.168.1.1', 993);
115
- test.ok(false, 'Should have thrown');
116
- } catch (err) {
117
- test.equal(err, testError);
118
- test.equal(logger._logs.error.length, 1);
119
- test.ok(logger._logs.error[0].msg.includes('Failed'));
120
- }
177
+ test.equal(calls[0].host, '2001:db8::1', 'socket options get the bare literal, not the bracketed URL form');
121
178
 
179
+ respondOk(sockets[0]);
180
+ await promise;
181
+
182
+ test.ok(logger._logs.info[0].proxyUrl.includes('[2001:db8::1]'), 'logs keep a valid bracketed URL');
122
183
  test.done();
123
184
  };
124
185
 
125
- module.exports['Proxy Connection: HTTP proxy failure hides password'] = async test => {
126
- const logger = createMockLogger();
186
+ module.exports['Proxy Connection: HTTPS proxy sets SNI for a hostname endpoint only'] = async test => {
187
+ let { proxyConnection, calls: hostnameCalls, sockets: hostnameSockets, logger } = httpCase({ secureProxy: true });
127
188
 
128
- const { proxyConnection } = proxyquire('../lib/proxy-connection', {
129
- 'nodemailer/lib/smtp-connection/http-proxy-client': (url, port, host, cb) => {
130
- cb(new Error('Failed'));
131
- },
132
- socks: { SocksClient: {} },
133
- dns: { promises: { resolve: async () => ['127.0.0.1'] } },
134
- net: { isIP: () => true }
135
- });
189
+ let promise = proxyConnection(logger, 'https://proxy.example.com:8443', 'mail.example.com', 993);
190
+ await tick();
136
191
 
137
- try {
138
- await proxyConnection(logger, 'http://user:secret@proxy.example.com:8080', '192.168.1.1', 993);
139
- } catch (err) {
140
- // Error expected - we just need to verify logging
141
- err.expected = true;
142
- test.ok(!logger._logs.error[0].proxyUrl.includes('secret'));
143
- test.ok(logger._logs.error[0].proxyUrl.includes('(hidden)'));
144
- }
192
+ test.equal(hostnameCalls[0].servername, 'proxy.example.com', 'SNI and hostname verification target the proxy, not the IMAP destination');
193
+ respondOk(hostnameSockets[0]);
194
+ await promise;
195
+
196
+ let { proxyConnection: connectViaLiteral, calls: literalCalls, sockets: literalSockets } = httpCase({ secureProxy: true });
197
+ promise = connectViaLiteral(logger, 'https://10.0.0.1:8443', 'mail.example.com', 993);
198
+ await tick();
199
+
200
+ test.equal(literalCalls[0].servername, undefined, 'an IP-literal endpoint gets no servername');
201
+ test.equal(literalCalls[0].host, '10.0.0.1');
202
+ respondOk(literalSockets[0]);
203
+ await promise;
145
204
 
146
205
  test.done();
147
206
  };
148
207
 
149
- // ============================================
150
- // SOCKS Proxy Tests
151
- // ============================================
208
+ module.exports['Proxy Connection: HTTP CONNECT sends Basic credentials and keeps them out of logs'] = async test => {
209
+ const { proxyConnection, sockets, logger } = httpCase();
152
210
 
153
- module.exports['Proxy Connection: SOCKS5 proxy success'] = async test => {
154
- const mockSocket = createMockSocket();
155
- const logger = createMockLogger();
211
+ const promise = proxyConnection(logger, 'http://user:secret123@proxy.example.com:8080', 'mail.example.com', 993);
212
+ await tick();
156
213
 
157
- const { proxyConnection } = proxyquire('../lib/proxy-connection', {
158
- 'nodemailer/lib/smtp-connection/http-proxy-client': () => {},
159
- socks: {
160
- SocksClient: {
161
- createConnection: async opts => {
162
- test.equal(opts.proxy.type, 5);
163
- test.equal(opts.command, 'connect');
164
- return { socket: mockSocket };
165
- }
166
- }
167
- },
168
- dns: { promises: { resolve: async () => ['127.0.0.1'] } },
169
- net: { isIP: () => true }
170
- });
214
+ const request = sockets[0].writes[0];
215
+ const authHeader = request.match(/Proxy-Authorization: Basic (\S+)/);
216
+ test.ok(authHeader, 'credentials are sent in the Proxy-Authorization header');
217
+ test.equal(Buffer.from(authHeader[1], 'base64').toString(), 'user:secret123');
218
+ test.ok(!request.includes('secret123'), 'credentials appear only base64 encoded, never in clear text');
171
219
 
172
- const socket = await proxyConnection(logger, 'socks5://proxy.example.com:1080', '192.168.1.1', 993);
220
+ respondOk(sockets[0]);
221
+ await promise;
173
222
 
174
- test.equal(socket, mockSocket);
175
- test.equal(logger._logs.info.length, 1);
176
- test.ok(logger._logs.info[0].msg.includes('SOCKS proxy'));
223
+ test.ok(!logger._logs.info[0].proxyUrl.includes('secret123'), 'the success log is redacted');
224
+ test.ok(logger._logs.info[0].proxyUrl.includes('(hidden)'));
177
225
  test.done();
178
226
  };
179
227
 
180
- module.exports['Proxy Connection: SOCKS proxy (defaults to SOCKS5)'] = async test => {
181
- const mockSocket = createMockSocket();
182
- const logger = createMockLogger();
228
+ module.exports['Proxy Connection: HTTP CONNECT rejects a non-2xx response and redacts the log'] = async test => {
229
+ const { proxyConnection, sockets, logger } = httpCase();
183
230
 
184
- const { proxyConnection } = proxyquire('../lib/proxy-connection', {
185
- 'nodemailer/lib/smtp-connection/http-proxy-client': () => {},
186
- socks: {
187
- SocksClient: {
188
- createConnection: async opts => {
189
- test.equal(opts.proxy.type, 5); // Default to SOCKS5
190
- return { socket: mockSocket };
191
- }
192
- }
193
- },
194
- dns: { promises: { resolve: async () => ['127.0.0.1'] } },
195
- net: { isIP: () => true }
196
- });
231
+ const promise = proxyConnection(logger, 'http://user:secret123@proxy.example.com:8080', 'mail.example.com', 993);
232
+ await tick();
233
+ sockets[0].emit('data', Buffer.from('HTTP/1.1 407 Proxy Authentication Required\r\n\r\n'));
197
234
 
198
- await proxyConnection(logger, 'socks://proxy.example.com:1080', '192.168.1.1', 993);
235
+ let err = await promise.then(() => null).catch(e => e);
236
+ test.ok(err, 'a refused CONNECT rejects');
237
+ test.equal(err.code, 'EPROXY');
238
+ test.ok(/407/.test(err.message), 'the status code is reported');
239
+ test.ok(sockets[0].destroyed, 'the socket is destroyed');
240
+ test.ok(!logger._logs.error[0].proxyUrl.includes('secret123'), 'the failure log is redacted');
241
+ test.ok(logger._logs.error[0].proxyUrl.includes('(hidden)'));
199
242
  test.done();
200
243
  };
201
244
 
202
- module.exports['Proxy Connection: SOCKS4 proxy'] = async test => {
203
- const mockSocket = createMockSocket();
204
- const logger = createMockLogger();
245
+ module.exports['Proxy Connection: HTTP CONNECT bounds the response header buffer'] = async test => {
246
+ const { proxyConnection, sockets, logger } = httpCase();
205
247
 
206
- const { proxyConnection } = proxyquire('../lib/proxy-connection', {
207
- 'nodemailer/lib/smtp-connection/http-proxy-client': () => {},
208
- socks: {
209
- SocksClient: {
210
- createConnection: async opts => {
211
- test.equal(opts.proxy.type, 4);
212
- return { socket: mockSocket };
213
- }
214
- }
215
- },
216
- dns: { promises: { resolve: async () => ['127.0.0.1'] } },
217
- net: { isIP: () => true }
218
- });
248
+ const promise = proxyConnection(logger, 'http://proxy.example.com:8080', 'mail.example.com', 993);
249
+ await tick();
219
250
 
220
- await proxyConnection(logger, 'socks4://proxy.example.com:1080', '192.168.1.1', 993);
251
+ // A proxy that never sends the header terminator must not grow memory without bound
252
+ for (let i = 0; i < 10 && !sockets[0].destroyed; i++) {
253
+ sockets[0].emit('data', Buffer.alloc(16 * 1024, 0x41));
254
+ }
255
+
256
+ let err = await promise.then(() => null).catch(e => e);
257
+ test.ok(err, 'the oversized header response rejects');
258
+ test.equal(err.code, 'EPROXY');
259
+ test.ok(/headers too large/i.test(err.message));
260
+ test.ok(sockets[0].destroyed, 'the socket is destroyed');
221
261
  test.done();
222
262
  };
223
263
 
224
- module.exports['Proxy Connection: SOCKS4a proxy'] = async test => {
225
- const mockSocket = createMockSocket();
226
- const logger = createMockLogger();
264
+ module.exports['Proxy Connection: HTTP CONNECT preserves bytes after the header terminator'] = async test => {
265
+ const { proxyConnection, sockets, logger } = httpCase();
227
266
 
228
- const { proxyConnection } = proxyquire('../lib/proxy-connection', {
229
- 'nodemailer/lib/smtp-connection/http-proxy-client': () => {},
230
- socks: {
231
- SocksClient: {
232
- createConnection: async opts => {
233
- test.equal(opts.proxy.type, 4);
234
- return { socket: mockSocket };
235
- }
236
- }
237
- },
238
- dns: { promises: { resolve: async () => ['127.0.0.1'] } },
239
- net: { isIP: () => true }
240
- });
267
+ const promise = proxyConnection(logger, 'http://proxy.example.com:8080', 'mail.example.com', 993);
268
+ await tick();
269
+
270
+ // The destination greeting arrives in the same segment as the CONNECT response
271
+ sockets[0].emit('data', Buffer.from('HTTP/1.1 200 OK\r\n\r\n* OK [CAPABILITY IMAP4rev1] ready\r\n'));
241
272
 
242
- await proxyConnection(logger, 'socks4a://proxy.example.com:1080', '192.168.1.1', 993);
273
+ const socket = await promise;
274
+ test.deepEqual(socket.unshifted, ['* OK [CAPABILITY IMAP4rev1] ready\r\n'], 'trailing bytes are pushed back for the next consumer');
243
275
  test.done();
244
276
  };
245
277
 
246
- module.exports['Proxy Connection: SOCKS proxy with authentication'] = async test => {
247
- const mockSocket = createMockSocket();
248
- const logger = createMockLogger();
278
+ module.exports['Proxy Connection: HTTP CONNECT rejects a destination with CRLF'] = async test => {
279
+ const { proxyConnection, calls, logger } = httpCase();
249
280
 
250
- const { proxyConnection } = proxyquire('../lib/proxy-connection', {
251
- 'nodemailer/lib/smtp-connection/http-proxy-client': () => {},
252
- socks: {
253
- SocksClient: {
254
- createConnection: async opts => {
255
- test.equal(opts.proxy.userId, 'testuser');
256
- test.equal(opts.proxy.password, 'testpass');
257
- return { socket: mockSocket };
258
- }
259
- }
260
- },
261
- dns: { promises: { resolve: async () => ['127.0.0.1'] } },
262
- net: { isIP: () => true }
263
- });
281
+ let err = await proxyConnection(logger, 'http://proxy.example.com:8080', 'mail.example.com\r\nX-Injected: 1', 993)
282
+ .then(() => null)
283
+ .catch(e => e);
264
284
 
265
- await proxyConnection(logger, 'socks5://testuser:testpass@proxy.example.com:1080', '192.168.1.1', 993);
285
+ test.ok(err, 'header injection through the destination is rejected');
286
+ test.equal(err.code, 'EPROXY');
287
+ test.deepEqual(calls, [], 'no socket is opened for an invalid destination');
266
288
  test.done();
267
289
  };
268
290
 
269
- module.exports['Proxy Connection: SOCKS proxy hides password in logs'] = async test => {
270
- const mockSocket = createMockSocket();
271
- const logger = createMockLogger();
291
+ module.exports['Proxy Connection: HTTP CONNECT rejects an invalid destination port'] = async test => {
292
+ const { proxyConnection, logger } = httpCase();
272
293
 
273
- const { proxyConnection } = proxyquire('../lib/proxy-connection', {
274
- 'nodemailer/lib/smtp-connection/http-proxy-client': () => {},
275
- socks: {
276
- SocksClient: {
277
- createConnection: async () => ({ socket: mockSocket })
278
- }
279
- },
280
- dns: { promises: { resolve: async () => ['127.0.0.1'] } },
281
- net: { isIP: () => true }
282
- });
294
+ let err = await proxyConnection(logger, 'http://proxy.example.com:8080', 'mail.example.com', 0)
295
+ .then(() => null)
296
+ .catch(e => e);
297
+
298
+ test.ok(err, 'a missing destination port is rejected');
299
+ test.equal(err.code, 'EPROXY');
300
+ test.done();
301
+ };
283
302
 
284
- await proxyConnection(logger, 'socks5://user:secretpass@proxy.example.com:1080', '192.168.1.1', 993);
303
+ module.exports['Proxy Connection: an early socket close rejects the tunnel'] = async test => {
304
+ const { proxyConnection, sockets, logger } = httpCase();
285
305
 
286
- test.ok(!logger._logs.info[0].proxyUrl.includes('secretpass'));
287
- test.ok(logger._logs.info[0].proxyUrl.includes('(hidden)'));
306
+ const promise = proxyConnection(logger, 'http://proxy.example.com:8080', 'mail.example.com', 993);
307
+ await tick();
308
+ sockets[0].emit('close');
309
+
310
+ let err = await promise.then(() => null).catch(e => e);
311
+ test.ok(err, 'a proxy that hangs up before responding rejects');
312
+ test.equal(err.code, 'EPROXY');
288
313
  test.done();
289
314
  };
290
315
 
291
- module.exports['Proxy Connection: SOCKS proxy default port'] = async test => {
292
- const mockSocket = createMockSocket();
293
- const logger = createMockLogger();
316
+ module.exports['Proxy Connection: a late socket event cannot settle the tunnel twice'] = async test => {
317
+ const { proxyConnection, sockets, logger } = httpCase();
294
318
 
295
- const { proxyConnection } = proxyquire('../lib/proxy-connection', {
296
- 'nodemailer/lib/smtp-connection/http-proxy-client': () => {},
297
- socks: {
298
- SocksClient: {
299
- createConnection: async opts => {
300
- test.equal(opts.proxy.port, 1080); // Default SOCKS port
301
- return { socket: mockSocket };
302
- }
303
- }
304
- },
305
- dns: { promises: { resolve: async () => ['127.0.0.1'] } },
306
- net: { isIP: () => true }
307
- });
319
+ const promise = proxyConnection(logger, 'http://proxy.example.com:8080', 'mail.example.com', 993);
320
+ await tick();
321
+ respondOk(sockets[0]);
322
+ const socket = await promise;
308
323
 
309
- await proxyConnection(logger, 'socks5://proxy.example.com', '192.168.1.1', 993);
324
+ // Late events after settlement: the temporary listeners are gone, so nothing re-settles and
325
+ // nothing throws. Only the early error guard remains.
326
+ test.equal(socket.listenerCount('close'), 0, 'the temporary close listener was removed');
327
+ test.doesNotThrow(() => socket.emit('error', new Error('late error')), 'a late error is absorbed by the early error guard');
328
+ test.doesNotThrow(() => socket.emit('data', Buffer.from('HTTP/1.1 500 late\r\n\r\n')), 'late data is no longer parsed as a CONNECT response');
329
+ test.equal(logger._logs.error.length, 1, 'the late error was logged once by the early guard, not reported as a proxy failure');
310
330
  test.done();
311
331
  };
312
332
 
313
- module.exports['Proxy Connection: SOCKS proxy failure'] = async test => {
314
- const logger = createMockLogger();
315
- const testError = new Error('SOCKS connection failed');
333
+ // ============================================
334
+ // Connection deadline
335
+ // ============================================
316
336
 
317
- const { proxyConnection } = proxyquire('../lib/proxy-connection', {
318
- 'nodemailer/lib/smtp-connection/http-proxy-client': () => {},
319
- socks: {
320
- SocksClient: {
321
- createConnection: async () => {
322
- throw testError;
323
- }
324
- }
325
- },
326
- dns: { promises: { resolve: async () => ['127.0.0.1'] } },
327
- net: { isIP: () => true }
328
- });
337
+ module.exports['Proxy Connection: HTTP CONNECT timeout destroys the in-flight socket'] = async test => {
338
+ const { proxyConnection, sockets, logger } = httpCase();
329
339
 
330
- try {
331
- await proxyConnection(logger, 'socks5://proxy.example.com:1080', '192.168.1.1', 993);
332
- test.ok(false, 'Should have thrown');
333
- } catch (err) {
334
- test.equal(err, testError);
335
- test.equal(logger._logs.error.length, 1);
336
- test.ok(logger._logs.error[0].msg.includes('Failed'));
337
- }
340
+ // The proxy accepts the connection and never answers the CONNECT request
341
+ const promise = proxyConnection(logger, 'http://proxy.example.com:8080', 'mail.example.com', 993, { connectionTimeout: 60 });
338
342
 
343
+ let err = await promise.then(() => null).catch(e => e);
344
+ test.ok(err, 'the stalled CONNECT rejects');
345
+ test.equal(err.code, 'CONNECT_TIMEOUT', 'a proxy phase expiry uses the shared connection timeout code');
346
+ test.equal(err.details.connectionTimeout, 60, 'the configured timeout is reported');
347
+ test.ok(sockets[0].destroyed, 'the in-flight socket is destroyed immediately');
339
348
  test.done();
340
349
  };
341
350
 
342
- module.exports['Proxy Connection: SOCKS proxy failure hides password'] = async test => {
343
- const logger = createMockLogger();
351
+ module.exports['Proxy Connection: a stalled endpoint connection still hits the deadline'] = async test => {
352
+ // autoConnect false: the dependency-level connection never completes
353
+ const { proxyConnection, sockets, logger } = httpCase({ autoConnect: false });
344
354
 
345
- const { proxyConnection } = proxyquire('../lib/proxy-connection', {
346
- 'nodemailer/lib/smtp-connection/http-proxy-client': () => {},
347
- socks: {
348
- SocksClient: {
349
- createConnection: async () => {
350
- throw new Error('Failed');
351
- }
352
- }
353
- },
354
- dns: { promises: { resolve: async () => ['127.0.0.1'] } },
355
- net: { isIP: () => true }
356
- });
355
+ let err = await proxyConnection(logger, 'http://proxy.example.com:8080', 'mail.example.com', 993, { connectionTimeout: 60 })
356
+ .then(() => null)
357
+ .catch(e => e);
357
358
 
358
- try {
359
- await proxyConnection(logger, 'socks5://user:secret@proxy.example.com:1080', '192.168.1.1', 993);
360
- } catch (err) {
361
- // Error expected - we just need to verify logging
362
- err.expected = true;
363
- test.ok(!logger._logs.error[0].proxyUrl.includes('secret'));
364
- test.ok(logger._logs.error[0].proxyUrl.includes('(hidden)'));
365
- }
359
+ test.ok(err, 'a connection that never establishes rejects');
360
+ test.equal(err.code, 'CONNECT_TIMEOUT');
361
+ test.ok(sockets[0].destroyed, 'the shared deadline destroys the stalled socket');
362
+ test.done();
363
+ };
364
+
365
+ module.exports['Proxy Connection: concurrent HTTP deadlines are independent'] = async test => {
366
+ const { proxyConnection, sockets, logger } = httpCase();
367
+
368
+ // A short-deadline connection that stalls and a long-deadline connection that succeeds
369
+ const stalled = proxyConnection(logger, 'http://proxy.example.com:8080', 'stalled.example.com', 993, { connectionTimeout: 60 })
370
+ .then(() => null)
371
+ .catch(e => e);
372
+ const healthy = proxyConnection(logger, 'http://proxy.example.com:8080', 'healthy.example.com', 993, { connectionTimeout: 5000 });
373
+
374
+ await tick();
375
+ test.equal(sockets.length, 2, 'both connections opened their own socket');
376
+
377
+ let err = await stalled;
378
+ test.equal(err.code, 'CONNECT_TIMEOUT', 'the short deadline expired on its own connection');
379
+ test.ok(sockets[0].destroyed, 'only the stalled socket was destroyed');
380
+ test.ok(!sockets[1].destroyed, 'the other connection is untouched by the expired deadline');
381
+
382
+ respondOk(sockets[1]);
383
+ const socket = await healthy;
384
+ test.equal(socket, sockets[1], 'the longer deadline still completes normally');
385
+ test.done();
386
+ };
366
387
 
388
+ module.exports['Proxy Connection: an exhausted deadline rejects before any work starts'] = async test => {
389
+ const { proxyConnection, calls, logger } = httpCase();
390
+
391
+ const deadline = new ConnectionDeadline(50);
392
+ await new Promise(resolve => setTimeout(resolve, 80));
393
+
394
+ let err = await proxyConnection(logger, 'http://proxy.example.com:8080', 'mail.example.com', 993, { deadline })
395
+ .then(() => null)
396
+ .catch(e => e);
397
+
398
+ test.ok(err, 'no phase is started once the budget is gone');
399
+ test.equal(err.code, 'CONNECT_TIMEOUT');
400
+ test.deepEqual(calls, [], 'no socket was opened');
367
401
  test.done();
368
402
  };
369
403
 
370
404
  // ============================================
371
- // DNS Resolution Tests
405
+ // SOCKS
372
406
  // ============================================
373
407
 
374
- module.exports['Proxy Connection: Resolves hostname to IP'] = async test => {
375
- const mockSocket = createMockSocket();
408
+ const socksCase = async ({ proxyUrl, host, port = 993, resolve4Result, dnsCalls }) => {
409
+ const options = [];
376
410
  const logger = createMockLogger();
377
- let resolvedHost = null;
411
+ const socket = createFakeSocket();
378
412
 
379
- const { proxyConnection } = proxyquire('../lib/proxy-connection', {
380
- 'nodemailer/lib/smtp-connection/http-proxy-client': (url, port, host, cb) => {
381
- resolvedHost = host;
382
- cb(null, mockSocket);
413
+ const { proxyConnection } = loadProxyModule({
414
+ socksCreateConnection: async opts => {
415
+ options.push(opts);
416
+ return { socket };
383
417
  },
384
- socks: { SocksClient: {} },
385
- dns: {
386
- promises: {
387
- resolve: async hostname => {
388
- if (hostname === 'mail.example.com') {
389
- return ['93.184.216.34'];
390
- }
391
- return [];
392
- }
393
- }
394
- },
395
- net: { isIP: host => /^\d+\.\d+\.\d+\.\d+$/.test(host) }
418
+ dnsStub: createDnsStub(resolve4Result, dnsCalls ? (method, hostname) => dnsCalls.push([method, hostname]) : null)
396
419
  });
397
420
 
398
- await proxyConnection(logger, 'http://proxy.example.com:8080', 'mail.example.com', 993);
421
+ const result = await proxyConnection(logger, proxyUrl, host, port).then(
422
+ value => ({ value }),
423
+ err => ({ err })
424
+ );
425
+
426
+ return { options, logger, socket, result };
427
+ };
399
428
 
400
- test.equal(resolvedHost, '93.184.216.34');
429
+ module.exports['Proxy Connection: SOCKS5 preserves the destination hostname for remote DNS'] = async test => {
430
+ const dnsCalls = [];
431
+ const { options, result } = await socksCase({ proxyUrl: 'socks5://proxy.example.com:1080', host: 'mail.example.com', dnsCalls });
432
+
433
+ test.equal(options[0].proxy.type, 5);
434
+ test.equal(options[0].proxy.host, 'proxy.example.com', 'the endpoint hostname reaches the dependency unresolved');
435
+ test.equal(options[0].destination.host, 'mail.example.com', 'the destination hostname is left for the proxy to resolve');
436
+ test.equal(options[0].command, 'connect');
437
+ test.ok(options[0].timeout > 0, 'a strictly positive timeout is passed, never zero');
438
+ test.deepEqual(dnsCalls, [], 'ImapFlow resolves nothing for SOCKS5');
439
+ test.ok(result.value, 'the socket is returned');
401
440
  test.done();
402
441
  };
403
442
 
404
- module.exports['Proxy Connection: Skips DNS for IP addresses'] = async test => {
405
- const mockSocket = createMockSocket();
406
- const logger = createMockLogger();
407
- let dnsResolveCalled = false;
443
+ module.exports['Proxy Connection: SOCKS (alias) defaults to SOCKS5'] = async test => {
444
+ const { options } = await socksCase({ proxyUrl: 'socks://proxy.example.com:1080', host: 'mail.example.com' });
445
+ test.equal(options[0].proxy.type, 5);
446
+ test.equal(options[0].destination.host, 'mail.example.com');
447
+ test.done();
448
+ };
408
449
 
409
- const { proxyConnection } = proxyquire('../lib/proxy-connection', {
410
- 'nodemailer/lib/smtp-connection/http-proxy-client': (url, port, host, cb) => {
411
- cb(null, mockSocket);
412
- },
413
- socks: { SocksClient: {} },
414
- dns: {
415
- promises: {
416
- resolve: async () => {
417
- dnsResolveCalled = true;
418
- return ['127.0.0.1'];
419
- }
420
- }
421
- },
422
- net: { isIP: () => true } // Pretend it's already an IP
423
- });
450
+ module.exports['Proxy Connection: SOCKS5 passes IP literals through unchanged'] = async test => {
451
+ let { options } = await socksCase({ proxyUrl: 'socks5://proxy.example.com:1080', host: '192.168.1.1' });
452
+ test.equal(options[0].destination.host, '192.168.1.1', 'IPv4 literal unchanged');
424
453
 
425
- await proxyConnection(logger, 'http://proxy.example.com:8080', '192.168.1.1', 993);
454
+ ({ options } = await socksCase({ proxyUrl: 'socks5://proxy.example.com:1080', host: '2001:db8::9' }));
455
+ test.equal(options[0].destination.host, '2001:db8::9', 'IPv6 literal unchanged');
456
+ test.done();
457
+ };
426
458
 
427
- test.equal(dnsResolveCalled, false);
459
+ module.exports['Proxy Connection: SOCKS4a preserves the destination hostname for remote DNS'] = async test => {
460
+ const dnsCalls = [];
461
+ const { options } = await socksCase({ proxyUrl: 'socks4a://proxy.example.com:1080', host: 'mail.example.com', dnsCalls });
462
+
463
+ test.equal(options[0].proxy.type, 4, 'the dependency uses proxy type 4 for both SOCKS4 and SOCKS4a');
464
+ test.equal(options[0].destination.host, 'mail.example.com', 'the hostname is preserved so the proxy resolves it');
465
+ test.deepEqual(dnsCalls, [], 'no local lookup for SOCKS4a');
428
466
  test.done();
429
467
  };
430
468
 
431
- module.exports['Proxy Connection: SOCKS resolves proxy hostname'] = async test => {
432
- const mockSocket = createMockSocket();
433
- const logger = createMockLogger();
434
- let proxyHostResolved = null;
435
-
436
- const { proxyConnection } = proxyquire('../lib/proxy-connection', {
437
- 'nodemailer/lib/smtp-connection/http-proxy-client': () => {},
438
- socks: {
439
- SocksClient: {
440
- createConnection: async opts => {
441
- proxyHostResolved = opts.proxy.host;
442
- return { socket: mockSocket };
443
- }
444
- }
445
- },
446
- dns: {
447
- promises: {
448
- resolve: async hostname => {
449
- if (hostname === 'proxy.example.com') {
450
- return ['10.0.0.1'];
451
- }
452
- return ['127.0.0.1'];
453
- }
454
- }
455
- },
456
- net: { isIP: host => /^\d+\.\d+\.\d+\.\d+$/.test(host) }
469
+ module.exports['Proxy Connection: SOCKS4 resolves the destination locally to IPv4'] = async test => {
470
+ const dnsCalls = [];
471
+ const { options } = await socksCase({
472
+ proxyUrl: 'socks4://proxy.example.com:1080',
473
+ host: 'mail.example.com',
474
+ resolve4Result: ['93.184.216.34'],
475
+ dnsCalls
457
476
  });
458
477
 
459
- await proxyConnection(logger, 'socks5://proxy.example.com:1080', '192.168.1.1', 993);
478
+ test.equal(options[0].proxy.type, 4);
479
+ test.equal(options[0].destination.host, '93.184.216.34', 'SOCKS4 gets a resolved IPv4 address, not a hostname');
480
+ test.deepEqual(dnsCalls, [['resolve4', 'mail.example.com']], 'the lookup is explicitly IPv4 only');
481
+ test.done();
482
+ };
483
+
484
+ module.exports['Proxy Connection: SOCKS4 reports an unresolvable destination'] = async test => {
485
+ const { result } = await socksCase({
486
+ proxyUrl: 'socks4://proxy.example.com:1080',
487
+ host: 'mail.example.com',
488
+ resolve4Result: []
489
+ });
460
490
 
461
- test.equal(proxyHostResolved, '10.0.0.1');
491
+ test.ok(result.err, 'an empty IPv4 lookup fails the connection');
492
+ test.equal(result.err.code, 'EPROXY');
462
493
  test.done();
463
494
  };
464
495
 
465
- // ============================================
466
- // Edge Cases
467
- // ============================================
496
+ module.exports['Proxy Connection: SOCKS4 and SOCKS4a reject IPv6 destinations clearly'] = async test => {
497
+ for (let proxyUrl of ['socks4://proxy.example.com:1080', 'socks4a://proxy.example.com:1080']) {
498
+ const { options, result, logger } = await socksCase({ proxyUrl, host: '2001:db8::9' });
468
499
 
469
- module.exports['Proxy Connection: Unknown protocol returns undefined'] = async test => {
470
- const logger = createMockLogger();
500
+ test.ok(result.err, `${proxyUrl} rejects an IPv6 destination`);
501
+ test.equal(result.err.code, 'UnsupportedProxyAddress', 'the failure names the unsupported address type');
502
+ test.deepEqual(options, [], 'no request is emitted with the address in the wrong field');
503
+ test.equal(logger._logs.error.length, 1, 'the failure is logged');
504
+ }
505
+ test.done();
506
+ };
471
507
 
472
- const { proxyConnection } = proxyquire('../lib/proxy-connection', {
473
- 'nodemailer/lib/smtp-connection/http-proxy-client': () => {},
474
- socks: { SocksClient: {} },
475
- dns: { promises: { resolve: async () => ['127.0.0.1'] } },
476
- net: { isIP: () => true }
477
- });
508
+ module.exports['Proxy Connection: bracketed IPv6 proxy URL yields a bare literal endpoint'] = async test => {
509
+ const { options, logger } = await socksCase({ proxyUrl: 'socks5://[2001:db8::1]:1080', host: 'mail.example.com' });
478
510
 
479
- const result = await proxyConnection(logger, 'ftp://proxy.example.com:21', '192.168.1.1', 993);
511
+ test.equal(options[0].proxy.host, '2001:db8::1', 'brackets are stripped before the address reaches the dependency');
512
+ test.ok(logger._logs.info[0].proxyUrl.includes('[2001:db8::1]'), 'logs keep a valid bracketed URL');
513
+ test.done();
514
+ };
480
515
 
481
- test.equal(result, undefined);
516
+ module.exports['Proxy Connection: SOCKS passes credentials and hides them in logs'] = async test => {
517
+ const { options, logger } = await socksCase({ proxyUrl: 'socks5://testuser:testpass@proxy.example.com:1080', host: 'mail.example.com' });
518
+
519
+ test.equal(options[0].proxy.userId, 'testuser');
520
+ test.equal(options[0].proxy.password, 'testpass');
521
+ test.ok(!logger._logs.info[0].proxyUrl.includes('testpass'));
522
+ test.ok(logger._logs.info[0].proxyUrl.includes('(hidden)'));
482
523
  test.done();
483
524
  };
484
525
 
485
- module.exports['Proxy Connection: HTTP proxy with no socket returned'] = async test => {
526
+ module.exports['Proxy Connection: SOCKS with username only'] = async test => {
527
+ const { options } = await socksCase({ proxyUrl: 'socks5://testuser@proxy.example.com:1080', host: 'mail.example.com' });
528
+ test.equal(options[0].proxy.userId, 'testuser');
529
+ test.equal(options[0].proxy.password, '', 'empty string from URL parsing');
530
+ test.done();
531
+ };
532
+
533
+ module.exports['Proxy Connection: SOCKS default port'] = async test => {
534
+ const { options } = await socksCase({ proxyUrl: 'socks5://proxy.example.com', host: 'mail.example.com' });
535
+ test.equal(options[0].proxy.port, 1080);
536
+ test.done();
537
+ };
538
+
539
+ module.exports['Proxy Connection: SOCKS failure is reported and redacted'] = async test => {
486
540
  const logger = createMockLogger();
541
+ const testError = new Error('SOCKS connection failed');
487
542
 
488
- const { proxyConnection } = proxyquire('../lib/proxy-connection', {
489
- 'nodemailer/lib/smtp-connection/http-proxy-client': (url, port, host, cb) => {
490
- cb(null, null); // No socket
491
- },
492
- socks: { SocksClient: {} },
493
- dns: { promises: { resolve: async () => ['127.0.0.1'] } },
494
- net: { isIP: () => true }
543
+ const { proxyConnection } = loadProxyModule({
544
+ socksCreateConnection: async () => {
545
+ throw testError;
546
+ }
495
547
  });
496
548
 
497
- const socket = await proxyConnection(logger, 'http://proxy.example.com:8080', '192.168.1.1', 993);
549
+ let err = await proxyConnection(logger, 'socks5://user:secret@proxy.example.com:1080', 'mail.example.com', 993)
550
+ .then(() => null)
551
+ .catch(e => e);
498
552
 
499
- test.equal(socket, null);
500
- // No log when socket is null
501
- test.equal(logger._logs.info.length, 0);
553
+ test.equal(err, testError, 'the dependency error is preserved');
554
+ test.equal(logger._logs.error.length, 1);
555
+ test.ok(!logger._logs.error[0].proxyUrl.includes('secret'));
556
+ test.ok(logger._logs.error[0].proxyUrl.includes('(hidden)'));
502
557
  test.done();
503
558
  };
504
559
 
505
- module.exports['Proxy Connection: DNS returns empty result'] = async test => {
506
- const mockSocket = createMockSocket();
560
+ module.exports['Proxy Connection: SOCKS returning no socket fails'] = async test => {
507
561
  const logger = createMockLogger();
508
- let usedHost = null;
509
562
 
510
- const { proxyConnection } = proxyquire('../lib/proxy-connection', {
511
- 'nodemailer/lib/smtp-connection/http-proxy-client': (url, port, host, cb) => {
512
- usedHost = host;
513
- cb(null, mockSocket);
514
- },
515
- socks: { SocksClient: {} },
516
- dns: {
517
- promises: {
518
- resolve: async () => [] // Empty result
519
- }
520
- },
521
- net: { isIP: () => false }
522
- });
563
+ const { proxyConnection } = loadProxyModule({ socksCreateConnection: async () => ({}) });
523
564
 
524
- await proxyConnection(logger, 'http://proxy.example.com:8080', 'mail.example.com', 993);
565
+ let err = await proxyConnection(logger, 'socks5://proxy.example.com:1080', 'mail.example.com', 993)
566
+ .then(() => null)
567
+ .catch(e => e);
525
568
 
526
- // Should keep original hostname when DNS returns empty
527
- test.equal(usedHost, 'mail.example.com');
569
+ test.ok(err, 'a missing socket is an error rather than an undefined return');
570
+ test.equal(err.code, 'EPROXY');
528
571
  test.done();
529
572
  };
530
573
 
531
- module.exports['Proxy Connection: SOCKS with username only'] = async test => {
532
- const mockSocket = createMockSocket();
574
+ module.exports['Proxy Connection: the SOCKS dependency timeout is normalized to CONNECT_TIMEOUT'] = async test => {
533
575
  const logger = createMockLogger();
534
576
 
535
- const { proxyConnection } = proxyquire('../lib/proxy-connection', {
536
- 'nodemailer/lib/smtp-connection/http-proxy-client': () => {},
537
- socks: {
538
- SocksClient: {
539
- createConnection: async opts => {
540
- test.equal(opts.proxy.userId, 'testuser');
541
- test.equal(opts.proxy.password, ''); // Empty string from URL parsing
542
- return { socket: mockSocket };
543
- }
544
- }
545
- },
546
- dns: { promises: { resolve: async () => ['127.0.0.1'] } },
547
- net: { isIP: () => true }
577
+ const { proxyConnection } = loadProxyModule({
578
+ socksCreateConnection: async () => {
579
+ // the shape the socks client uses for its own expiry
580
+ throw new Error('Proxy connection timed out');
581
+ }
548
582
  });
549
583
 
550
- await proxyConnection(logger, 'socks5://testuser@proxy.example.com:1080', '192.168.1.1', 993);
584
+ let err = await proxyConnection(logger, 'socks5://proxy.example.com:1080', 'mail.example.com', 993, { connectionTimeout: 5000 })
585
+ .then(() => null)
586
+ .catch(e => e);
587
+
588
+ test.equal(err.code, 'CONNECT_TIMEOUT', 'SOCKS and HTTP expose the same timeout code');
589
+ test.equal(err.details.connectionTimeout, 5000, 'the normalized error carries the configured timeout');
590
+ test.ok(err._err, 'the dependency error is kept for diagnostics');
551
591
  test.done();
552
592
  };
553
593
 
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();
594
+ module.exports['Proxy Connection: a stalled SOCKS negotiation hits the shared deadline'] = async test => {
560
595
  const logger = createMockLogger();
561
596
 
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 }
597
+ const { proxyConnection } = loadProxyModule({
598
+ socksCreateConnection: () => new Promise(() => {}) // never settles
569
599
  });
570
600
 
571
- const socket = await proxyConnection(logger, 'http://proxy.example.com:8080', '192.168.1.1', 993);
601
+ let err = await proxyConnection(logger, 'socks5://proxy.example.com:1080', 'mail.example.com', 993, { connectionTimeout: 60 })
602
+ .then(() => null)
603
+ .catch(e => e);
572
604
 
573
- assertEarlyErrorHandler(test, socket, mockSocket);
605
+ test.equal(err.code, 'CONNECT_TIMEOUT', 'the shared deadline bounds the dependency');
574
606
  test.done();
575
607
  };
576
608
 
577
- module.exports['Proxy Connection: SOCKS socket has early error handler before return'] = async test => {
578
- const mockSocket = createMockEmitterSocket();
609
+ module.exports['Proxy Connection: SOCKS4 local lookup runs inside the deadline'] = async test => {
579
610
  const logger = createMockLogger();
580
611
 
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 }
612
+ const { proxyConnection } = loadProxyModule({
613
+ socksCreateConnection: async () => ({ socket: createFakeSocket() }),
614
+ dnsStub: { promises: { resolve4: () => new Promise(() => {}), resolve: () => new Promise(() => {}) } }
590
615
  });
591
616
 
592
- const socket = await proxyConnection(logger, 'socks5://proxy.example.com:1080', '192.168.1.1', 993);
617
+ let err = await proxyConnection(logger, 'socks4://proxy.example.com:1080', 'mail.example.com', 993, { connectionTimeout: 60 })
618
+ .then(() => null)
619
+ .catch(e => e);
593
620
 
594
- assertEarlyErrorHandler(test, socket, mockSocket);
621
+ test.equal(err.code, 'CONNECT_TIMEOUT', 'a stalled DNS lookup cannot outlive the connection timeout');
595
622
  test.done();
596
623
  };
597
624
 
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();
625
+ // ============================================
626
+ // Edge cases
627
+ // ============================================
628
+
629
+ module.exports['Proxy Connection: Unknown protocol returns undefined'] = async test => {
602
630
  const logger = createMockLogger();
631
+ const { proxyConnection } = loadProxyModule({});
603
632
 
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
- });
633
+ const result = await proxyConnection(logger, 'ftp://proxy.example.com:21', 'mail.example.com', 993);
634
+
635
+ test.equal(result, undefined);
636
+ test.done();
637
+ };
638
+
639
+ module.exports['Proxy Connection: detachEarlyErrorHandler removes the early guard'] = async test => {
640
+ const { proxyConnection, detachEarlyErrorHandler, sockets, logger } = httpCase();
641
+
642
+ const promise = proxyConnection(logger, 'http://proxy.example.com:8080', 'mail.example.com', 993);
643
+ await tick();
644
+ respondOk(sockets[0]);
645
+ const socket = await promise;
612
646
 
613
- const socket = await proxyConnection(logger, 'http://proxy.example.com:8080', '192.168.1.1', 993);
614
647
  test.ok(socket.listenerCount('error') >= 1, 'early error handler attached on return');
615
648
  test.ok(typeof socket._earlyErrorHandler === 'function', 'handler reference stored on the socket');
616
649
 
@@ -621,7 +654,85 @@ module.exports['Proxy Connection: detachEarlyErrorHandler removes the early guar
621
654
 
622
655
  // Detaching again (or on a bare socket) must be a safe no-op.
623
656
  test.doesNotThrow(() => detachEarlyErrorHandler(socket));
624
- test.doesNotThrow(() => detachEarlyErrorHandler(createMockEmitterSocket()));
657
+ test.doesNotThrow(() => detachEarlyErrorHandler(createFakeSocket()));
658
+
659
+ test.done();
660
+ };
661
+
662
+ module.exports['Proxy Connection: socket without .on skips early error handler attach'] = async test => {
663
+ const logger = createMockLogger();
664
+ const { proxyConnection } = loadProxyModule({ socksCreateConnection: async () => ({ socket: { write() {} } }) });
665
+
666
+ const socket = await proxyConnection(logger, 'socks5://proxy.example.com:1080', 'mail.example.com', 993);
667
+
668
+ test.ok(socket, 'a socket-like object without an event emitter interface is still returned');
669
+ test.equal(socket._earlyErrorHandler, undefined, 'no handler is attached to it');
670
+ test.done();
671
+ };
672
+
673
+ // ============================================
674
+ // Credential handling and malformed userinfo
675
+ // ============================================
676
+
677
+ module.exports['Proxy Connection: a proxy password with a bare percent sign still connects'] = async test => {
678
+ // URL userinfo is percent-encoded, so a bare '%' is not valid encoding. Decoding it must not
679
+ // throw out of the connect callback, which would both crash the process and leave the
680
+ // connection promise pending forever.
681
+ const { proxyConnection, sockets, logger } = httpCase();
682
+
683
+ const promise = proxyConnection(logger, 'http://user:p%ss@proxy.example.com:8080', 'mail.example.com', 993);
684
+ await tick();
685
+
686
+ const authHeader = sockets[0].writes[0].match(/Proxy-Authorization: Basic (\S+)/);
687
+ test.ok(authHeader, 'credentials are still sent');
688
+ test.equal(Buffer.from(authHeader[1], 'base64').toString(), 'user:p%ss', 'the undecodable value is used as it came in');
689
+
690
+ respondOk(sockets[0]);
691
+ const socket = await promise;
692
+ test.ok(socket, 'the tunnel is established');
693
+ test.done();
694
+ };
695
+
696
+ module.exports['Proxy Connection: a SOCKS failure never carries the proxy password'] = async test => {
697
+ // The socks client attaches its full options object - password included - to the errors it
698
+ // throws, and a logger that serializes error properties would write it out in clear text.
699
+ const logger = createMockLogger();
700
+
701
+ const { proxyConnection } = loadProxyModule({
702
+ socksCreateConnection: async opts => {
703
+ let err = new Error('Socks5 proxy rejected connection - Failure');
704
+ err.options = opts; // this is what socks does
705
+ throw err;
706
+ }
707
+ });
708
+
709
+ let err = await proxyConnection(logger, 'socks5://user:secretpass@proxy.example.com:1080', 'mail.example.com', 993)
710
+ .then(() => null)
711
+ .catch(e => e);
712
+
713
+ test.ok(err, 'the failure still propagates');
714
+ test.equal(err.options, undefined, 'the credential-bearing options object is stripped from the error');
715
+ test.ok(!JSON.stringify(logger._logs.error).includes('secretpass'), 'the password does not reach the log');
716
+ test.done();
717
+ };
718
+
719
+ module.exports['Proxy Connection: a normalized SOCKS timeout carries no credentials either'] = async test => {
720
+ const logger = createMockLogger();
721
+
722
+ const { proxyConnection } = loadProxyModule({
723
+ socksCreateConnection: async opts => {
724
+ let err = new Error('Proxy connection timed out');
725
+ err.options = opts;
726
+ throw err;
727
+ }
728
+ });
729
+
730
+ let err = await proxyConnection(logger, 'socks5://user:secretpass@proxy.example.com:1080', 'mail.example.com', 993, { connectionTimeout: 5000 })
731
+ .then(() => null)
732
+ .catch(e => e);
625
733
 
734
+ test.equal(err.code, 'CONNECT_TIMEOUT');
735
+ test.equal(err._err.options, undefined, 'the retained diagnostic error is stripped too');
736
+ test.ok(!JSON.stringify(logger._logs.error).includes('secretpass'), 'the password does not reach the log');
626
737
  test.done();
627
738
  };