Haraka 3.1.3 → 3.1.5

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 (65) hide show
  1. package/.prettierignore +2 -0
  2. package/CONTRIBUTORS.md +23 -1
  3. package/Changes.md +52 -0
  4. package/Plugins.md +81 -64
  5. package/README.md +1 -1
  6. package/bin/haraka +7 -5
  7. package/connection.js +15 -19
  8. package/docs/Plugins.md +1 -1
  9. package/docs/plugins/aliases.md +0 -2
  10. package/docs/plugins/queue/qmail-queue.md +0 -1
  11. package/logger.js +2 -2
  12. package/outbound/hmail.js +76 -83
  13. package/outbound/index.js +36 -34
  14. package/outbound/queue.js +231 -176
  15. package/package.json +26 -29
  16. package/plugins/prevent_credential_leaks.js +2 -2
  17. package/plugins/process_title.js +1 -1
  18. package/plugins/queue/smtp_forward.js +5 -5
  19. package/plugins/status.js +8 -5
  20. package/plugins/tls.js +1 -1
  21. package/plugins.js +19 -14
  22. package/rfc1869.js +10 -10
  23. package/run_tests +8 -2
  24. package/server.js +15 -10
  25. package/smtp_client.js +10 -15
  26. package/test/config/tls/haraka.local.pem +47 -47
  27. package/test/connection.js +286 -147
  28. package/test/endpoint.js +5 -4
  29. package/test/fixtures/line_socket.js +1 -0
  30. package/test/fixtures/util_hmailitem.js +1 -1
  31. package/test/host_pool.js +57 -31
  32. package/test/logger.js +75 -135
  33. package/test/outbound/bounce_net_errors.js +132 -0
  34. package/test/outbound/bounce_rfc3464.js +226 -0
  35. package/test/outbound/hmail.js +140 -104
  36. package/test/outbound/index.js +61 -101
  37. package/test/outbound/qfile.js +25 -25
  38. package/test/outbound/queue.js +233 -0
  39. package/test/plugins/auth/auth_base.js +39 -44
  40. package/test/plugins/auth/auth_vpopmaild.js +8 -9
  41. package/test/plugins/queue/smtp_forward.js +953 -183
  42. package/test/plugins/rcpt_to.host_list_base.js +58 -93
  43. package/test/plugins/rcpt_to.in_host_list.js +126 -175
  44. package/test/plugins/record_envelope_addresses.js +93 -0
  45. package/test/plugins/status.js +10 -10
  46. package/test/plugins/tls.js +11 -21
  47. package/test/plugins/xclient.js +102 -0
  48. package/test/plugins.js +10 -13
  49. package/test/rfc1869.js +71 -48
  50. package/test/server.js +281 -436
  51. package/test/smtp_client.js +1194 -220
  52. package/test/tls_socket.js +74 -243
  53. package/test/transaction.js +486 -201
  54. package/tls_socket.js +19 -23
  55. package/transaction.js +33 -10
  56. package/config/rabbitmq.ini +0 -10
  57. package/config/rabbitmq_amqplib.ini +0 -19
  58. package/docs/plugins/queue/rabbitmq.md +0 -34
  59. package/docs/plugins/queue/rabbitmq_amqplib.md +0 -51
  60. package/plugins/queue/rabbitmq.js +0 -141
  61. package/plugins/queue/rabbitmq_amqplib.js +0 -96
  62. package/test/config/tls/ec.pem +0 -23
  63. package/test/config/tls/mismatched.pem +0 -49
  64. package/test/outbound_bounce_net_errors.js +0 -157
  65. package/test/outbound_bounce_rfc3464.js +0 -366
@@ -1,303 +1,1277 @@
1
- const assert = require('node:assert')
1
+ 'use strict'
2
+
3
+ const { describe, it, beforeEach, afterEach } = require('node:test')
4
+ const assert = require('node:assert/strict')
5
+ const { PassThrough } = require('node:stream')
2
6
  const path = require('node:path')
3
7
 
8
+ const { Address } = require('address-rfc2821')
4
9
  const fixtures = require('haraka-test-fixtures')
10
+ const net_utils = require('haraka-net-utils')
5
11
  const message = require('haraka-email-message')
6
12
 
7
- const smtp_client = require('../smtp_client')
8
- const test_socket = require('./fixtures/line_socket')
13
+ const smtp_client_module = require('../smtp_client')
14
+ const { smtp_client: SMTPClient } = smtp_client_module
15
+ const tls_socket = require('../tls_socket')
16
+ const { Socket } = require('./fixtures/line_socket')
17
+
18
+ // State enum values mirror the module-internal STATE object
19
+ const STATE = { IDLE: 1, ACTIVE: 2, RELEASED: 3, DESTROYED: 4 }
20
+
21
+ // ─── Socket / client helpers ─────────────────────────────────────────────────
9
22
 
10
- function getClientOpts(socket) {
11
- return {
23
+ function makeSocket() {
24
+ const s = new Socket(25, 'localhost')
25
+ s.write = () => true
26
+ s.upgrade = (opts, cb) => cb && cb(true, null, {}, { name: 'AES128-GCM-SHA256', version: 'TLSv1.3' })
27
+ s.remoteAddress = '1.2.3.4'
28
+ return s
29
+ }
30
+
31
+ function makeClient(opts = {}) {
32
+ const socket = 'socket' in opts ? opts.socket : makeSocket()
33
+ return new SMTPClient({
34
+ host: 'mx.example.com',
12
35
  port: 25,
13
- host: 'localhost',
14
- connect_timeout: 30,
36
+ connect_timeout: 10,
15
37
  idle_timeout: 30,
16
38
  socket,
17
- }
39
+ ...opts,
40
+ })
18
41
  }
19
42
 
20
- describe('smtp_client', () => {
21
- it('testUpgradeIsCalledOnSTARTTLS', () => {
22
- const plugin = new fixtures.plugin('queue/smtp_forward')
43
+ // Stub tls_socket.connect so get_client / get_client_plugin don't open real sockets
44
+ let _origTlsConnect
45
+ function mockTlsConnect(socketFactory) {
46
+ _origTlsConnect = tls_socket.connect
47
+ tls_socket.connect =
48
+ socketFactory ||
49
+ (() => {
50
+ const s = makeSocket()
51
+ net_utils.add_line_processor(s)
52
+ return s
53
+ })
54
+ }
55
+ function restoreTlsConnect() {
56
+ if (_origTlsConnect) tls_socket.connect = _origTlsConnect
57
+ }
23
58
 
24
- // switch config directory to 'test/config'
25
- plugin.config = plugin.config.module_config(path.resolve('test'))
59
+ function makeConnection(overrides = {}) {
60
+ const conn = fixtures.connection.createConnection()
61
+ conn.server = { notes: {} }
62
+ conn.hello = { host: 'client.example.com' }
63
+ conn.local = { host: 'relay.example.com' }
64
+ conn.remote = { ip: '1.2.3.4' }
65
+ conn.transaction = null
66
+ return Object.assign(conn, overrides)
67
+ }
26
68
 
27
- plugin.register()
69
+ function makePlugin() {
70
+ const p = new fixtures.plugin('queue/smtp_forward')
71
+ p.config = p.config.module_config(path.resolve('test'))
72
+ p.register()
73
+ p.tls_options = {}
74
+ return p
75
+ }
28
76
 
29
- const cmds = {}
30
- let upgradeArgs = {}
77
+ // ─── Constructor ─────────────────────────────────────────────────────────────
31
78
 
32
- const socket = {
33
- setTimeout: (arg) => {},
34
- setKeepAlive: (arg) => {},
35
- on: (eventName, callback) => {
36
- cmds[eventName] = callback
37
- },
38
- upgrade: (arg) => {
39
- upgradeArgs = arg
40
- },
79
+ describe('SMTPClient constructor', () => {
80
+ it('initialises default properties', () => {
81
+ const client = makeClient()
82
+ assert.equal(client.command, 'greeting')
83
+ assert.deepEqual(client.response, [])
84
+ assert.equal(client.connected, false)
85
+ assert.equal(client.authenticating, false)
86
+ assert.equal(client.authenticated, false)
87
+ assert.deepEqual(client.auth_capabilities, [])
88
+ assert.equal(client.host, 'mx.example.com')
89
+ assert.equal(client.port, 25)
90
+ assert.equal(client.smtputf8, false)
91
+ assert.ok(client.uuid)
92
+ assert.equal(client.state, STATE.IDLE)
93
+ })
94
+
95
+ it('parses connect_timeout from opts', () => {
96
+ const client = makeClient({ connect_timeout: '45' })
97
+ assert.equal(client.connect_timeout, 45)
98
+ })
99
+
100
+ it('defaults connect_timeout to 30', () => {
101
+ const client = makeClient({ connect_timeout: undefined })
102
+ assert.equal(client.connect_timeout, 30)
103
+ })
104
+
105
+ it('calls setTimeout and setKeepAlive on the socket', () => {
106
+ const socket = makeSocket()
107
+ let timeoutSet = false
108
+ socket.setTimeout = () => {
109
+ timeoutSet = true
41
110
  }
111
+ socket.setKeepAlive = () => {}
112
+ new SMTPClient({ host: 'mx.example.com', port: 25, socket })
113
+ assert.ok(timeoutSet)
114
+ })
115
+ })
42
116
 
43
- const client = new smtp_client.smtp_client(getClientOpts(socket))
44
- client.load_tls_config({ key: Buffer.from('OutboundTlsKeyLoaded') })
117
+ // ─── Line handler ────────────────────────────────────────────────────────────
118
+
119
+ describe('SMTPClient line handler', () => {
120
+ let client
121
+
122
+ beforeEach(() => {
123
+ client = makeClient()
124
+ })
125
+
126
+ it('emits error and destroys on unrecognised SMTP line', () => {
127
+ const errors = []
128
+ client.on('error', (e) => errors.push(e))
129
+ client.socket.emit('line', 'not-smtp\r\n')
130
+ assert.ok(errors.length === 1)
131
+ assert.ok(/Unrecognized response/.test(errors[0]))
132
+ assert.equal(client.state, STATE.DESTROYED)
133
+ })
134
+
135
+ it('accumulates multi-line responses (continuation marker)', () => {
136
+ client.command = 'ehlo'
137
+ // Send multi-line: first line has '-' continuation
138
+ client.socket.emit('line', '250-mx.example.com Hello\r\n')
139
+ assert.deepEqual(client.response, ['mx.example.com Hello'])
140
+ // No event emitted yet for ehlo — it requires a ' ' terminator
141
+ })
142
+
143
+ it('emits greeting EHLO on 220 response', () => {
144
+ let greetingArg = null
145
+ client.on('greeting', (cmd) => {
146
+ greetingArg = cmd
147
+ })
148
+ client.socket.emit('line', '220 hello server\r\n')
149
+ assert.equal(greetingArg, 'EHLO')
150
+ assert.equal(client.connected, true)
151
+ })
152
+
153
+ it('emits helo after ehlo 250', () => {
154
+ client.command = 'ehlo'
155
+ let heloFired = false
156
+ client.on('helo', () => {
157
+ heloFired = true
158
+ })
159
+ client.socket.emit('line', '250 OK\r\n')
160
+ assert.ok(heloFired)
161
+ })
162
+
163
+ it('falls back to HELO when EHLO is rejected with 5xx', () => {
164
+ client.command = 'ehlo'
165
+ let greetingArg = null
166
+ client.on('greeting', (cmd) => {
167
+ greetingArg = cmd
168
+ })
169
+ client.socket.emit('line', '502 EHLO not supported\r\n')
170
+ assert.equal(greetingArg, 'HELO')
171
+ })
45
172
 
173
+ it('emits capabilities on EHLO 2xx then returns if command changed', () => {
174
+ client.command = 'ehlo'
175
+ let capsFired = false
176
+ client.on('capabilities', () => {
177
+ capsFired = true
178
+ client.command = 'starttls' // simulate command change inside handler
179
+ })
180
+ client.socket.emit('line', '250 OK\r\n')
181
+ assert.ok(capsFired)
182
+ // helo should NOT have been emitted because command changed in capabilities handler
183
+ })
184
+
185
+ it('emits helo/mail/rcpt/data/dot/rset/auth for their commands', () => {
186
+ const commands = ['helo', 'mail', 'rcpt', 'data', 'dot', 'rset', 'auth']
187
+ for (const cmd of commands) {
188
+ const c = makeClient()
189
+ c.command = cmd
190
+ let fired = false
191
+ c.on(cmd, () => {
192
+ fired = true
193
+ })
194
+ c.socket.emit('line', '250 OK\r\n')
195
+ assert.ok(fired, `expected '${cmd}' event to fire`)
196
+ }
197
+ })
198
+
199
+ it('emits quit and destroys on quit 2xx', () => {
200
+ client.command = 'quit'
201
+ let quitFired = false
202
+ client.on('quit', () => {
203
+ quitFired = true
204
+ })
205
+ client.socket.emit('line', '221 Bye\r\n')
206
+ assert.ok(quitFired)
207
+ assert.equal(client.state, STATE.DESTROYED)
208
+ })
209
+
210
+ it('sets xclient flag and emits xclient on XCLIENT success', () => {
211
+ client.command = 'xclient'
212
+ let xclientArg = null
213
+ client.on('xclient', (arg) => {
214
+ xclientArg = arg
215
+ })
216
+ client.socket.emit('line', '220 OK\r\n')
217
+ assert.ok(client.xclient)
218
+ assert.equal(xclientArg, 'EHLO')
219
+ })
220
+
221
+ it('carries on as helo when XCLIENT is rejected with 5xx', () => {
222
+ client.command = 'xclient'
223
+ client.socket.emit('line', '503 XCLIENT not permitted\r\n')
224
+ assert.equal(client.command, 'helo')
225
+ })
226
+
227
+ it('calls upgrade on starttls response', () => {
46
228
  client.command = 'starttls'
47
- cmds.line('250 Hello client.example.com\r\n')
229
+ client.tls_options = { servername: 'mx.example.com' }
230
+ let upgradeCalled = false
231
+ client.socket.upgrade = (opts, cb) => {
232
+ upgradeCalled = true
233
+ }
234
+ client.socket.emit('line', '220 Go ahead\r\n')
235
+ assert.ok(upgradeCalled)
236
+ })
48
237
 
49
- const { StringDecoder } = require('string_decoder')
50
- const decoder = new StringDecoder('utf8')
238
+ it('emits bad_code on 4xx/5xx for active commands', () => {
239
+ client.command = 'mail'
240
+ client.state = STATE.ACTIVE
241
+ let badCode = null
242
+ client.on('bad_code', (code) => {
243
+ badCode = code
244
+ })
245
+ client.socket.emit('line', '550 Rejected\r\n')
246
+ assert.equal(badCode, '550')
247
+ })
51
248
 
52
- const cent = Buffer.from(upgradeArgs.key)
53
- assert.equal(decoder.write(cent), 'OutboundTlsKeyLoaded')
249
+ it('returns early after bad_code when state is not ACTIVE', () => {
250
+ client.command = 'mail'
251
+ client.state = STATE.IDLE
252
+ let heloFired = false
253
+ client.on('helo', () => {
254
+ heloFired = true
255
+ }) // shouldn't fire
256
+ let badCodeFired = false
257
+ client.on('bad_code', () => {
258
+ badCodeFired = true
259
+ })
260
+ client.socket.emit('line', '550 Rejected\r\n')
261
+ assert.ok(badCodeFired)
262
+ // state is IDLE so it returns early — no further dispatch
54
263
  })
55
264
 
56
- it('startTLS', () => {
57
- let cmd = ''
265
+ it('destroys on 441 Connection timed out', () => {
266
+ client.command = 'mail'
267
+ client.state = STATE.ACTIVE // must be ACTIVE to pass through bad_code without returning early
268
+ client.socket.emit('line', '441 Connection timed out\r\n')
269
+ assert.equal(client.state, STATE.DESTROYED)
270
+ })
58
271
 
59
- const socket = {
60
- setTimeout: (arg) => {},
61
- setKeepAlive: (arg) => {},
62
- on: (eventName, callback) => {},
63
- upgrade: (arg) => {},
64
- write: (arg) => {
65
- cmd = arg
272
+ it('throws on unknown command', () => {
273
+ client.command = 'unknown_cmd'
274
+ assert.throws(() => client.socket.emit('line', '250 OK\r\n'), /Unknown command: unknown_cmd/)
275
+ })
276
+
277
+ // ── Auth responses ──────────────────────────────────────────────────────
278
+
279
+ // ── Auth challenge responses (334) ─────────────────────────────────────
280
+ // Each case: [event to emit, challenge line]
281
+ for (const [event, challenge] of [
282
+ ['auth_username', '334 VXNlcm5hbWU6\r\n'], // base64('Username:')
283
+ ['auth_username', '334 dXNlcm5hbWU6\r\n'], // base64('username:') — case-insensitive workaround
284
+ ['auth_password', '334 UGFzc3dvcmQ6\r\n'], // base64('Password:')
285
+ ]) {
286
+ it(`emits ${event} on ${challenge.trim()}`, () => {
287
+ client.command = 'auth'
288
+ let fired = false
289
+ client.on(event, () => {
290
+ fired = true
291
+ })
292
+ client.socket.emit('line', challenge)
293
+ assert.ok(fired)
294
+ })
295
+ }
296
+
297
+ it('emits auth and sets authenticated on 235 while authenticating', () => {
298
+ client.command = 'auth'
299
+ client.authenticating = true
300
+ let authFired = false
301
+ client.on('auth', () => {
302
+ authFired = true
303
+ })
304
+ client.socket.emit('line', '235 Authentication successful\r\n')
305
+ assert.ok(authFired)
306
+ assert.equal(client.authenticated, true)
307
+ assert.equal(client.authenticating, false)
308
+ })
309
+
310
+ it('emits auth event via switch for auth command with 250', () => {
311
+ client.command = 'auth'
312
+ client.authenticating = false
313
+ let authFired = false
314
+ client.on('auth', () => {
315
+ authFired = true
316
+ })
317
+ client.socket.emit('line', '250 OK\r\n')
318
+ assert.ok(authFired)
319
+ })
320
+ })
321
+
322
+ // ─── Socket connect event ─────────────────────────────────────────────────────
323
+
324
+ describe('SMTPClient socket connect event', () => {
325
+ it('sets remote_ip from remoteAddress', () => {
326
+ const socket = makeSocket()
327
+ socket.remoteAddress = '::ffff:10.0.0.1'
328
+ const client = makeClient({ socket })
329
+ socket.emit('connect')
330
+ assert.equal(client.remote_ip, '10.0.0.1')
331
+ })
332
+
333
+ it('handles undefined remoteAddress without crash', () => {
334
+ const socket = makeSocket()
335
+ socket.remoteAddress = undefined
336
+ const client = makeClient({ socket })
337
+ assert.doesNotThrow(() => socket.emit('connect'))
338
+ assert.equal(client.remote_ip, undefined)
339
+ })
340
+
341
+ it('replaces timeout with idle_timeout on connect', () => {
342
+ const socket = makeSocket()
343
+ let lastTimeout = null
344
+ socket.setTimeout = (ms) => {
345
+ lastTimeout = ms
346
+ }
347
+ const client = makeClient({ socket, idle_timeout: 120 })
348
+ socket.emit('connect')
349
+ assert.equal(lastTimeout, 120_000)
350
+ })
351
+ })
352
+
353
+ // ─── closed() — socket error / timeout / close / end ─────────────────────────
354
+
355
+ describe('SMTPClient closed() handler', () => {
356
+ it('IDLE state: destroys on socket error', () => {
357
+ const client = makeClient()
358
+ assert.equal(client.state, STATE.IDLE)
359
+ client.socket.emit('error', new Error('ECONNREFUSED'))
360
+ assert.equal(client.state, STATE.DESTROYED)
361
+ })
362
+
363
+ it('ACTIVE state: emits error then destroys on socket error', () => {
364
+ const client = makeClient()
365
+ client.state = STATE.ACTIVE
366
+ const errors = []
367
+ client.on('error', (e) => errors.push(e))
368
+ client.socket.emit('error', new Error('connection dropped'))
369
+ assert.ok(errors.length === 1)
370
+ assert.ok(/SMTP connection errored/.test(errors[0]))
371
+ assert.equal(client.state, STATE.DESTROYED)
372
+ })
373
+
374
+ it('RELEASED state: destroys on socket error', () => {
375
+ const client = makeClient()
376
+ client.state = STATE.RELEASED
377
+ client.socket.emit('error', new Error('gone'))
378
+ assert.equal(client.state, STATE.DESTROYED)
379
+ })
380
+
381
+ it('DESTROYED state: emits connection-error on socket error', () => {
382
+ const client = makeClient()
383
+ client.destroy()
384
+ const connErrors = []
385
+ client.on('connection-error', (e) => connErrors.push(e))
386
+ client.socket.emit('error', new Error('late error'))
387
+ assert.ok(connErrors.length === 1)
388
+ assert.ok(/SMTP connection errored/.test(connErrors[0]))
389
+ })
390
+
391
+ it('DESTROYED state: emits connection-error on socket timeout', () => {
392
+ const client = makeClient()
393
+ client.destroy()
394
+ const connErrors = []
395
+ client.on('connection-error', (e) => connErrors.push(e))
396
+ client.socket.emit('timeout')
397
+ assert.ok(connErrors.length === 1)
398
+ assert.ok(/timed out/.test(connErrors[0]))
399
+ })
400
+
401
+ it('DESTROYED state: does NOT emit connection-error on socket close', () => {
402
+ const client = makeClient()
403
+ client.destroy()
404
+ const connErrors = []
405
+ client.on('connection-error', (e) => connErrors.push(e))
406
+ client.socket.emit('close')
407
+ assert.equal(connErrors.length, 0)
408
+ })
409
+
410
+ it('handles socket timeout in IDLE state', () => {
411
+ const client = makeClient()
412
+ client.socket.emit('timeout')
413
+ assert.equal(client.state, STATE.DESTROYED)
414
+ })
415
+
416
+ it('handles socket close in IDLE state', () => {
417
+ const client = makeClient()
418
+ client.socket.emit('close')
419
+ assert.equal(client.state, STATE.DESTROYED)
420
+ })
421
+
422
+ it('handles socket end in IDLE state', () => {
423
+ const client = makeClient()
424
+ client.socket.emit('end')
425
+ assert.equal(client.state, STATE.DESTROYED)
426
+ })
427
+
428
+ it('closed handler coerces null error to empty string', () => {
429
+ const client = makeClient()
430
+ client.state = STATE.ACTIVE
431
+ const errors = []
432
+ client.on('error', (e) => errors.push(e))
433
+ client.socket.emit('error', null)
434
+ assert.ok(errors.length === 1)
435
+ assert.ok(errors[0].includes('SMTP connection errored'))
436
+ })
437
+ })
438
+
439
+ // ─── load_tls_config ──────────────────────────────────────────────────────────
440
+
441
+ describe('SMTPClient#load_tls_config', () => {
442
+ it('sets tls_options with servername equal to host', () => {
443
+ const client = makeClient()
444
+ client.load_tls_config()
445
+ assert.equal(client.tls_options.servername, 'mx.example.com')
446
+ })
447
+
448
+ it('merges additional opts into tls_options', () => {
449
+ const client = makeClient()
450
+ client.load_tls_config({ key: Buffer.from('secret'), rejectUnauthorized: false })
451
+ assert.equal(client.tls_options.servername, 'mx.example.com')
452
+ assert.equal(client.tls_options.rejectUnauthorized, false)
453
+ assert.ok(Buffer.isBuffer(client.tls_options.key))
454
+ })
455
+ })
456
+
457
+ // ─── send_command ─────────────────────────────────────────────────────────────
458
+
459
+ describe('SMTPClient#send_command', () => {
460
+ it('writes command + CRLF to socket', () => {
461
+ const written = []
462
+ const socket = makeSocket()
463
+ socket.write = (data) => written.push(data)
464
+ const client = makeClient({ socket })
465
+ client.send_command('EHLO', 'example.com')
466
+ assert.equal(written[0], 'EHLO example.com\r\n')
467
+ assert.equal(client.command, 'ehlo')
468
+ assert.deepEqual(client.response, [])
469
+ })
470
+
471
+ it('writes just "." for dot command', () => {
472
+ const written = []
473
+ const socket = makeSocket()
474
+ socket.write = (data) => written.push(data)
475
+ const client = makeClient({ socket })
476
+ client.send_command('dot')
477
+ assert.equal(written[0], '.\r\n')
478
+ assert.equal(client.command, 'dot')
479
+ })
480
+
481
+ it('sends command without data', () => {
482
+ const written = []
483
+ const socket = makeSocket()
484
+ socket.write = (data) => written.push(data)
485
+ const client = makeClient({ socket })
486
+ client.send_command('QUIT')
487
+ assert.equal(written[0], 'QUIT\r\n')
488
+ })
489
+
490
+ it('emits client_protocol event', () => {
491
+ const lines = []
492
+ const client = makeClient()
493
+ client.on('client_protocol', (l) => lines.push(l))
494
+ client.send_command('MAIL', 'FROM:<me@example.com>')
495
+ assert.equal(lines[0], 'MAIL FROM:<me@example.com>')
496
+ })
497
+ })
498
+
499
+ // ─── start_data ───────────────────────────────────────────────────────────────
500
+
501
+ describe('SMTPClient#start_data', () => {
502
+ it('sets command to dot and resets response', () => {
503
+ const client = makeClient()
504
+ client.response = ['leftover']
505
+ const pt = new PassThrough()
506
+ pt.pipe = () => {}
507
+ client.start_data(pt)
508
+ assert.equal(client.command, 'dot')
509
+ assert.deepEqual(client.response, [])
510
+ })
511
+
512
+ it('pipes the data stream to the socket', () => {
513
+ const client = makeClient()
514
+ let pipeTarget = null
515
+ const mockStream = {
516
+ pipe: (dest, opts) => {
517
+ pipeTarget = dest
66
518
  },
67
519
  }
520
+ client.start_data(mockStream)
521
+ assert.equal(pipeTarget, client.socket)
522
+ })
523
+ })
68
524
 
69
- const client = new smtp_client.smtp_client(getClientOpts(socket))
70
- client.tls_options = {}
525
+ // ─── release ──────────────────────────────────────────────────────────────────
71
526
 
72
- client.secured = false
73
- client.response = ['STARTTLS']
527
+ describe('SMTPClient#release', () => {
528
+ it('is a no-op when already DESTROYED', () => {
529
+ const client = makeClient()
530
+ client.destroy()
531
+ assert.doesNotThrow(() => client.release())
532
+ assert.equal(client.state, STATE.DESTROYED)
533
+ })
74
534
 
75
- smtp_client.onCapabilitiesOutbound(client, false, undefined, {
76
- enable_tls: true,
77
- })
535
+ it('sends QUIT and destroys when connected', () => {
536
+ const written = []
537
+ const socket = makeSocket()
538
+ socket.write = (data) => written.push(data)
539
+ const client = makeClient({ socket })
540
+ client.connected = true
541
+ client.release()
542
+ assert.ok(written.some((l) => l === 'QUIT\r\n'))
543
+ assert.equal(client.state, STATE.DESTROYED)
544
+ })
78
545
 
79
- assert.equal(cmd, 'STARTTLS\r\n')
546
+ it('destroys without QUIT when not connected', () => {
547
+ const written = []
548
+ const socket = makeSocket()
549
+ socket.write = (data) => written.push(data)
550
+ const client = makeClient({ socket })
551
+ client.connected = false
552
+ client.release()
553
+ assert.equal(written.length, 0)
554
+ assert.equal(client.state, STATE.DESTROYED)
80
555
  })
81
556
 
82
- describe('auth', () => {
83
- beforeEach((done) => {
84
- smtp_client.get_client(
85
- { notes: {} },
86
- (client) => {
87
- this.client = client
88
- done()
89
- },
557
+ it('removes all named event listeners', () => {
558
+ const client = makeClient()
559
+ client.on('greeting', () => {})
560
+ client.on('error', () => {})
561
+ client.on('bad_code', () => {})
562
+ client.release()
563
+ assert.equal(client.listenerCount('greeting'), 0)
564
+ assert.equal(client.listenerCount('error'), 0)
565
+ assert.equal(client.listenerCount('bad_code'), 0)
566
+ })
567
+ })
568
+
569
+ // ─── destroy ──────────────────────────────────────────────────────────────────
570
+
571
+ describe('SMTPClient#destroy', () => {
572
+ it('sets state to DESTROYED and calls socket.destroy', () => {
573
+ const client = makeClient()
574
+ client.destroy()
575
+ assert.equal(client.state, STATE.DESTROYED)
576
+ assert.ok(client.socket.destroy.called)
577
+ })
578
+
579
+ it('is idempotent — second call is a no-op', () => {
580
+ const client = makeClient()
581
+ client.destroy()
582
+ const callCount = client.socket.destroy.callCount
583
+ client.destroy()
584
+ assert.equal(client.socket.destroy.callCount, callCount)
585
+ })
586
+ })
587
+
588
+ // ─── upgrade ──────────────────────────────────────────────────────────────────
589
+
590
+ describe('SMTPClient#upgrade', () => {
591
+ it('delegates to socket.upgrade with tls_options', () => {
592
+ const socket = makeSocket()
593
+ let upgradeOpts = null
594
+ socket.upgrade = (opts, cb) => {
595
+ upgradeOpts = opts
596
+ }
597
+ const client = makeClient({ socket })
598
+ const opts = { servername: 'secure.example.com', rejectUnauthorized: true }
599
+ client.upgrade(opts)
600
+ assert.deepEqual(upgradeOpts, opts)
601
+ })
602
+
603
+ it('logs upgrade details in callback', () => {
604
+ const socket = makeSocket()
605
+ socket.upgrade = (opts, cb) =>
606
+ cb(
607
+ true,
608
+ null,
90
609
  {
91
- socket: test_socket.connect(),
610
+ subject: { CN: 'example.com', O: 'Org' },
611
+ issuer: { O: 'CA' },
612
+ valid_to: '2030-01-01',
613
+ fingerprint: 'AA:BB',
92
614
  },
615
+ { name: 'AES', version: 'TLSv1.3' },
93
616
  )
94
- })
617
+ const client = makeClient({ socket })
618
+ assert.doesNotThrow(() => client.upgrade({ servername: 'example.com' }))
619
+ })
620
+ })
95
621
 
96
- it('authenticates during SMTP conversation', (done) => {
97
- const message_stream = new message.stream({ main: { spool_after: 1024 } }, '123456789')
622
+ // ─── is_dead_sender ───────────────────────────────────────────────────────────
98
623
 
99
- const data = []
100
- let reading_body = false
101
- data.push('220 hi')
624
+ describe('SMTPClient#is_dead_sender', () => {
625
+ it('returns false when connection has a transaction', () => {
626
+ const client = makeClient()
627
+ const plugin = makePlugin()
628
+ const conn = makeConnection()
629
+ conn.transaction = { mail_from: new Address('<a@b.com>') }
630
+ assert.equal(client.is_dead_sender(plugin, conn), false)
631
+ })
102
632
 
103
- this.client.on('greeting', (command) => {
104
- assert.equal(this.client.response[0], 'hi')
105
- assert.equal('EHLO', command)
106
- this.client.send_command(command, 'example.com')
107
- })
633
+ it('returns true and releases when transaction is null', () => {
634
+ const client = makeClient()
635
+ client.connected = false // ensure release() doesn't try to QUIT
636
+ const plugin = makePlugin()
637
+ const conn = makeConnection()
638
+ conn.transaction = null
639
+ const result = client.is_dead_sender(plugin, conn)
640
+ assert.equal(result, true)
641
+ assert.equal(client.state, STATE.DESTROYED)
642
+ })
643
+ })
108
644
 
109
- data.push('EHLO example.com')
110
- data.push('250 hello')
645
+ // ─── get_client export ────────────────────────────────────────────────────────
111
646
 
112
- this.client.on('helo', () => {
113
- assert.equal(this.client.response[0], 'hello')
114
- this.client.send_command('AUTH', 'PLAIN AHRlc3QAdGVzdHBhc3M=')
115
- this.client.send_command('MAIL', 'FROM: me@example.com')
116
- })
647
+ describe('smtp_client.get_client', () => {
648
+ beforeEach(() => mockTlsConnect())
649
+ afterEach(restoreTlsConnect)
117
650
 
118
- data.push('AUTH PLAIN AHRlc3QAdGVzdHBhc3M=') // test/testpass
119
- data.push('235 Authentication successful.')
651
+ it('calls callback with a new SMTPClient', (t, done) => {
652
+ smtp_client_module.get_client(
653
+ { notes: {} },
654
+ (client) => {
655
+ assert.ok(client instanceof SMTPClient)
656
+ assert.ok(client.uuid)
657
+ done()
658
+ },
659
+ { host: 'mx.example.com', port: 25 },
660
+ )
661
+ })
662
+ })
120
663
 
121
- data.push('MAIL FROM: me@example.com')
122
- data.push('250 sender ok')
664
+ // ─── onCapabilitiesOutbound ───────────────────────────────────────────────────
123
665
 
124
- this.client.on('mail', () => {
125
- assert.equal(this.client.response[0], 'sender ok')
126
- this.client.send_command('RCPT', 'TO: you@example.com')
127
- })
666
+ describe('smtp_client.onCapabilitiesOutbound', () => {
667
+ let client, written
128
668
 
129
- data.push('RCPT TO: you@example.com')
130
- data.push('250 recipient ok')
669
+ beforeEach(() => {
670
+ written = []
671
+ const socket = makeSocket()
672
+ socket.write = (data) => written.push(data)
673
+ client = makeClient({ socket })
674
+ client.tls_options = {}
675
+ })
131
676
 
132
- this.client.on('rcpt', () => {
133
- assert.equal(this.client.response[0], 'recipient ok')
134
- this.client.send_command('DATA')
135
- })
677
+ it('sends XCLIENT when capability advertised and not yet done', () => {
678
+ client.response = ['XCLIENT ADDR']
679
+ client.xclient = false
680
+ const conn = makeConnection()
681
+ smtp_client_module.onCapabilitiesOutbound(client, false, conn, {})
682
+ assert.ok(written.some((l) => /XCLIENT ADDR=/.test(l)))
683
+ })
136
684
 
137
- data.push('DATA')
138
- data.push('354 go ahead')
139
-
140
- this.client.on('data', () => {
141
- assert.equal(this.client.response[0], 'go ahead')
142
- this.client.start_data(message_stream)
143
- message_stream.on('end', () => {
144
- this.client.socket.write('.\r\n')
145
- })
146
- message_stream.add_line('Header: test\r\n')
147
- message_stream.add_line('\r\n')
148
- message_stream.add_line('hi\r\n')
149
- message_stream.add_line_end()
150
- })
685
+ it('skips XCLIENT when already performed', () => {
686
+ client.response = ['XCLIENT ADDR']
687
+ client.xclient = true
688
+ smtp_client_module.onCapabilitiesOutbound(client, false, makeConnection(), {})
689
+ assert.ok(!written.some((l) => l.startsWith('XCLIENT')))
690
+ })
151
691
 
152
- data.push('.')
153
- data.push('250 message queued')
692
+ it('sets smtputf8 flag when SMTPUTF8 advertised', () => {
693
+ client.response = ['SMTPUTF8']
694
+ smtp_client_module.onCapabilitiesOutbound(client, false, makeConnection(), {})
695
+ assert.ok(client.smtputf8)
696
+ })
154
697
 
155
- this.client.on('dot', () => {
156
- assert.equal(this.client.response[0], 'message queued')
157
- this.client.send_command('QUIT')
158
- })
698
+ it('sends STARTTLS when advertised, not secured, and enable_tls true', () => {
699
+ client.response = ['STARTTLS']
700
+ smtp_client_module.onCapabilitiesOutbound(client, false, makeConnection(), { enable_tls: true }, () => {})
701
+ assert.ok(written.some((l) => l === 'STARTTLS\r\n'))
702
+ })
159
703
 
160
- data.push('QUIT')
161
- data.push('221 goodbye')
704
+ it('skips STARTTLS when already secured', () => {
705
+ client.response = ['STARTTLS']
706
+ smtp_client_module.onCapabilitiesOutbound(client, true, makeConnection(), { enable_tls: true })
707
+ assert.ok(!written.some((l) => l === 'STARTTLS\r\n'))
708
+ })
162
709
 
163
- this.client.on('quit', () => {
164
- assert.equal(this.client.response[0], 'goodbye')
165
- done()
710
+ it('skips STARTTLS when enable_tls is false', () => {
711
+ client.response = ['STARTTLS']
712
+ smtp_client_module.onCapabilitiesOutbound(client, false, makeConnection(), { enable_tls: false })
713
+ assert.ok(!written.some((l) => l === 'STARTTLS\r\n'))
714
+ })
715
+
716
+ it('parses AUTH capabilities', () => {
717
+ client.response = ['AUTH PLAIN LOGIN CRAM-MD5']
718
+ smtp_client_module.onCapabilitiesOutbound(client, false, makeConnection(), {})
719
+ assert.deepEqual(client.auth_capabilities, ['plain', 'login', 'cram-md5'])
720
+ })
721
+
722
+ it('handles multiple capabilities in one response', () => {
723
+ client.response = ['SMTPUTF8', 'AUTH PLAIN', 'STARTTLS']
724
+ smtp_client_module.onCapabilitiesOutbound(client, false, makeConnection(), {})
725
+ assert.ok(client.smtputf8)
726
+ assert.deepEqual(client.auth_capabilities, ['plain'])
727
+ })
728
+
729
+ it('skips STARTTLS when host is in no_tls_hosts ban list', () => {
730
+ client.response = ['STARTTLS']
731
+ // Note: the code checks tls_options.no_tls_hosts but reads tls_config.no_tls_hosts
732
+ // (a known quirk) — set both to exercise the branch
733
+ client.tls_options = { no_tls_hosts: ['10.0.0.0/8'] }
734
+ client.tls_config = { no_tls_hosts: ['10.0.0.0/8'] }
735
+ client.remote_ip = '10.0.0.1'
736
+ const conn = makeConnection()
737
+ smtp_client_module.onCapabilitiesOutbound(client, false, conn, { enable_tls: true, host: '10.0.0.1' }, () => {})
738
+ assert.ok(!written.some((l) => l === 'STARTTLS\r\n'))
739
+ })
740
+ })
741
+
742
+ // ─── get_client_plugin ────────────────────────────────────────────────────────
743
+
744
+ describe('smtp_client.get_client_plugin', () => {
745
+ let plugin, conn
746
+
747
+ beforeEach(() => {
748
+ mockTlsConnect()
749
+ plugin = makePlugin()
750
+ conn = makeConnection()
751
+ conn.transaction = { mail_from: new Address('<sender@example.com>') }
752
+ })
753
+ afterEach(restoreTlsConnect)
754
+
755
+ // Helper: wrap get_client_plugin callback in a Promise
756
+ const getClientPlugin = (opts = { host: 'relay.example.com', port: 25 }) =>
757
+ new Promise((resolve, reject) => {
758
+ smtp_client_module.get_client_plugin(plugin, conn, opts, (err, client) => {
759
+ if (err) reject(err)
760
+ else resolve(client)
166
761
  })
762
+ })
167
763
 
168
- this.client.socket.write = function (line) {
169
- if (data.length == 0) {
170
- assert.ok(false)
171
- return
172
- }
173
- assert.equal(`${data.shift()}\r\n`, line)
174
- if (reading_body && line == '.\r\n') {
175
- reading_body = false
176
- }
177
- if (!reading_body) {
178
- if (line == 'DATA\r\n') {
179
- reading_body = true
180
- }
181
- while (true) {
182
- const line2 = data.shift()
183
- this.emit('line', `${line2}\r\n`)
184
- if (line2[3] == ' ') break
185
- }
186
- }
764
+ it('calls callback with null error and a SMTPClient', async () => {
765
+ const client = await getClientPlugin()
766
+ assert.ok(client instanceof SMTPClient)
767
+ })
187
768
 
188
- return true
189
- }
769
+ it('merges auth_type / auth_user / auth_pass into c.auth', async () => {
770
+ const c = { host: 'relay.example.com', port: 25, auth_type: 'plain', auth_user: 'alice', auth_pass: 's3cr3t' }
771
+ await getClientPlugin(c)
772
+ assert.deepEqual(c.auth, { type: 'plain', user: 'alice', pass: 's3cr3t' })
773
+ })
190
774
 
191
- this.client.socket.emit('line', data.shift())
775
+ it('does not set c.auth when no auth fields present', async () => {
776
+ const c = { host: 'relay.example.com', port: 25 }
777
+ await getClientPlugin(c)
778
+ assert.equal(c.auth, undefined)
779
+ })
780
+
781
+ it('loads tls_config on the returned client', async () => {
782
+ const client = await getClientPlugin()
783
+ assert.ok(client.tls_options)
784
+ })
785
+
786
+ it('greeting handler sends EHLO with local.host (no xclient)', async () => {
787
+ const client = await getClientPlugin()
788
+ const written = []
789
+ client.socket.write = (data) => written.push(data)
790
+ client.emit('greeting', 'EHLO')
791
+ assert.ok(written.some((l) => /EHLO relay\.example\.com/.test(l)))
792
+ })
793
+
794
+ it('greeting handler sends EHLO with hello.host when xclient is set', async () => {
795
+ const client = await getClientPlugin()
796
+ client.xclient = true
797
+ const written = []
798
+ client.socket.write = (data) => written.push(data)
799
+ client.emit('greeting', 'EHLO')
800
+ assert.ok(written.some((l) => /EHLO client\.example\.com/.test(l)))
801
+ })
802
+
803
+ it('xclient handler sends EHLO with hello.host', async () => {
804
+ const client = await getClientPlugin()
805
+ client.xclient = true
806
+ const written = []
807
+ client.socket.write = (data) => written.push(data)
808
+ client.emit('xclient', 'EHLO')
809
+ assert.ok(written.some((l) => /EHLO client\.example\.com/.test(l)))
810
+ })
811
+
812
+ it('helo handler sends MAIL FROM when no auth configured', async () => {
813
+ const client = await getClientPlugin()
814
+ const written = []
815
+ client.socket.write = (data) => written.push(data)
816
+ client.emit('helo')
817
+ assert.ok(written.some((l) => /MAIL FROM/.test(l)))
818
+ })
819
+
820
+ it('helo handler sends MAIL FROM when already authenticated', async () => {
821
+ const c = { host: 'relay.example.com', port: 25, auth: { type: 'plain', user: 'u', pass: 'p' } }
822
+ const client = await getClientPlugin(c)
823
+ client.authenticated = true
824
+ client.auth_capabilities = ['plain']
825
+ const written = []
826
+ client.socket.write = (data) => written.push(data)
827
+ client.emit('helo')
828
+ assert.ok(written.some((l) => /MAIL FROM/.test(l)))
829
+ })
830
+
831
+ it('helo handler skips when auth.type is null', async () => {
832
+ const c = { host: 'relay.example.com', port: 25, auth: { type: null, user: 'u', pass: 'p' } }
833
+ const client = await getClientPlugin(c)
834
+ client.authenticated = false
835
+ client.auth_capabilities = []
836
+ const written = []
837
+ client.socket.write = (data) => written.push(data)
838
+ assert.doesNotThrow(() => client.emit('helo'))
839
+ assert.equal(written.length, 0)
840
+ })
841
+
842
+ it('helo handler sends AUTH PLAIN with base64 credentials', async () => {
843
+ const c = { host: 'relay.example.com', port: 25, auth: { type: 'plain', user: 'alice', pass: 'secret' } }
844
+ const client = await getClientPlugin(c)
845
+ client.authenticated = false
846
+ client.auth_capabilities = ['plain']
847
+ const written = []
848
+ client.socket.write = (data) => written.push(data)
849
+ client.emit('helo')
850
+ assert.ok(written.some((l) => /AUTH PLAIN/.test(l)))
851
+ })
852
+
853
+ // Table-drive the helo-throws cases
854
+ for (const [desc, opts, capabilities, pattern] of [
855
+ ['unsupported auth type', { type: 'plain', user: 'u', pass: 'p' }, ['cram-md5'], /not supported by server/],
856
+ ['plain auth with no user/pass', { type: 'plain', user: '', pass: '' }, ['plain'], /Must include auth\.user/],
857
+ ['cram-md5 (not implemented)', { type: 'cram-md5', user: 'u', pass: 'p' }, ['cram-md5'], /Not implemented/],
858
+ ['unknown auth type', { type: 'gssapi', user: 'u', pass: 'p' }, ['gssapi'], /Unknown AUTH type/],
859
+ ]) {
860
+ it(`helo handler throws for ${desc}`, async () => {
861
+ const c = { host: 'relay.example.com', port: 25, auth: opts }
862
+ const client = await getClientPlugin(c)
863
+ client.authenticated = false
864
+ client.auth_capabilities = capabilities
865
+ assert.throws(() => client.emit('helo'), pattern)
192
866
  })
867
+ }
868
+
869
+ it('auth handler sends MAIL FROM after successful authentication', async () => {
870
+ const client = await getClientPlugin()
871
+ client.authenticating = false
872
+ const written = []
873
+ client.socket.write = (data) => written.push(data)
874
+ client.emit('auth')
875
+ assert.ok(written.some((l) => /MAIL FROM/.test(l)))
193
876
  })
194
877
 
195
- describe('basic', () => {
196
- beforeEach((done) => {
197
- smtp_client.get_client(
198
- { notes: {} },
199
- (client) => {
200
- this.client = client
201
- done()
202
- },
203
- {
204
- socket: test_socket.connect(),
205
- },
206
- )
878
+ it('auth handler returns early when still authenticating', async () => {
879
+ const client = await getClientPlugin()
880
+ client.authenticating = true
881
+ const written = []
882
+ client.socket.write = (data) => written.push(data)
883
+ client.emit('auth')
884
+ assert.equal(written.length, 0)
885
+ })
886
+
887
+ it('error handler calls call_next', async () => {
888
+ const client = await getClientPlugin()
889
+ let nextCalled = false
890
+ client.next = () => {
891
+ nextCalled = true
892
+ }
893
+ client.emit('error', 'something went wrong')
894
+ assert.ok(nextCalled)
895
+ })
896
+
897
+ it('connection-error handler calls call_next', async () => {
898
+ const client = await getClientPlugin()
899
+ let nextCalled = false
900
+ client.next = () => {
901
+ nextCalled = true
902
+ }
903
+ client.emit('connection-error', 'backend unreachable')
904
+ assert.ok(nextCalled)
905
+ })
906
+
907
+ it('connection-error handler calls host_pool.failed when pool exists', async () => {
908
+ let failedCalled = false
909
+ conn.server.notes.host_pool = {
910
+ failed: () => {
911
+ failedCalled = true
912
+ },
913
+ }
914
+ const client = await getClientPlugin()
915
+ client.emit('connection-error', 'Error: connect ECONNREFUSED')
916
+ assert.ok(failedCalled)
917
+ })
918
+
919
+ it('throws when neither forwarding_host_pool nor host/port specified', () => {
920
+ assert.throws(
921
+ () => smtp_client_module.get_client_plugin(plugin, conn, {}, () => {}),
922
+ /forwarding_host_pool or host and port/,
923
+ )
924
+ })
925
+
926
+ it('uses forwarding_host_pool when configured', async () => {
927
+ const c = { forwarding_host_pool: '10.0.0.1:25, 10.0.0.2:25' }
928
+ const client = await getClientPlugin(c)
929
+ assert.ok(client instanceof SMTPClient)
930
+ assert.ok(conn.server.notes.host_pool)
931
+ })
932
+
933
+ it('reuses existing host_pool from server.notes', async () => {
934
+ const HostPool = require('../host_pool')
935
+ const pool = new HostPool('10.0.0.3:25')
936
+ conn.server.notes.host_pool = pool
937
+ await getClientPlugin({ forwarding_host_pool: '10.0.0.3:25' })
938
+ assert.equal(conn.server.notes.host_pool, pool)
939
+ })
940
+
941
+ it('server_protocol event logs protocol line', async () => {
942
+ const client = await getClientPlugin()
943
+ assert.doesNotThrow(() => client.emit('server_protocol', '220 server ready'))
944
+ })
945
+
946
+ it('capabilities handler calls onCapabilitiesOutbound', async () => {
947
+ const client = await getClientPlugin({ host: 'relay.example.com', port: 25, enable_tls: true })
948
+ client.response = ['SIZE 10240000', 'AUTH PLAIN LOGIN']
949
+ assert.doesNotThrow(() => client.emit('capabilities'))
950
+ assert.deepEqual(client.auth_capabilities, ['plain', 'login'])
951
+ })
952
+
953
+ it('on_secured fires greeting and is idempotent', async () => {
954
+ const client = await getClientPlugin({ host: 'relay.example.com', port: 25, enable_tls: true })
955
+ client.response = ['STARTTLS']
956
+ const written = []
957
+ client.socket.write = (d) => written.push(d)
958
+ client.emit('capabilities')
959
+
960
+ let greetingCount = 0
961
+ client.on('greeting', () => {
962
+ greetingCount++
207
963
  })
208
964
 
209
- it('conducts a SMTP session', (done) => {
210
- const message_stream = new message.stream({ main: { spool_after: 1024 } }, '123456789')
965
+ client.socket.emit('secure')
966
+ client.socket.emit('secure') // second call is a no-op
967
+ assert.equal(greetingCount, 1)
968
+ })
211
969
 
212
- const data = []
213
- let reading_body = false
214
- data.push('220 hi')
970
+ it('connected + xclient: sends XCLIENT immediately', (t, done) => {
971
+ // Make the mock socket emit a 220 greeting synchronously during SMTPClient construction
972
+ // so smtp_client.connected is true before get_client_plugin's check runs
973
+ const origConnect = tls_socket.connect
974
+ tls_socket.connect = () => {
975
+ const s = makeSocket()
976
+ net_utils.add_line_processor(s)
977
+ const origOn = s.on.bind(s)
978
+ let lineHandlerRegistered = false
979
+ s.on = function (event, handler) {
980
+ origOn(event, handler)
981
+ if (event === 'line' && !lineHandlerRegistered) {
982
+ lineHandlerRegistered = true
983
+ // emit greeting synchronously so connected becomes true before callback
984
+ handler('220 ready\r\n')
985
+ }
986
+ return s
987
+ }
988
+ return s
989
+ }
215
990
 
216
- this.client.on('greeting', (command) => {
217
- assert.equal(this.client.response[0], 'hi')
218
- assert.equal('EHLO', command)
219
- this.client.send_command(command, 'example.com')
220
- })
991
+ const written = []
992
+ const mockPlugin = makePlugin()
221
993
 
222
- data.push('EHLO example.com')
223
- data.push('250 hello')
994
+ smtp_client_module.get_client_plugin(
995
+ mockPlugin,
996
+ conn,
997
+ { host: 'relay.example.com', port: 25 },
998
+ (err, client) => {
999
+ tls_socket.connect = origConnect
1000
+ // If connected=true and xclient=true, XCLIENT was sent
1001
+ // If connected=true and xclient=false, helo was emitted
1002
+ // Either way connected path was exercised — just verify no crash
1003
+ assert.ok(client instanceof SMTPClient)
1004
+ done()
1005
+ },
1006
+ )
1007
+ })
1008
+ })
224
1009
 
225
- this.client.on('helo', () => {
226
- assert.equal(this.client.response[0], 'hello')
227
- this.client.send_command('MAIL', 'FROM: me@example.com')
228
- })
1010
+ // ─── Full SMTP session (integration) ─────────────────────────────────────────
229
1011
 
230
- data.push('MAIL FROM: me@example.com')
231
- data.push('250 sender ok')
1012
+ describe('smtp_client full session (basic)', () => {
1013
+ beforeEach((t, done) => {
1014
+ smtp_client_module.get_client(
1015
+ { notes: {} },
1016
+ (client) => {
1017
+ this.client = client
1018
+ done()
1019
+ },
1020
+ { socket: require('./fixtures/line_socket').connect() },
1021
+ )
1022
+ })
232
1023
 
233
- this.client.on('mail', () => {
234
- assert.equal(this.client.response[0], 'sender ok')
235
- this.client.send_command('RCPT', 'TO: you@example.com')
236
- })
1024
+ it('conducts a SMTP session', (t, done) => {
1025
+ const message_stream = new message.stream({ main: { spool_after: 1024 } }, '123456789')
237
1026
 
238
- data.push('RCPT TO: you@example.com')
239
- data.push('250 recipient ok')
1027
+ const data = []
1028
+ let reading_body = false
1029
+ data.push('220 hi')
240
1030
 
241
- this.client.on('rcpt', () => {
242
- assert.equal(this.client.response[0], 'recipient ok')
243
- this.client.send_command('DATA')
244
- })
1031
+ this.client.on('greeting', (command) => {
1032
+ assert.equal(this.client.response[0], 'hi')
1033
+ assert.equal('EHLO', command)
1034
+ this.client.send_command(command, 'example.com')
1035
+ })
245
1036
 
246
- data.push('DATA')
247
- data.push('354 go ahead')
248
-
249
- this.client.on('data', () => {
250
- assert.equal(this.client.response[0], 'go ahead')
251
- this.client.start_data(message_stream)
252
- message_stream.on('end', () => {
253
- this.client.socket.write('.\r\n')
254
- })
255
- message_stream.add_line('Header: test\r\n')
256
- message_stream.add_line('\r\n')
257
- message_stream.add_line('hi\r\n')
258
- message_stream.add_line_end()
259
- })
1037
+ data.push('EHLO example.com')
1038
+ data.push('250 hello')
260
1039
 
261
- data.push('.')
262
- data.push('250 message queued')
1040
+ this.client.on('helo', () => {
1041
+ assert.equal(this.client.response[0], 'hello')
1042
+ this.client.send_command('MAIL', 'FROM: me@example.com')
1043
+ })
263
1044
 
264
- this.client.on('dot', () => {
265
- assert.equal(this.client.response[0], 'message queued')
266
- this.client.send_command('QUIT')
267
- })
1045
+ data.push('MAIL FROM: me@example.com')
1046
+ data.push('250 sender ok')
1047
+
1048
+ this.client.on('mail', () => {
1049
+ assert.equal(this.client.response[0], 'sender ok')
1050
+ this.client.send_command('RCPT', 'TO: you@example.com')
1051
+ })
1052
+
1053
+ data.push('RCPT TO: you@example.com')
1054
+ data.push('250 recipient ok')
268
1055
 
269
- data.push('QUIT')
270
- data.push('221 goodbye')
1056
+ this.client.on('rcpt', () => {
1057
+ assert.equal(this.client.response[0], 'recipient ok')
1058
+ this.client.send_command('DATA')
1059
+ })
1060
+
1061
+ data.push('DATA')
1062
+ data.push('354 go ahead')
1063
+
1064
+ this.client.on('data', () => {
1065
+ assert.equal(this.client.response[0], 'go ahead')
1066
+ this.client.start_data(message_stream)
1067
+ message_stream.add_line('Header: test\r\n')
1068
+ message_stream.add_line('\r\n')
1069
+ message_stream.add_line('hi\r\n')
1070
+ message_stream.add_line_end()
1071
+ })
1072
+
1073
+ data.push('Header: test')
1074
+ data.push('')
1075
+ data.push('hi')
1076
+ data.push('.')
1077
+ data.push('250 message queued')
1078
+
1079
+ this.client.on('dot', () => {
1080
+ assert.equal(this.client.response[0], 'message queued')
1081
+ this.client.send_command('QUIT')
1082
+ })
1083
+
1084
+ data.push('QUIT')
1085
+ data.push('221 goodbye')
1086
+
1087
+ this.client.on('quit', () => {
1088
+ assert.equal(this.client.response[0], 'goodbye')
1089
+ done()
1090
+ })
1091
+
1092
+ this.client.socket.write = function (line) {
1093
+ if (data.length === 0) {
1094
+ assert.ok(false)
1095
+ return
1096
+ }
1097
+ const lineStr = Buffer.isBuffer(line) ? line.toString() : line
1098
+ assert.equal(`${data.shift()}\r\n`, lineStr)
1099
+ if (reading_body && lineStr === '.\r\n') reading_body = false
1100
+ if (reading_body) return true
1101
+ if (lineStr === 'DATA\r\n') reading_body = true
1102
+ while (true) {
1103
+ const line2 = data.shift()
1104
+ this.emit('line', `${line2}\r\n`)
1105
+ if (line2[3] === ' ') break
1106
+ }
1107
+ return true
1108
+ }
1109
+
1110
+ this.client.socket.emit('line', data.shift())
1111
+ })
1112
+ })
1113
+
1114
+ // ─── Full SMTP session with AUTH (integration) ───────────────────────────────
271
1115
 
272
- this.client.on('quit', () => {
273
- assert.equal(this.client.response[0], 'goodbye')
1116
+ describe('smtp_client full session (auth)', () => {
1117
+ beforeEach((t, done) => {
1118
+ smtp_client_module.get_client(
1119
+ { notes: {} },
1120
+ (client) => {
1121
+ this.client = client
274
1122
  done()
275
- })
1123
+ },
1124
+ { socket: require('./fixtures/line_socket').connect() },
1125
+ )
1126
+ })
276
1127
 
277
- this.client.socket.write = function (line) {
278
- if (data.length == 0) {
279
- assert.ok(false)
280
- return
281
- }
282
- assert.equal(`${data.shift()}\r\n`, line)
283
- if (reading_body && line == '.\r\n') {
284
- reading_body = false
285
- }
286
- if (reading_body) return true
1128
+ it('authenticates during SMTP conversation', (t, done) => {
1129
+ const message_stream = new message.stream({ main: { spool_after: 1024 } }, '123456789')
287
1130
 
288
- if (line == 'DATA\r\n') {
289
- reading_body = true
290
- }
1131
+ const data = []
1132
+ let reading_body = false
1133
+ data.push('220 hi')
1134
+
1135
+ this.client.on('greeting', (command) => {
1136
+ assert.equal(this.client.response[0], 'hi')
1137
+ assert.equal('EHLO', command)
1138
+ this.client.send_command(command, 'example.com')
1139
+ })
1140
+
1141
+ data.push('EHLO example.com')
1142
+ data.push('250 hello')
1143
+
1144
+ this.client.on('helo', () => {
1145
+ assert.equal(this.client.response[0], 'hello')
1146
+ this.client.send_command('AUTH', 'PLAIN AHRlc3QAdGVzdHBhc3M=')
1147
+ this.client.send_command('MAIL', 'FROM: me@example.com')
1148
+ })
1149
+
1150
+ data.push('AUTH PLAIN AHRlc3QAdGVzdHBhc3M=')
1151
+ data.push('235 Authentication successful.')
1152
+
1153
+ data.push('MAIL FROM: me@example.com')
1154
+ data.push('250 sender ok')
1155
+
1156
+ this.client.on('mail', () => {
1157
+ assert.equal(this.client.response[0], 'sender ok')
1158
+ this.client.send_command('RCPT', 'TO: you@example.com')
1159
+ })
1160
+
1161
+ data.push('RCPT TO: you@example.com')
1162
+ data.push('250 recipient ok')
1163
+
1164
+ this.client.on('rcpt', () => {
1165
+ assert.equal(this.client.response[0], 'recipient ok')
1166
+ this.client.send_command('DATA')
1167
+ })
1168
+
1169
+ data.push('DATA')
1170
+ data.push('354 go ahead')
1171
+
1172
+ this.client.on('data', () => {
1173
+ assert.equal(this.client.response[0], 'go ahead')
1174
+ this.client.start_data(message_stream)
1175
+ message_stream.add_line('Header: test\r\n')
1176
+ message_stream.add_line('\r\n')
1177
+ message_stream.add_line('hi\r\n')
1178
+ message_stream.add_line_end()
1179
+ })
1180
+
1181
+ data.push('Header: test')
1182
+ data.push('')
1183
+ data.push('hi')
1184
+ data.push('.')
1185
+ data.push('250 message queued')
1186
+
1187
+ this.client.on('dot', () => {
1188
+ assert.equal(this.client.response[0], 'message queued')
1189
+ this.client.send_command('QUIT')
1190
+ })
1191
+
1192
+ data.push('QUIT')
1193
+ data.push('221 goodbye')
1194
+
1195
+ this.client.on('quit', () => {
1196
+ assert.equal(this.client.response[0], 'goodbye')
1197
+ done()
1198
+ })
1199
+
1200
+ this.client.socket.write = function (line) {
1201
+ if (data.length === 0) {
1202
+ assert.ok(false)
1203
+ return
1204
+ }
1205
+ const lineStr = Buffer.isBuffer(line) ? line.toString() : line
1206
+ assert.equal(`${data.shift()}\r\n`, lineStr)
1207
+ if (reading_body && lineStr === '.\r\n') reading_body = false
1208
+ if (!reading_body) {
1209
+ if (lineStr === 'DATA\r\n') reading_body = true
291
1210
  while (true) {
292
1211
  const line2 = data.shift()
293
1212
  this.emit('line', `${line2}\r\n`)
294
- if (line2[3] == ' ') break
1213
+ if (line2[3] === ' ') break
295
1214
  }
296
-
297
- return true
298
1215
  }
1216
+ return true
1217
+ }
299
1218
 
300
- this.client.socket.emit('line', data.shift())
301
- })
1219
+ this.client.socket.emit('line', data.shift())
1220
+ })
1221
+ })
1222
+
1223
+ // ─── testUpgradeIsCalledOnSTARTTLS ───────────────────────────────────────────
1224
+
1225
+ describe('smtp_client', () => {
1226
+ it('testUpgradeIsCalledOnSTARTTLS', () => {
1227
+ const plugin = makePlugin()
1228
+
1229
+ const cmds = {}
1230
+ let upgradeArgs = {}
1231
+
1232
+ const socket = {
1233
+ setTimeout: () => {},
1234
+ setKeepAlive: () => {},
1235
+ on: (eventName, callback) => {
1236
+ cmds[eventName] = callback
1237
+ },
1238
+ upgrade: (arg) => {
1239
+ upgradeArgs = arg
1240
+ },
1241
+ }
1242
+
1243
+ const client = new SMTPClient({ host: 'mx.example.com', port: 25, socket })
1244
+ client.load_tls_config({ key: Buffer.from('OutboundTlsKeyLoaded') })
1245
+
1246
+ client.command = 'starttls'
1247
+ cmds.line('250 Hello client.example.com\r\n')
1248
+
1249
+ const { StringDecoder } = require('node:string_decoder')
1250
+ const decoder = new StringDecoder('utf8')
1251
+ const cent = Buffer.from(upgradeArgs.key)
1252
+ assert.equal(decoder.write(cent), 'OutboundTlsKeyLoaded')
1253
+ })
1254
+
1255
+ it('startTLS', () => {
1256
+ let cmd = ''
1257
+
1258
+ const socket = {
1259
+ setTimeout: () => {},
1260
+ setKeepAlive: () => {},
1261
+ on: () => {},
1262
+ upgrade: () => {},
1263
+ write: (arg) => {
1264
+ cmd = arg
1265
+ },
1266
+ }
1267
+
1268
+ const client = new SMTPClient({ host: 'mx.example.com', port: 25, socket })
1269
+ client.tls_options = {}
1270
+ client.secured = false
1271
+ client.response = ['STARTTLS']
1272
+
1273
+ smtp_client_module.onCapabilitiesOutbound(client, false, undefined, { enable_tls: true })
1274
+
1275
+ assert.equal(cmd, 'STARTTLS\r\n')
302
1276
  })
303
1277
  })