Haraka 3.1.6 → 3.2.0

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 (69) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/CONTRIBUTORS.md +8 -8
  3. package/Plugins.md +99 -99
  4. package/address.js +53 -0
  5. package/bin/haraka +1 -1
  6. package/config/smtp_forward.ini +10 -0
  7. package/config/smtp_proxy.ini +10 -0
  8. package/connection.js +28 -11
  9. package/docs/Outbound.md +1 -1
  10. package/docs/Transaction.md +1 -1
  11. package/docs/plugins/queue/smtp_forward.md +19 -3
  12. package/docs/plugins/queue/smtp_proxy.md +10 -2
  13. package/docs/plugins/status.md +21 -5
  14. package/haraka.js +1 -1
  15. package/outbound/hmail.js +41 -41
  16. package/outbound/index.js +5 -5
  17. package/outbound/queue.js +1 -1
  18. package/outbound/tls.js +2 -43
  19. package/package.json +48 -48
  20. package/plugins/auth/auth_base.js +9 -3
  21. package/plugins/auth/auth_proxy.js +14 -11
  22. package/plugins/block_me.js +6 -4
  23. package/plugins/prevent_credential_leaks.js +3 -1
  24. package/plugins/process_title.js +6 -6
  25. package/plugins/queue/qmail-queue.js +15 -19
  26. package/plugins/queue/smtp_forward.js +14 -6
  27. package/plugins/queue/smtp_proxy.js +14 -3
  28. package/plugins/rcpt_to.host_list_base.js +1 -1
  29. package/plugins/record_envelope_addresses.js +2 -2
  30. package/plugins/status.js +34 -5
  31. package/plugins/tls.js +13 -5
  32. package/plugins/xclient.js +3 -1
  33. package/server.js +5 -3
  34. package/smtp_client.js +20 -11
  35. package/test/config/block_me.recipient +1 -0
  36. package/test/config/block_me.senders +1 -0
  37. package/test/connection.js +25 -1
  38. package/test/fixtures/util_hmailitem.js +1 -1
  39. package/test/outbound/bounce_net_errors.js +3 -2
  40. package/test/outbound/index.js +2 -2
  41. package/test/plugins/auth/auth_base.js +1 -1
  42. package/test/plugins/auth/auth_bridge.js +80 -0
  43. package/test/plugins/auth/flat_file.js +128 -0
  44. package/test/plugins/block_me.js +157 -0
  45. package/test/plugins/data.signatures.js +114 -0
  46. package/test/plugins/delay_deny.js +263 -0
  47. package/test/plugins/prevent_credential_leaks.js +178 -0
  48. package/test/plugins/process_title.js +135 -0
  49. package/test/plugins/queue/deliver.js +99 -0
  50. package/test/plugins/queue/discard.js +79 -0
  51. package/test/plugins/queue/lmtp.js +138 -0
  52. package/test/plugins/queue/qmail-queue.js +99 -0
  53. package/test/plugins/queue/quarantine.js +81 -0
  54. package/test/plugins/queue/smtp_bridge.js +154 -0
  55. package/test/plugins/queue/smtp_forward.js +43 -7
  56. package/test/plugins/queue/smtp_proxy.js +139 -0
  57. package/test/plugins/rcpt_to.host_list_base.js +1 -1
  58. package/test/plugins/rcpt_to.in_host_list.js +1 -1
  59. package/test/plugins/record_envelope_addresses.js +2 -2
  60. package/test/plugins/reseed_rng.js +34 -0
  61. package/test/plugins/status.js +71 -0
  62. package/test/plugins/tarpit.js +91 -0
  63. package/test/plugins/tls.js +25 -0
  64. package/test/plugins/toobusy.js +21 -0
  65. package/test/plugins/xclient.js +14 -0
  66. package/test/server.js +59 -0
  67. package/test/smtp_client.js +46 -13
  68. package/test/tls_socket.js +82 -0
  69. package/tls_socket.js +50 -0
@@ -0,0 +1,139 @@
1
+ 'use strict'
2
+
3
+ const assert = require('node:assert')
4
+ const path = require('node:path')
5
+ const { describe, it, beforeEach, afterEach, before } = require('node:test')
6
+
7
+ const fixtures = require('haraka-test-fixtures')
8
+
9
+ const tls_socket = require('../../../tls_socket')
10
+
11
+ before(() => {
12
+ require('haraka-constants').import(global)
13
+ })
14
+
15
+ describe('queue/smtp_proxy', () => {
16
+ let plugin, conn
17
+
18
+ beforeEach(() => {
19
+ plugin = new fixtures.plugin('queue/smtp_proxy')
20
+ plugin.load_smtp_proxy_ini()
21
+ conn = fixtures.connection.createConnection()
22
+ conn.init_transaction()
23
+ })
24
+
25
+ describe('hook_rset', () => {
26
+ it('calls next() when no smtp_client in notes', (t, done) => {
27
+ plugin.hook_rset((rc) => {
28
+ assert.equal(rc, undefined)
29
+ done()
30
+ }, conn)
31
+ })
32
+
33
+ it('releases smtp_client and calls next() when smtp_client exists', (t, done) => {
34
+ let released = false
35
+ conn.notes.smtp_client = {
36
+ release: () => {
37
+ released = true
38
+ },
39
+ }
40
+ plugin.hook_rset((rc) => {
41
+ assert.equal(released, true)
42
+ assert.equal(conn.notes.smtp_client, undefined)
43
+ done()
44
+ }, conn)
45
+ })
46
+ })
47
+
48
+ describe('hook_quit', () => {
49
+ it('calls next() when no smtp_client in notes', (t, done) => {
50
+ plugin.hook_quit((rc) => {
51
+ assert.equal(rc, undefined)
52
+ done()
53
+ }, conn)
54
+ })
55
+
56
+ it('is the same function as hook_rset', () => {
57
+ assert.equal(plugin.hook_rset, plugin.hook_quit)
58
+ })
59
+ })
60
+
61
+ describe('hook_disconnect', () => {
62
+ it('calls next() when no smtp_client in notes', (t, done) => {
63
+ plugin.hook_disconnect((rc) => {
64
+ assert.equal(rc, undefined)
65
+ done()
66
+ }, conn)
67
+ })
68
+
69
+ it('releases and calls next when smtp_client exists', (t, done) => {
70
+ let released = false
71
+ let callNextCalled = false
72
+ conn.notes.smtp_client = {
73
+ release: () => {
74
+ released = true
75
+ },
76
+ call_next: () => {
77
+ callNextCalled = true
78
+ },
79
+ }
80
+ plugin.hook_disconnect((rc) => {
81
+ assert.equal(released, true)
82
+ assert.equal(callNextCalled, true)
83
+ assert.equal(conn.notes.smtp_client, undefined)
84
+ done()
85
+ }, conn)
86
+ })
87
+ })
88
+
89
+ describe('hook_queue', () => {
90
+ it('calls next() when transaction is missing', (t, done) => {
91
+ const connNoTxn = fixtures.connection.createConnection()
92
+ plugin.hook_queue((rc) => {
93
+ assert.equal(rc, undefined)
94
+ done()
95
+ }, connNoTxn)
96
+ })
97
+
98
+ it('calls next() when smtp_client is not in notes', (t, done) => {
99
+ plugin.hook_queue((rc) => {
100
+ assert.equal(rc, undefined)
101
+ done()
102
+ }, conn)
103
+ })
104
+ })
105
+
106
+ describe('tls_options', () => {
107
+ let origTlsConfig, origTlsCfg
108
+
109
+ beforeEach(() => {
110
+ origTlsConfig = tls_socket.config
111
+ origTlsCfg = tls_socket.cfg
112
+ tls_socket.config = require('haraka-config').module_config(path.resolve('test'))
113
+ tls_socket.cfg = undefined
114
+ // re-derive with test/config in scope
115
+ plugin.load_smtp_proxy_ini()
116
+ })
117
+
118
+ afterEach(() => {
119
+ tls_socket.config = origTlsConfig
120
+ tls_socket.cfg = origTlsCfg
121
+ })
122
+
123
+ it('populates tls_options from tls.ini [main]', () => {
124
+ assert.ok(plugin.tls_options)
125
+ assert.equal(plugin.tls_options.rejectUnauthorized, false)
126
+ assert.equal(plugin.tls_options.minVersion, 'TLSv1')
127
+ assert.ok(plugin.tls_options.ciphers)
128
+ assert.ok(Array.isArray(plugin.tls_options.no_tls_hosts))
129
+ assert.ok(Array.isArray(plugin.tls_options.force_tls_hosts))
130
+ })
131
+
132
+ it('reload re-derives tls_options', () => {
133
+ const first = plugin.tls_options
134
+ plugin.load_smtp_proxy_ini()
135
+ assert.ok(plugin.tls_options)
136
+ assert.notEqual(plugin.tls_options, first)
137
+ })
138
+ })
139
+ })
@@ -2,7 +2,7 @@
2
2
  const assert = require('node:assert/strict')
3
3
  const { describe, it, beforeEach } = require('node:test')
4
4
 
5
- const { Address } = require('address-rfc2821')
5
+ const { Address } = require('../../address')
6
6
  const fixtures = require('haraka-test-fixtures')
7
7
 
8
8
  const _set_up = () => {
@@ -2,7 +2,7 @@
2
2
  const assert = require('node:assert/strict')
3
3
  const { describe, it, beforeEach } = require('node:test')
4
4
 
5
- const { Address } = require('address-rfc2821')
5
+ const { Address } = require('../../address')
6
6
  const fixtures = require('haraka-test-fixtures')
7
7
  require('haraka-constants').import(global)
8
8
 
@@ -1,9 +1,9 @@
1
1
  'use strict'
2
2
 
3
- const assert = require('node:assert/strict')
3
+ const assert = require('node:assert')
4
4
  const { describe, it, beforeEach } = require('node:test')
5
5
 
6
- const { Address } = require('address-rfc2821')
6
+ const { Address } = require('../../address')
7
7
  const fixtures = require('haraka-test-fixtures')
8
8
 
9
9
  const _set_up = () => {
@@ -0,0 +1,34 @@
1
+ 'use strict'
2
+
3
+ const assert = require('node:assert/strict')
4
+ const { describe, it } = require('node:test')
5
+
6
+ const fixtures = require('haraka-test-fixtures')
7
+
8
+ describe('reseed_rng', () => {
9
+ describe('hook_init_child', () => {
10
+ it('calls Math.seedrandom with a hex string and calls next', (t, done) => {
11
+ const plugin = new fixtures.plugin('reseed_rng')
12
+ let called = false
13
+ let calledArg
14
+ Math.seedrandom = (arg) => {
15
+ called = true
16
+ calledArg = arg
17
+ }
18
+ plugin.hook_init_child((rc) => {
19
+ delete Math.seedrandom
20
+ assert.equal(rc, undefined)
21
+ assert.ok(called, 'Math.seedrandom should have been called')
22
+ assert.equal(typeof calledArg, 'string')
23
+ assert.ok(calledArg.length > 0)
24
+ done()
25
+ })
26
+ })
27
+
28
+ it('throws when Math.seedrandom is not defined', () => {
29
+ const plugin = new fixtures.plugin('reseed_rng')
30
+ delete Math.seedrandom
31
+ assert.throws(() => plugin.hook_init_child(() => {}), /Math\.seedrandom is not a function/)
32
+ })
33
+ })
34
+ })
@@ -134,4 +134,75 @@ describe('status', () => {
134
134
  this.plugin.hook_unrecognized_command(() => {}, this.connection, ['STATUS', 'QUEUE PUSH file'])
135
135
  })
136
136
  })
137
+
138
+ describe('merge_worker_responses', () => {
139
+ beforeEach(_set_up)
140
+
141
+ it('POOL LIST merges objects from all workers', () => {
142
+ const result = JSON.parse(
143
+ JSON.stringify(
144
+ this.plugin.merge_worker_responses('POOL LIST', [
145
+ { 'host1:25': { inUse: 1, size: 3 } },
146
+ { 'host2:25': { inUse: 0, size: 2 } },
147
+ {},
148
+ ]),
149
+ ),
150
+ )
151
+ assert.deepEqual(result, {
152
+ 'host1:25': { inUse: 1, size: 3 },
153
+ 'host2:25': { inUse: 0, size: 2 },
154
+ })
155
+ })
156
+
157
+ it('POOL LIST with all empty workers returns empty object', () => {
158
+ const result = JSON.parse(JSON.stringify(this.plugin.merge_worker_responses('POOL LIST', [{}, {}, {}])))
159
+ assert.deepEqual(result, {})
160
+ })
161
+
162
+ it('QUEUE INSPECT merges queues from all workers', () => {
163
+ const result = JSON.parse(
164
+ JSON.stringify(
165
+ this.plugin.merge_worker_responses('QUEUE INSPECT', [
166
+ { delivery_queue: [{ id: 'a' }], temp_fail_queue: [{ id: 'x', fire_time: 1 }] },
167
+ { delivery_queue: [{ id: 'b' }], temp_fail_queue: [] },
168
+ { delivery_queue: [], temp_fail_queue: [{ id: 'y', fire_time: 2 }] },
169
+ ]),
170
+ ),
171
+ )
172
+ assert.deepEqual(result, {
173
+ delivery_queue: [{ id: 'a' }, { id: 'b' }],
174
+ temp_fail_queue: [
175
+ { id: 'x', fire_time: 1 },
176
+ { id: 'y', fire_time: 2 },
177
+ ],
178
+ })
179
+ })
180
+
181
+ it('QUEUE INSPECT with all empty queues returns empty lists', () => {
182
+ const result = JSON.parse(
183
+ JSON.stringify(
184
+ this.plugin.merge_worker_responses('QUEUE INSPECT', [
185
+ { delivery_queue: [], temp_fail_queue: [] },
186
+ { delivery_queue: [], temp_fail_queue: [] },
187
+ ]),
188
+ ),
189
+ )
190
+ assert.deepEqual(result, { delivery_queue: [], temp_fail_queue: [] })
191
+ })
192
+
193
+ it('QUEUE STATS sums across workers', () => {
194
+ const result = this.plugin.merge_worker_responses('QUEUE STATS', ['1/2/3', '0/1/0', '2/0/1'])
195
+ assert.equal(result, '3/3/4')
196
+ })
197
+
198
+ it('QUEUE STATS with all zeros', () => {
199
+ const result = this.plugin.merge_worker_responses('QUEUE STATS', ['0/0/0', '0/0/0', '0/0/0'])
200
+ assert.equal(result, '0/0/0')
201
+ })
202
+
203
+ it('unknown command returns results array unchanged', () => {
204
+ const result = this.plugin.merge_worker_responses('POOL UNKNOWN', [{ foo: 1 }, { foo: 2 }])
205
+ assert.equal(result.length, 2)
206
+ })
207
+ })
137
208
  })
@@ -0,0 +1,91 @@
1
+ 'use strict'
2
+
3
+ const assert = require('node:assert/strict')
4
+ const { describe, it, beforeEach } = require('node:test')
5
+
6
+ const fixtures = require('haraka-test-fixtures')
7
+
8
+ describe('tarpit', () => {
9
+ let plugin
10
+
11
+ beforeEach(() => {
12
+ plugin = new fixtures.plugin('tarpit')
13
+ plugin.config.get = () => ({ main: {} })
14
+ })
15
+
16
+ describe('register', () => {
17
+ it('registers tarpit on all default hooks', () => {
18
+ const registered = []
19
+ plugin.register_hook = (hook) => registered.push(hook)
20
+ plugin.register()
21
+ assert.ok(registered.includes('connect'))
22
+ assert.ok(registered.includes('ehlo'))
23
+ assert.ok(registered.includes('mail'))
24
+ assert.ok(registered.includes('rcpt'))
25
+ assert.ok(registered.includes('data'))
26
+ assert.ok(registered.includes('queue'))
27
+ assert.ok(registered.includes('quit'))
28
+ })
29
+
30
+ it('registers only configured hooks when hooks_to_delay is set', () => {
31
+ plugin.config.get = () => ({ main: { hooks_to_delay: 'ehlo, mail' } })
32
+ const registered = []
33
+ plugin.register_hook = (hook) => registered.push(hook)
34
+ plugin.register()
35
+ assert.deepEqual(registered, ['ehlo', 'mail'])
36
+ })
37
+ })
38
+
39
+ describe('tarpit', () => {
40
+ let conn
41
+
42
+ beforeEach(() => {
43
+ conn = fixtures.connection.createConnection()
44
+ conn.init_transaction()
45
+ })
46
+
47
+ it('calls next immediately when no transaction', (t, done) => {
48
+ conn.transaction = null
49
+ plugin.tarpit((rc) => {
50
+ assert.equal(rc, undefined)
51
+ done()
52
+ }, conn)
53
+ })
54
+
55
+ it('calls next immediately when no tarpit delay set', (t, done) => {
56
+ // No tarpit note on connection or transaction
57
+ plugin.tarpit((rc) => {
58
+ assert.equal(rc, undefined)
59
+ done()
60
+ }, conn)
61
+ })
62
+
63
+ it('calls next immediately when connection.notes.tarpit is 0', (t, done) => {
64
+ conn.notes.tarpit = 0
65
+ plugin.tarpit((rc) => {
66
+ assert.equal(rc, undefined)
67
+ done()
68
+ }, conn)
69
+ })
70
+
71
+ it('delays and calls next when connection.notes.tarpit is set', { timeout: 3000 }, (t, done) => {
72
+ conn.notes.tarpit = 0.1
73
+ const start = Date.now()
74
+ plugin.tarpit((rc) => {
75
+ assert.equal(rc, undefined)
76
+ assert.ok(Date.now() - start >= 90, 'should have waited ~100ms')
77
+ done()
78
+ }, conn)
79
+ })
80
+
81
+ it('uses transaction.notes.tarpit when connection note is absent', { timeout: 3000 }, (t, done) => {
82
+ conn.transaction.notes.tarpit = 0.1
83
+ const start = Date.now()
84
+ plugin.tarpit((rc) => {
85
+ assert.equal(rc, undefined)
86
+ assert.ok(Date.now() - start >= 90)
87
+ done()
88
+ }, conn)
89
+ })
90
+ })
91
+ })
@@ -60,4 +60,29 @@ describe('tls', () => {
60
60
  )
61
61
  })
62
62
  })
63
+
64
+ describe('upgrade_connection (STARTTLS injection)', () => {
65
+ // RFC 3207 §4: data pipelined after STARTTLS but before the TLS
66
+ // handshake must be discarded, not processed on the cleartext channel.
67
+ it('discards pipelined plaintext before the TLS handshake', () => {
68
+ const c = this.connection
69
+ c.tls = { advertised: true }
70
+ c.notes = {}
71
+ // attacker pipelined an injected command after STARTTLS
72
+ c.current_data = Buffer.from('RCPT TO:<victim@example.com>\r\n')
73
+ let dataAtUpgrade = 'UPGRADE_NOT_CALLED'
74
+ c.client = {
75
+ upgrade() {
76
+ dataAtUpgrade = c.current_data
77
+ },
78
+ }
79
+ c.respond = () => {} // bypass the real _process_data path
80
+ this.plugin.timeout = 0
81
+
82
+ this.plugin.upgrade_connection(() => {}, c, ['STARTTLS'])
83
+
84
+ assert.equal(dataAtUpgrade, null, 'buffer cleared before upgrade()')
85
+ assert.equal(c.current_data, null)
86
+ })
87
+ })
63
88
  })
@@ -0,0 +1,21 @@
1
+ 'use strict'
2
+
3
+ const assert = require('node:assert/strict')
4
+ const { describe, it } = require('node:test')
5
+
6
+ const fixtures = require('haraka-test-fixtures')
7
+
8
+ describe('toobusy', () => {
9
+ describe('register', () => {
10
+ it('handles missing toobusy-js gracefully (does not throw)', () => {
11
+ const plugin = new fixtures.plugin('toobusy')
12
+ // toobusy-js is not installed; register should catch the error and return
13
+ let registered = false
14
+ plugin.register_hook = () => {
15
+ registered = true
16
+ }
17
+ assert.doesNotThrow(() => plugin.register())
18
+ assert.equal(registered, false, 'hook should not be registered without toobusy-js')
19
+ })
20
+ })
21
+ })
@@ -99,4 +99,18 @@ describe('xclient', () => {
99
99
  })
100
100
  }
101
101
  })
102
+
103
+ describe('DESTPORT type', () => {
104
+ it('stores local.port as an integer (587/465 auth check)', async () => {
105
+ this.connection.remote.ip = '127.0.0.1'
106
+ await new Promise((resolve) => {
107
+ this.plugin.hook_unrecognized_command(() => resolve(), this.connection, [
108
+ 'XCLIENT',
109
+ 'ADDR=1.2.3.4 DESTPORT=587',
110
+ ])
111
+ })
112
+ assert.strictEqual(this.connection.local.port, 587)
113
+ assert.equal(typeof this.connection.local.port, 'number')
114
+ })
115
+ })
102
116
  })
package/test/server.js CHANGED
@@ -527,3 +527,62 @@ describe('server', () => {
527
527
  })
528
528
  })
529
529
  })
530
+
531
+ describe('_graceful (cluster restart)', () => {
532
+ it('actually disconnects workers (queued thunks are invoked)', async () => {
533
+ const cluster = require('node:cluster')
534
+ const Server = require('../server')
535
+ Server.cfg = Server.cfg || { main: {} }
536
+ Server.cfg.main = Server.cfg.main || {}
537
+ Server.cfg.main.force_shutdown_timeout = 1
538
+
539
+ const saved = {
540
+ cluster: Server.cluster,
541
+ workers: cluster.workers,
542
+ fork: cluster.fork,
543
+ rmAll: cluster.removeAllListeners,
544
+ }
545
+
546
+ let disconnected = 0
547
+ const mkWorker = () => ({
548
+ _cbs: {},
549
+ send() {},
550
+ kill() {},
551
+ once(ev, cb) {
552
+ ;(this._cbs[ev] ||= []).push(cb)
553
+ },
554
+ on(ev, cb) {
555
+ ;(this._cbs[ev] ||= []).push(cb)
556
+ },
557
+ _fire(ev) {
558
+ for (const cb of this._cbs[ev] || []) cb()
559
+ },
560
+ disconnect() {
561
+ disconnected++
562
+ setImmediate(() => {
563
+ this._fire('disconnect')
564
+ setImmediate(() => this._fire('exit'))
565
+ })
566
+ },
567
+ })
568
+
569
+ cluster.workers = { 1: mkWorker() }
570
+ cluster.removeAllListeners = () => {}
571
+ cluster.fork = () => {
572
+ const nw = mkWorker()
573
+ setImmediate(() => nw._fire('listening'))
574
+ return nw
575
+ }
576
+ Server.cluster = cluster
577
+
578
+ try {
579
+ await Server._graceful()
580
+ assert.equal(disconnected, 1, 'worker.disconnect() was invoked')
581
+ } finally {
582
+ Server.cluster = saved.cluster
583
+ cluster.workers = saved.workers
584
+ cluster.fork = saved.fork
585
+ cluster.removeAllListeners = saved.rmAll
586
+ }
587
+ })
588
+ })
@@ -5,7 +5,7 @@ const assert = require('node:assert/strict')
5
5
  const { PassThrough } = require('node:stream')
6
6
  const path = require('node:path')
7
7
 
8
- const { Address } = require('address-rfc2821')
8
+ const { Address } = require('../address')
9
9
  const fixtures = require('haraka-test-fixtures')
10
10
  const net_utils = require('haraka-net-utils')
11
11
  const message = require('haraka-email-message')
@@ -436,18 +436,18 @@ describe('SMTPClient closed() handler', () => {
436
436
  })
437
437
  })
438
438
 
439
- // ─── load_tls_config ──────────────────────────────────────────────────────────
439
+ // ─── load_tls_options ──────────────────────────────────────────────────────────
440
440
 
441
- describe('SMTPClient#load_tls_config', () => {
441
+ describe('SMTPClient#load_tls_options', () => {
442
442
  it('sets tls_options with servername equal to host', () => {
443
443
  const client = makeClient()
444
- client.load_tls_config()
444
+ client.load_tls_options()
445
445
  assert.equal(client.tls_options.servername, 'mx.example.com')
446
446
  })
447
447
 
448
448
  it('merges additional opts into tls_options', () => {
449
449
  const client = makeClient()
450
- client.load_tls_config({ key: Buffer.from('secret'), rejectUnauthorized: false })
450
+ client.load_tls_options({ key: Buffer.from('secret'), rejectUnauthorized: false })
451
451
  assert.equal(client.tls_options.servername, 'mx.example.com')
452
452
  assert.equal(client.tls_options.rejectUnauthorized, false)
453
453
  assert.ok(Buffer.isBuffer(client.tls_options.key))
@@ -728,10 +728,8 @@ describe('smtp_client.onCapabilitiesOutbound', () => {
728
728
 
729
729
  it('skips STARTTLS when host is in no_tls_hosts ban list', () => {
730
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
731
+ // no_tls_hosts is read from tls_options consistently
733
732
  client.tls_options = { no_tls_hosts: ['10.0.0.0/8'] }
734
- client.tls_config = { no_tls_hosts: ['10.0.0.0/8'] }
735
733
  client.remote_ip = '10.0.0.1'
736
734
  const conn = makeConnection()
737
735
  smtp_client_module.onCapabilitiesOutbound(client, false, conn, { enable_tls: true, host: '10.0.0.1' }, () => {})
@@ -766,6 +764,25 @@ describe('smtp_client.get_client_plugin', () => {
766
764
  assert.ok(client instanceof SMTPClient)
767
765
  })
768
766
 
767
+ it('emits error (does not throw) when AUTH type is unsupported', async () => {
768
+ const c = {
769
+ host: 'relay.example.com',
770
+ port: 25,
771
+ auth_type: 'login',
772
+ auth_user: 'a',
773
+ auth_pass: 'b',
774
+ }
775
+ const client = await getClientPlugin(c)
776
+ client.auth_capabilities = [] // server advertised no AUTH
777
+ let errMsg
778
+ client.on('error', (m) => {
779
+ errMsg = m
780
+ })
781
+ // pre-fix this threw out of the event loop and crashed the worker
782
+ assert.doesNotThrow(() => client.emit('helo'))
783
+ assert.match(String(errMsg), /not supported by server/)
784
+ })
785
+
769
786
  it('merges auth_type / auth_user / auth_pass into c.auth', async () => {
770
787
  const c = { host: 'relay.example.com', port: 25, auth_type: 'plain', auth_user: 'alice', auth_pass: 's3cr3t' }
771
788
  await getClientPlugin(c)
@@ -791,6 +808,16 @@ describe('smtp_client.get_client_plugin', () => {
791
808
  assert.ok(written.some((l) => /EHLO relay\.example\.com/.test(l)))
792
809
  })
793
810
 
811
+ it('redacts AUTH credentials from protocol logs (S4)', async () => {
812
+ const client = await getClientPlugin()
813
+ const logged = []
814
+ conn.logprotocol = (p, msg) => logged.push(msg)
815
+ client.emit('client_protocol', 'AUTH PLAIN AGFsaWNlAHMzY3JldA==')
816
+ assert.equal(logged.length, 1)
817
+ assert.equal(logged[0], 'C: AUTH PLAIN [redacted]')
818
+ assert.ok(!logged[0].includes('AGFsaWNlAHMzY3JldA=='))
819
+ })
820
+
794
821
  it('greeting handler sends EHLO with hello.host when xclient is set', async () => {
795
822
  const client = await getClientPlugin()
796
823
  client.xclient = true
@@ -850,19 +877,25 @@ describe('smtp_client.get_client_plugin', () => {
850
877
  assert.ok(written.some((l) => /AUTH PLAIN/.test(l)))
851
878
  })
852
879
 
853
- // Table-drive the helo-throws cases
880
+ // these used to throw out of the event loop (crashing the worker); they must
881
+ // now route through the smtp_client 'error' flow.
854
882
  for (const [desc, opts, capabilities, pattern] of [
855
883
  ['unsupported auth type', { type: 'plain', user: 'u', pass: 'p' }, ['cram-md5'], /not supported by server/],
856
884
  ['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/],
885
+ ['cram-md5 (not implemented)', { type: 'cram-md5', user: 'u', pass: 'p' }, ['cram-md5'], /not implemented/i],
858
886
  ['unknown auth type', { type: 'gssapi', user: 'u', pass: 'p' }, ['gssapi'], /Unknown AUTH type/],
859
887
  ]) {
860
- it(`helo handler throws for ${desc}`, async () => {
888
+ it(`helo handler emits error (no throw) for ${desc}`, async () => {
861
889
  const c = { host: 'relay.example.com', port: 25, auth: opts }
862
890
  const client = await getClientPlugin(c)
863
891
  client.authenticated = false
864
892
  client.auth_capabilities = capabilities
865
- assert.throws(() => client.emit('helo'), pattern)
893
+ let errMsg
894
+ client.on('error', (m) => {
895
+ errMsg = m
896
+ })
897
+ assert.doesNotThrow(() => client.emit('helo'))
898
+ assert.match(String(errMsg), pattern)
866
899
  })
867
900
  }
868
901
 
@@ -1241,7 +1274,7 @@ describe('smtp_client', () => {
1241
1274
  }
1242
1275
 
1243
1276
  const client = new SMTPClient({ host: 'mx.example.com', port: 25, socket })
1244
- client.load_tls_config({ key: Buffer.from('OutboundTlsKeyLoaded') })
1277
+ client.load_tls_options({ key: Buffer.from('OutboundTlsKeyLoaded') })
1245
1278
 
1246
1279
  client.command = 'starttls'
1247
1280
  cmds.line('250 Hello client.example.com\r\n')