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,228 +1,998 @@
1
1
  'use strict'
2
- const assert = require('node:assert')
2
+
3
+ const { describe, it, beforeEach, afterEach } = require('node:test')
4
+ const assert = require('node:assert/strict')
5
+ const { EventEmitter } = require('node:events')
3
6
  const path = require('node:path')
4
7
 
5
8
  const { Address } = require('address-rfc2821')
6
9
  const fixtures = require('haraka-test-fixtures')
7
10
  const Notes = require('haraka-notes')
8
11
 
12
+ // Haraka result codes (haraka-constants)
9
13
  const OK = 906
14
+ const DENY = 902
15
+ const DENYSOFT = 903
16
+
17
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
18
+
19
+ function makePlugin() {
20
+ const p = new fixtures.plugin('queue/smtp_forward')
21
+ p.config = p.config.module_config(path.resolve('test'))
22
+ p.register()
23
+ // Deep-clone cfg to prevent shared haraka-config reference mutations across tests
24
+ p.cfg = JSON.parse(JSON.stringify(p.cfg))
25
+ return p
26
+ }
27
+
28
+ function makeConnection() {
29
+ const conn = fixtures.connection.createConnection()
30
+ conn.init_transaction()
31
+ conn.server = { notes: {} }
32
+ return conn
33
+ }
34
+
35
+ function makeHmail(notes = {}) {
36
+ const n = new Notes()
37
+ for (const [k, v] of Object.entries(notes)) n.set(k, v)
38
+ return { todo: { notes: n } }
39
+ }
10
40
 
11
- const _setup = (done) => {
12
- this.plugin = new fixtures.plugin('queue/smtp_forward')
41
+ /** Mock SMTPClient returned by get_client_plugin stubs. */
42
+ class MockSMTPClient extends EventEmitter {
43
+ constructor() {
44
+ super()
45
+ this.smtp_utf8 = false
46
+ this.response = ['250 OK']
47
+ this.next = null
48
+ this.commands = []
49
+ }
13
50
 
14
- // switch config directory to 'test/config'
15
- this.plugin.config = this.plugin.config.module_config(path.resolve('test'))
51
+ call_next(code, msg) {
52
+ if (this.next) {
53
+ const n = this.next
54
+ delete this.next
55
+ n(code, msg)
56
+ }
57
+ }
16
58
 
17
- this.plugin.register()
18
- this.hmail = { todo: { notes: new Notes() } }
59
+ release() {
60
+ this.released = true
61
+ }
19
62
 
20
- this.connection = new fixtures.connection.createConnection()
21
- this.connection.init_transaction()
63
+ is_dead_sender() {
64
+ return false
65
+ }
22
66
 
23
- done()
67
+ send_command(cmd, data) {
68
+ this.commands.push(data !== undefined ? `${cmd} ${data}` : cmd)
69
+ }
70
+
71
+ start_data(stream) {
72
+ this.started = true
73
+ }
24
74
  }
25
75
 
26
- describe('smtp_forward', () => {
27
- describe('tls config', () => {
28
- it('TLS enabled but no outbound config in tls.ini', () => {
29
- const plugin = new fixtures.plugin('queue/smtp_forward')
30
- plugin.register()
76
+ // Temporarily replace smtp_client_mod.get_client_plugin for queue_forward tests
77
+ const smtp_client_mod = require('../../../smtp_client')
31
78
 
32
- assert.equal(plugin.tls_options, undefined)
33
- assert.equal(plugin.register_hook.called, true)
34
- })
79
+ function stubGetClientPlugin(factory) {
80
+ const orig = smtp_client_mod.get_client_plugin
81
+ smtp_client_mod.get_client_plugin = factory
82
+ return () => {
83
+ smtp_client_mod.get_client_plugin = orig
84
+ }
85
+ }
86
+
87
+ // ─── register ────────────────────────────────────────────────────────────────
88
+
89
+ describe('smtp_forward register', () => {
90
+ it('registers the queue hook', () => {
91
+ const plugin = makePlugin()
92
+ assert.ok(plugin.hooks.queue)
35
93
  })
36
94
 
37
- describe('register', () => {
38
- beforeEach(_setup)
95
+ it('registers the get_mx hook', () => {
96
+ const plugin = makePlugin()
97
+ assert.ok(plugin.hooks.get_mx)
98
+ })
39
99
 
40
- it('register', () => {
41
- this.plugin.register()
42
- assert.ok(this.plugin.cfg.main)
43
- })
100
+ it('registers check_sender hook when check_sender=true', () => {
101
+ const plugin = new fixtures.plugin('queue/smtp_forward')
102
+ plugin.config = plugin.config.module_config(path.resolve('test'))
103
+ plugin.load_smtp_forward_ini = function () {
104
+ this.cfg = {
105
+ main: { check_sender: true, check_recipient: true, enable_outbound: true, host: 'localhost', port: 25 },
106
+ }
107
+ }
108
+ plugin.register()
109
+ assert.ok(plugin.hooks.mail)
44
110
  })
45
111
 
46
- describe('get_config', () => {
47
- beforeEach(_setup)
112
+ it('registers check_recipient hook when check_recipient=true', () => {
113
+ const plugin = new fixtures.plugin('queue/smtp_forward')
114
+ plugin.config = plugin.config.module_config(path.resolve('test'))
115
+ plugin.load_smtp_forward_ini = function () {
116
+ this.cfg = { main: { check_recipient: true, host: 'localhost', port: 25 } }
117
+ }
118
+ plugin.register()
119
+ assert.ok(plugin.hooks.rcpt)
120
+ })
48
121
 
49
- it('no recipient', () => {
50
- const cfg = this.plugin.get_config(this.connection)
51
- assert.equal(cfg.host, 'localhost')
52
- assert.equal(cfg.enable_tls, true)
53
- assert.equal(cfg.one_message_per_rcpt, true)
54
- })
122
+ it('registers queue_outbound hook when enable_outbound=true', () => {
123
+ const plugin = new fixtures.plugin('queue/smtp_forward')
124
+ plugin.config = plugin.config.module_config(path.resolve('test'))
125
+ plugin.load_smtp_forward_ini = function () {
126
+ this.cfg = { main: { enable_outbound: true, host: 'localhost', port: 25 } }
127
+ }
128
+ plugin.register()
129
+ assert.ok(plugin.hooks.queue_outbound)
130
+ })
55
131
 
56
- it('null recipient', () => {
57
- this.connection.transaction.rcpt_to.push(new Address('<>'))
58
- const cfg = this.plugin.get_config(this.connection)
59
- assert.equal(cfg.host, 'localhost')
60
- assert.equal(cfg.enable_tls, true)
61
- assert.equal(cfg.one_message_per_rcpt, true)
62
- })
132
+ it('aborts registration when load_errs is non-empty', () => {
133
+ const plugin = new fixtures.plugin('queue/smtp_forward')
134
+ plugin.config = plugin.config.module_config(path.resolve('test'))
135
+ plugin.load_smtp_forward_ini = function () {
136
+ this.cfg = { main: {} }
137
+ this.load_errs.push('simulated error')
138
+ }
139
+ plugin.register()
140
+ assert.equal(plugin.hooks.queue, undefined)
141
+ })
63
142
 
64
- it('valid recipient', () => {
65
- this.connection.transaction.rcpt_to.push(new Address('<matt@example.com>'))
66
- const cfg = this.plugin.get_config(this.connection)
67
- assert.equal(cfg.enable_tls, true)
68
- assert.equal(cfg.one_message_per_rcpt, true)
69
- assert.equal(cfg.host, 'localhost')
70
- })
143
+ it('TLS enabled but no outbound config in tls.ini', () => {
144
+ const plugin = new fixtures.plugin('queue/smtp_forward')
145
+ plugin.register()
146
+ assert.equal(plugin.tls_options, undefined)
147
+ assert.ok(Object.keys(plugin.hooks).length)
148
+ })
149
+ })
71
150
 
72
- it('valid recipient with route', () => {
73
- this.connection.transaction.rcpt_to.push(new Address('<matt@test.com>'))
74
- assert.deepEqual(this.plugin.get_config(this.connection), {
75
- host: '1.2.3.4',
76
- enable_tls: true,
77
- auth_user: 'postmaster@test.com',
78
- auth_pass: 'superDuperSecret',
79
- })
80
- })
151
+ // ─── load_smtp_forward_ini ────────────────────────────────────────────────────
81
152
 
82
- it('valid recipient with route & diff config', () => {
83
- this.connection.transaction.rcpt_to.push(new Address('<matt@test1.com>'))
84
- const cfg = this.plugin.get_config(this.connection)
85
- assert.deepEqual(cfg, {
86
- host: '1.2.3.4',
87
- enable_tls: false,
88
- })
89
- })
153
+ describe('smtp_forward load_smtp_forward_ini', () => {
154
+ it('loads configuration from ini file', () => {
155
+ const plugin = makePlugin()
156
+ assert.ok(plugin.cfg.main)
157
+ assert.equal(plugin.cfg.main.host, 'localhost')
158
+ })
90
159
 
91
- it('valid 2 recipients with same route', () => {
92
- this.connection.transaction.rcpt_to.push(new Address('<matt@test.com>'), new Address('<matt@test.com>'))
93
- const cfg = this.plugin.get_config(this.connection)
94
- assert.deepEqual(cfg.host, '1.2.3.4')
95
- })
160
+ it('sets up a reload callback', () => {
161
+ // Calling load_smtp_forward_ini again should not crash
162
+ const plugin = makePlugin()
163
+ assert.doesNotThrow(() => plugin.load_smtp_forward_ini())
164
+ assert.ok(plugin.cfg.main)
165
+ })
166
+ })
96
167
 
97
- it('null sender', () => {
98
- this.plugin.cfg.main.domain_selector = 'mail_from'
99
- this.connection.transaction.mail_from = new Address('<>')
100
- const cfg = this.plugin.get_config(this.connection)
101
- assert.equal(cfg.host, 'localhost')
102
- assert.equal(cfg.enable_tls, true)
103
- assert.equal(cfg.one_message_per_rcpt, true)
104
- })
168
+ // ─── get_config ───────────────────────────────────────────────────────────────
105
169
 
106
- it('return mail_from domain configuration', () => {
107
- this.connection.transaction.mail_from = new Address('<matt@test2.com>')
108
- this.plugin.cfg.main.domain_selector = 'mail_from'
109
- const cfg = this.plugin.get_config(this.connection)
110
- assert.deepEqual(cfg.host, '2.3.4.5')
111
- delete this.plugin.cfg.main.domain_selector // clear this for future tests
112
- })
170
+ describe('smtp_forward get_config', () => {
171
+ let plugin, connection
172
+
173
+ beforeEach(() => {
174
+ plugin = makePlugin()
175
+ connection = makeConnection()
113
176
  })
114
177
 
115
- describe('get_mx', () => {
116
- beforeEach(_setup)
178
+ it('returns main cfg when no transaction', () => {
179
+ connection.transaction = null
180
+ const cfg = plugin.get_config(connection)
181
+ assert.equal(cfg.host, 'localhost')
182
+ })
117
183
 
118
- it('returns no outbound route for undefined domains', (done) => {
119
- this.plugin.get_mx(
120
- (code, mx) => {
121
- assert.equal(code, undefined)
122
- assert.deepEqual(mx, undefined)
123
- done()
124
- },
125
- this.hmail,
126
- 'undefined.com',
127
- )
128
- })
184
+ it('returns main cfg when no rcpt_to (no domain_selector set)', () => {
185
+ const cfg = plugin.get_config(connection)
186
+ assert.equal(cfg.host, 'localhost')
187
+ assert.equal(cfg.enable_tls, true)
188
+ })
129
189
 
130
- it('returns no outbound route when queue.wants !== smtp_forward', (done) => {
131
- this.hmail.todo.notes.set('queue.wants', 'outbound')
132
- this.hmail.todo.notes.set('queue.next_hop', 'smtp://5.4.3.2:26')
133
- this.plugin.get_mx(
134
- (code, mx) => {
135
- assert.equal(code, undefined)
136
- assert.deepEqual(mx, undefined)
137
- done()
138
- },
139
- this.hmail,
140
- 'undefined.com',
141
- )
142
- })
190
+ it('returns main cfg for null recipient', () => {
191
+ connection.transaction.rcpt_to.push(new Address('<>'))
192
+ const cfg = plugin.get_config(connection)
193
+ assert.equal(cfg.host, 'localhost')
194
+ })
143
195
 
144
- it('returns an outbound route for defined domains', (done) => {
145
- this.plugin.get_mx(
146
- (code, mx) => {
147
- assert.equal(code, OK)
148
- assert.deepEqual(mx, {
149
- priority: 0,
150
- exchange: '1.2.3.4',
151
- port: 2555,
152
- auth_user: 'postmaster@test.com',
153
- auth_pass: 'superDuperSecret',
154
- })
155
- done()
156
- },
157
- this.hmail,
158
- 'test.com',
159
- )
160
- })
196
+ it('returns main cfg for unknown recipient domain', () => {
197
+ connection.transaction.rcpt_to.push(new Address('<matt@example.com>'))
198
+ const cfg = plugin.get_config(connection)
199
+ assert.equal(cfg.host, 'localhost')
200
+ })
161
201
 
162
- it('is enabled when queue.wants is set', (done) => {
163
- this.hmail.todo.notes.set('queue.wants', 'smtp_forward')
164
- this.hmail.todo.notes.set('queue.next_hop', 'smtp://4.3.2.1:465')
165
- this.plugin.get_mx(
166
- (code, mx) => {
167
- assert.equal(code, OK)
168
- assert.deepEqual(mx, {
169
- priority: 0,
170
- port: 465,
171
- exchange: '4.3.2.1',
172
- })
173
- done()
174
- },
175
- this.hmail,
176
- 'undefined.com',
177
- )
178
- })
202
+ it('returns domain config for known recipient domain', () => {
203
+ connection.transaction.rcpt_to.push(new Address('<matt@test.com>'))
204
+ const cfg = plugin.get_config(connection)
205
+ assert.equal(cfg.host, '1.2.3.4')
206
+ assert.equal(cfg.auth_user, 'postmaster@test.com')
207
+ })
179
208
 
180
- it('sets using_lmtp when next_hop URL is lmtp', (done) => {
181
- this.hmail.todo.notes.set('queue.wants', 'smtp_forward')
182
- this.hmail.todo.notes.set('queue.next_hop', 'lmtp://4.3.2.1')
183
- this.plugin.get_mx(
184
- (code, mx) => {
185
- assert.equal(code, OK)
186
- assert.deepEqual(mx, {
187
- priority: 0,
188
- port: 24,
189
- using_lmtp: true,
190
- exchange: '4.3.2.1',
191
- })
192
- done()
193
- },
194
- this.hmail,
195
- 'undefined.com',
196
- )
197
- })
209
+ it('returns domain config with different TLS setting', () => {
210
+ connection.transaction.rcpt_to.push(new Address('<matt@test1.com>'))
211
+ const cfg = plugin.get_config(connection)
212
+ assert.deepEqual(cfg, { host: '1.2.3.4', enable_tls: false })
198
213
  })
199
214
 
200
- describe('is_outbound_enabled', () => {
201
- beforeEach(_setup)
215
+ it('returns main cfg when domain_selector=mail_from but mail_from is null', () => {
216
+ plugin.cfg.main.domain_selector = 'mail_from'
217
+ connection.transaction.mail_from = null
218
+ const cfg = plugin.get_config(connection)
219
+ assert.equal(cfg.host, 'localhost')
220
+ })
202
221
 
203
- it('enable_outbound is false by default', () => {
204
- assert.equal(this.plugin.is_outbound_enabled(this.plugin.cfg), false)
205
- })
222
+ it('returns main cfg when domain_selector=mail_from and null sender', () => {
223
+ plugin.cfg.main.domain_selector = 'mail_from'
224
+ connection.transaction.mail_from = new Address('<>')
225
+ const cfg = plugin.get_config(connection)
226
+ assert.equal(cfg.host, 'localhost')
227
+ })
206
228
 
207
- it('per-domain enable_outbound is false by default', () => {
208
- this.connection.transaction.rcpt_to = [new Address('<postmaster@test.com>')]
209
- const cfg = this.plugin.get_config(this.connection)
210
- assert.equal(this.plugin.is_outbound_enabled(cfg), false)
211
- })
229
+ it('returns domain config for mail_from domain_selector', () => {
230
+ plugin.cfg.main.domain_selector = 'mail_from'
231
+ connection.transaction.mail_from = new Address('<matt@test2.com>')
232
+ const cfg = plugin.get_config(connection)
233
+ assert.equal(cfg.host, '2.3.4.5')
234
+ })
212
235
 
213
- it('per-domain enable_outbound can be set to true', () => {
214
- this.plugin.cfg['test.com'].enable_outbound = true
215
- this.connection.transaction.rcpt_to = [new Address('<postmaster@test.com>')]
216
- const cfg = this.plugin.get_config(this.connection)
217
- assert.equal(this.plugin.is_outbound_enabled(cfg), true)
218
- })
236
+ it('returns config by full email address when present', () => {
237
+ plugin.cfg.main.domain_selector = 'mail_from'
238
+ plugin.cfg['specific@test.com'] = { host: 'specific.example.com' }
239
+ connection.transaction.mail_from = new Address('<specific@test.com>')
240
+ const cfg = plugin.get_config(connection)
241
+ assert.equal(cfg.host, 'specific.example.com')
242
+ })
243
+ })
244
+
245
+ // ─── check_sender ────────────────────────────────────────────────────────────
246
+
247
+ describe('smtp_forward check_sender', () => {
248
+ let plugin, connection
249
+
250
+ beforeEach(() => {
251
+ plugin = makePlugin()
252
+ connection = makeConnection()
253
+ })
254
+
255
+ it('returns without calling next when no transaction', () => {
256
+ connection.transaction = null
257
+ let nextCalled = false
258
+ plugin.check_sender(
259
+ () => {
260
+ nextCalled = true
261
+ },
262
+ connection,
263
+ [new Address('<a@test.com>')],
264
+ )
265
+ assert.equal(nextCalled, false)
266
+ })
267
+
268
+ it('skips and calls next() for null/empty sender', () => {
269
+ let code
270
+ plugin.check_sender(
271
+ (c) => {
272
+ code = c
273
+ },
274
+ connection,
275
+ [new Address('<>')],
276
+ )
277
+ assert.equal(code, undefined) // next() with no args
278
+ })
279
+
280
+ it('calls next() when sender domain not in config', () => {
281
+ let called = false
282
+ plugin.check_sender(
283
+ () => {
284
+ called = true
285
+ },
286
+ connection,
287
+ [new Address('<user@unknown.com>')],
288
+ )
289
+ assert.ok(called)
290
+ })
291
+
292
+ it('denies spoofed MAIL FROM (domain in cfg, not relaying)', () => {
293
+ connection.relaying = false
294
+ let code
295
+ plugin.check_sender(
296
+ (c) => {
297
+ code = c
298
+ },
299
+ connection,
300
+ [new Address('<user@test.com>')],
301
+ )
302
+ assert.equal(code, DENY)
303
+ const r = connection.transaction.results.get(plugin)
304
+ assert.ok(r.fail.includes('mail_from!spoof'))
305
+ })
306
+
307
+ it('passes and calls next() when relaying from local domain', () => {
308
+ connection.relaying = true
309
+ let code
310
+ plugin.check_sender(
311
+ (c) => {
312
+ code = c
313
+ },
314
+ connection,
315
+ [new Address('<user@test.com>')],
316
+ )
317
+ assert.equal(code, undefined)
318
+ assert.ok(connection.transaction.notes.local_sender)
319
+ const r = connection.transaction.results.get(plugin)
320
+ assert.ok(r.pass.includes('mail_from'))
321
+ })
322
+ })
323
+
324
+ // ─── set_queue ────────────────────────────────────────────────────────────────
325
+
326
+ describe('smtp_forward set_queue', () => {
327
+ let plugin, connection
328
+
329
+ beforeEach(() => {
330
+ plugin = makePlugin()
331
+ connection = makeConnection()
332
+ })
333
+
334
+ it('returns false when transaction has no notes (no transaction)', () => {
335
+ connection.transaction = null
336
+ assert.equal(plugin.set_queue(connection, 'smtp_forward', 'test.com'), false)
337
+ })
338
+
339
+ it('sets queue.wants on first call', () => {
340
+ const result = plugin.set_queue(connection, 'smtp_forward', 'test.com')
341
+ assert.equal(result, true)
342
+ assert.equal(connection.transaction.notes.get('queue.wants'), 'smtp_forward')
343
+ })
344
+
345
+ it('sets queue.next_hop when domain has a host', () => {
346
+ plugin.set_queue(connection, 'smtp_forward', 'test.com')
347
+ assert.equal(connection.transaction.notes.get('queue.next_hop'), 'smtp://1.2.3.4')
348
+ })
349
+
350
+ it('does not set next_hop when domain has no host override', () => {
351
+ // test2.com has host=2.3.4.5, so it will set next_hop
352
+ plugin.set_queue(connection, 'smtp_forward', 'test2.com')
353
+ assert.equal(connection.transaction.notes.get('queue.next_hop'), 'smtp://2.3.4.5')
354
+ })
355
+
356
+ it('returns true for undefined domain (no dom_cfg)', () => {
357
+ const result = plugin.set_queue(connection, 'smtp_forward', 'unknown.com')
358
+ assert.equal(result, true)
359
+ assert.equal(connection.transaction.notes.get('queue.wants'), 'smtp_forward')
360
+ })
361
+
362
+ it('returns true when queue already set to same value (no dst_host)', () => {
363
+ connection.transaction.notes.set('queue.wants', 'smtp_forward')
364
+ // unknown.com has no host, so dst_host is just from main (localhost)
365
+ const result = plugin.set_queue(connection, 'smtp_forward', 'unknown.com')
366
+ assert.equal(result, true)
367
+ })
368
+
369
+ it('returns true when next_hop matches existing next_hop', () => {
370
+ connection.transaction.notes.set('queue.wants', 'smtp_forward')
371
+ connection.transaction.notes.set('queue.next_hop', 'smtp://1.2.3.4')
372
+ const result = plugin.set_queue(connection, 'smtp_forward', 'test.com')
373
+ assert.equal(result, true)
374
+ })
375
+
376
+ it('returns true when next_hop already set but no new dst_host', () => {
377
+ connection.transaction.notes.set('queue.wants', 'smtp_forward')
378
+ connection.transaction.notes.set('queue.next_hop', 'smtp://1.2.3.4')
379
+ // unknown.com has no specific host so dst_host comes from main.host='localhost'
380
+ // Actually let's use a domain with no host to test the !dst_host branch
381
+ delete plugin.cfg.main.host
382
+ const result = plugin.set_queue(connection, 'smtp_forward', 'unknown.com')
383
+ assert.equal(result, true)
384
+ })
385
+
386
+ it('returns false when different destination (split transaction)', () => {
387
+ connection.transaction.notes.set('queue.wants', 'smtp_forward')
388
+ connection.transaction.notes.set('queue.next_hop', 'smtp://9.9.9.9')
389
+ // test.com has host=1.2.3.4, which differs from 9.9.9.9
390
+ const result = plugin.set_queue(connection, 'smtp_forward', 'test.com')
391
+ assert.equal(result, false)
392
+ })
393
+
394
+ it('returns false when queue_wanted differs from existing', () => {
395
+ connection.transaction.notes.set('queue.wants', 'outbound')
396
+ const result = plugin.set_queue(connection, 'smtp_forward', 'test.com')
397
+ assert.equal(result, false)
398
+ })
399
+ })
400
+
401
+ // ─── check_recipient ─────────────────────────────────────────────────────────
402
+
403
+ describe('smtp_forward check_recipient', () => {
404
+ let plugin, connection
405
+
406
+ beforeEach(() => {
407
+ plugin = makePlugin()
408
+ connection = makeConnection()
409
+ })
410
+
411
+ it('returns without calling next when no transaction', () => {
412
+ connection.transaction = null
413
+ let called = false
414
+ plugin.check_recipient(
415
+ () => {
416
+ called = true
417
+ },
418
+ connection,
419
+ [new Address('<a@test.com>')],
420
+ )
421
+ assert.equal(called, false)
422
+ })
423
+
424
+ it('skips and calls next for rcpt with no host', () => {
425
+ let code
426
+ const rcpt = new Address('<>')
427
+ plugin.check_recipient(
428
+ (c) => {
429
+ code = c
430
+ },
431
+ connection,
432
+ [rcpt],
433
+ )
434
+ assert.equal(code, undefined)
435
+ })
436
+
437
+ it('uses outbound queue when relaying as local_sender', () => {
438
+ connection.relaying = true
439
+ connection.transaction.notes.local_sender = true
440
+ let code
441
+ plugin.check_recipient(
442
+ (c) => {
443
+ code = c
444
+ },
445
+ connection,
446
+ [new Address('<user@example.com>')],
447
+ )
448
+ assert.equal(code, OK)
449
+ assert.equal(connection.transaction.notes.get('queue.wants'), 'outbound')
450
+ })
451
+
452
+ it('accepts rcpt for a configured domain', () => {
453
+ let code
454
+ plugin.check_recipient(
455
+ (c) => {
456
+ code = c
457
+ },
458
+ connection,
459
+ [new Address('<user@test.com>')],
460
+ )
461
+ assert.equal(code, OK)
462
+ })
463
+
464
+ it('denies softly when set_queue fails for configured domain (split transaction)', () => {
465
+ // First call sets queue.wants to smtp_forward for test.com
466
+ plugin.set_queue(connection, 'smtp_forward', 'test.com')
467
+ // Now change the next_hop so the second call conflicts
468
+ connection.transaction.notes.set('queue.next_hop', 'smtp://9.9.9.9')
469
+ let code
470
+ plugin.check_recipient(
471
+ (c) => {
472
+ code = c
473
+ },
474
+ connection,
475
+ [new Address('<user@test.com>')],
476
+ )
477
+ assert.equal(code, DENYSOFT)
478
+ })
479
+
480
+ it('passes through for unconfigured domain (no route)', () => {
481
+ let code
482
+ plugin.check_recipient(
483
+ (c) => {
484
+ code = c
485
+ },
486
+ connection,
487
+ [new Address('<user@unknown.com>')],
488
+ )
489
+ assert.equal(code, undefined) // next() with no args
490
+ })
491
+ })
492
+
493
+ // ─── auth ─────────────────────────────────────────────────────────────────────
494
+
495
+ describe('smtp_forward auth', () => {
496
+ let plugin, connection, smtp_client
497
+
498
+ beforeEach(() => {
499
+ plugin = makePlugin()
500
+ connection = makeConnection()
501
+ smtp_client = new MockSMTPClient()
502
+ })
503
+
504
+ it('does nothing when smtp_client.secured is pending (false)', () => {
505
+ smtp_client.secured = false
506
+ const cfg = { auth_type: 'plain', auth_user: 'user', auth_pass: 'pass', host: 'relay', port: 25 }
507
+ plugin.auth(cfg, connection, smtp_client)
508
+ smtp_client.emit('capabilities')
509
+ assert.equal(smtp_client.commands.length, 0) // AUTH not sent
510
+ })
511
+
512
+ it('sends AUTH PLAIN credentials when auth_type=plain', () => {
513
+ const cfg = { auth_type: 'plain', auth_user: 'testuser', auth_pass: 'testpass', host: 'relay', port: 25 }
514
+ plugin.auth(cfg, connection, smtp_client)
515
+ smtp_client.emit('capabilities')
516
+ assert.ok(smtp_client.commands.some((c) => /^AUTH PLAIN/.test(c)))
517
+ })
518
+
519
+ it('AUTH PLAIN base64 encodes \\0user\\0pass', () => {
520
+ const cfg = { auth_type: 'plain', auth_user: 'u', auth_pass: 'p', host: 'relay', port: 25 }
521
+ plugin.auth(cfg, connection, smtp_client)
522
+ smtp_client.emit('capabilities')
523
+ const authCmd = smtp_client.commands.find((c) => /^AUTH PLAIN/.test(c))
524
+ assert.ok(authCmd)
525
+ const encoded = authCmd.split(' ')[2]
526
+ assert.equal(Buffer.from(encoded, 'base64').toString(), '\0u\0p')
527
+ })
528
+
529
+ it('sends AUTH LOGIN and sets authenticating=true when auth_type=login', () => {
530
+ const cfg = { auth_type: 'login', auth_user: 'testuser', auth_pass: 'testpass', host: 'relay', port: 25 }
531
+ plugin.auth(cfg, connection, smtp_client)
532
+ smtp_client.emit('capabilities')
533
+ assert.ok(smtp_client.commands.includes('AUTH LOGIN'))
534
+ assert.equal(smtp_client.authenticating, true)
535
+ assert.equal(smtp_client.authenticated, false)
536
+ })
537
+
538
+ it('login: responds to auth_username with base64 username', () => {
539
+ const cfg = { auth_type: 'login', auth_user: 'testuser', auth_pass: 'testpass', host: 'relay', port: 25 }
540
+ plugin.auth(cfg, connection, smtp_client)
541
+ smtp_client.emit('capabilities')
542
+ smtp_client.emit('auth_username')
543
+ assert.equal(smtp_client.commands.at(-1), Buffer.from('testuser').toString('base64'))
544
+ })
219
545
 
220
- it('per-domain enable_outbound is false even if top level is false', () => {
221
- this.plugin.cfg.main.enable_outbound = false // this will be ignored
222
- this.plugin.cfg['test.com'].enable_outbound = false
223
- this.connection.transaction.rcpt_to = [new Address('<postmaster@test.com>')]
224
- const cfg = this.plugin.get_config(this.connection)
225
- assert.equal(this.plugin.is_outbound_enabled(cfg), false)
546
+ it('login: responds to auth_password with base64 password', () => {
547
+ const cfg = { auth_type: 'login', auth_user: 'testuser', auth_pass: 'testpass', host: 'relay', port: 25 }
548
+ plugin.auth(cfg, connection, smtp_client)
549
+ smtp_client.emit('capabilities')
550
+ smtp_client.emit('auth_password')
551
+ assert.equal(smtp_client.commands.at(-1), Buffer.from('testpass').toString('base64'))
552
+ })
553
+
554
+ it('skips AUTH when secured is undefined (not pending)', () => {
555
+ // secured is undefined → no early return, AUTH PLAIN is sent
556
+ const cfg = { auth_type: 'plain', auth_user: 'u', auth_pass: 'p', host: 'relay', port: 25 }
557
+ delete smtp_client.secured
558
+ plugin.auth(cfg, connection, smtp_client)
559
+ smtp_client.emit('capabilities')
560
+ assert.ok(smtp_client.commands.some((c) => /^AUTH PLAIN/.test(c)))
561
+ })
562
+ })
563
+
564
+ // ─── forward_enabled ──────────────────────────────────────────────────────────
565
+
566
+ describe('smtp_forward forward_enabled', () => {
567
+ let plugin, connection
568
+
569
+ beforeEach(() => {
570
+ plugin = makePlugin()
571
+ connection = makeConnection()
572
+ })
573
+
574
+ it('returns false when queue.wants is set to a non smtp_forward value', () => {
575
+ connection.transaction.notes.set('queue.wants', 'outbound')
576
+ assert.equal(plugin.forward_enabled(connection, plugin.cfg.main), false)
577
+ })
578
+
579
+ it('returns false when relaying and outbound is disabled', () => {
580
+ connection.relaying = true
581
+ // enable_outbound is false by default in test config
582
+ assert.equal(plugin.forward_enabled(connection, plugin.cfg.main), false)
583
+ })
584
+
585
+ it('returns true when queue.wants is smtp_forward', () => {
586
+ connection.transaction.notes.set('queue.wants', 'smtp_forward')
587
+ assert.equal(plugin.forward_enabled(connection, plugin.cfg.main), true)
588
+ })
589
+
590
+ it('returns true when not relaying (even if outbound disabled)', () => {
591
+ connection.relaying = false
592
+ assert.equal(plugin.forward_enabled(connection, plugin.cfg.main), true)
593
+ })
594
+
595
+ it('returns true when relaying and outbound is enabled', () => {
596
+ connection.relaying = true
597
+ plugin.cfg.main.enable_outbound = true
598
+ assert.equal(plugin.forward_enabled(connection, plugin.cfg.main), true)
599
+ })
600
+ })
601
+
602
+ // ─── queue_forward ────────────────────────────────────────────────────────────
603
+
604
+ describe('smtp_forward queue_forward', () => {
605
+ let plugin, connection, restore
606
+
607
+ beforeEach(() => {
608
+ plugin = makePlugin()
609
+ connection = makeConnection()
610
+ connection.transaction.rcpt_to = [new Address('<rcpt@example.com>')]
611
+ connection.transaction.mail_from = new Address('<sender@example.com>')
612
+ connection.relaying = false
613
+ })
614
+
615
+ afterEach(() => {
616
+ if (restore) {
617
+ restore()
618
+ restore = null
619
+ }
620
+ })
621
+
622
+ it('returns without calling next when remote.closed', () => {
623
+ connection.remote.closed = true
624
+ let called = false
625
+ plugin.queue_forward(() => {
626
+ called = true
627
+ }, connection)
628
+ assert.equal(called, false)
629
+ })
630
+
631
+ it('calls next() when forward_enabled returns false', (t, done) => {
632
+ connection.relaying = true // outbound disabled → forward_enabled=false
633
+ plugin.queue_forward((code) => {
634
+ assert.equal(code, undefined)
635
+ done()
636
+ }, connection)
637
+ })
638
+
639
+ it('forwards mail: mail event triggers first RCPT', (t, done) => {
640
+ const client = new MockSMTPClient()
641
+ restore = stubGetClientPlugin((plug, conn, cfg, cb) => cb(null, client))
642
+
643
+ plugin.queue_forward(() => {}, connection)
644
+ client.emit('mail')
645
+
646
+ assert.ok(client.commands.some((c) => /^RCPT TO:/.test(c)))
647
+ done()
648
+ })
649
+
650
+ it('sends DATA after last RCPT TO', (t, done) => {
651
+ const client = new MockSMTPClient()
652
+ restore = stubGetClientPlugin((plug, conn, cfg, cb) => cb(null, client))
653
+
654
+ plugin.queue_forward(() => {}, connection)
655
+ client.emit('mail') // sends RCPT TO for index 0
656
+ client.emit('rcpt') // one_message_per_rcpt=true, sends DATA
657
+
658
+ // wait for the DATA command
659
+ assert.ok(client.commands.some((c) => c === 'DATA' || c.includes('DATA')))
660
+ done()
661
+ })
662
+
663
+ it('data event calls start_data with message_stream', (t, done) => {
664
+ const client = new MockSMTPClient()
665
+ restore = stubGetClientPlugin((plug, conn, cfg, cb) => cb(null, client))
666
+
667
+ plugin.queue_forward(() => {}, connection)
668
+ client.emit('mail')
669
+ client.emit('rcpt') // sends DATA (one_message_per_rcpt)
670
+ client.emit('data')
671
+
672
+ assert.ok(client.started)
673
+ done()
674
+ })
675
+
676
+ it('dot event calls next(OK) and releases when all rcpts done', (t, done) => {
677
+ const client = new MockSMTPClient()
678
+ restore = stubGetClientPlugin((plug, conn, cfg, cb) => cb(null, client))
679
+
680
+ let gotCode
681
+ plugin.queue_forward((code) => {
682
+ gotCode = code
683
+ }, connection)
684
+
685
+ client.emit('mail')
686
+ client.emit('rcpt')
687
+ client.emit('data')
688
+ client.emit('dot')
689
+
690
+ // release() is called after call_next() in the dot handler
691
+ assert.equal(gotCode, OK)
692
+ assert.ok(client.released)
693
+ done()
694
+ })
695
+
696
+ it('dot event sends RSET when more rcpts remain (multi-rcpt, one_message_per_rcpt)', (t, done) => {
697
+ connection.transaction.rcpt_to = [new Address('<a@example.com>'), new Address('<b@example.com>')]
698
+ const client = new MockSMTPClient()
699
+ restore = stubGetClientPlugin((plug, conn, cfg, cb) => cb(null, client))
700
+
701
+ plugin.queue_forward(() => {}, connection)
702
+ client.emit('mail') // sends RCPT TO for index 0
703
+ client.emit('rcpt') // one_message_per_rcpt → sends DATA
704
+ client.emit('data')
705
+ client.commands = [] // clear to observe next commands
706
+ client.emit('dot') // more rcpts remain → RSET
707
+
708
+ assert.ok(client.commands.includes('RSET'))
709
+ done()
710
+ })
711
+
712
+ it('rset event sends MAIL FROM', (t, done) => {
713
+ const client = new MockSMTPClient()
714
+ restore = stubGetClientPlugin((plug, conn, cfg, cb) => cb(null, client))
715
+
716
+ plugin.queue_forward(() => {}, connection)
717
+ client.commands = []
718
+ client.emit('rset')
719
+
720
+ assert.ok(client.commands.some((c) => /^MAIL FROM:/.test(c)))
721
+ done()
722
+ })
723
+
724
+ it('bad_code 5xx emits DENY and releases', (t, done) => {
725
+ const client = new MockSMTPClient()
726
+ restore = stubGetClientPlugin((plug, conn, cfg, cb) => cb(null, client))
727
+
728
+ let gotCode
729
+ plugin.queue_forward((code) => {
730
+ gotCode = code
731
+ }, connection)
732
+
733
+ client.emit('bad_code', '550', 'User unknown')
734
+
735
+ // release() is called after call_next() in the bad_code handler
736
+ assert.equal(gotCode, DENY)
737
+ assert.ok(client.released)
738
+ done()
739
+ })
740
+
741
+ it('bad_code 4xx emits DENYSOFT and releases', (t, done) => {
742
+ const client = new MockSMTPClient()
743
+ restore = stubGetClientPlugin((plug, conn, cfg, cb) => cb(null, client))
744
+
745
+ plugin.queue_forward((code) => {
746
+ assert.equal(code, DENYSOFT)
747
+ done()
748
+ }, connection)
749
+
750
+ client.emit('bad_code', '421', 'Service unavailable')
751
+ })
752
+
753
+ it('dead_sender: adds err result and skips forwarding', (t, done) => {
754
+ const client = new MockSMTPClient()
755
+ client.is_dead_sender = () => true
756
+ restore = stubGetClientPlugin((plug, conn, cfg, cb) => cb(null, client))
757
+
758
+ plugin.queue_forward(() => {}, connection)
759
+ client.emit('mail')
760
+
761
+ const r = connection.transaction.results.get(plugin)
762
+ assert.ok(r.err.some((e) => /dead sender/.test(e)))
763
+ done()
764
+ })
765
+
766
+ it('calls plugin.auth when auth_user is configured in cfg', (t, done) => {
767
+ const client = new MockSMTPClient()
768
+ restore = stubGetClientPlugin((plug, conn, cfg, cb) => cb(null, client))
769
+
770
+ // point the connection to test.com domain which has auth_user in the ini
771
+ connection.transaction.rcpt_to = [new Address('<user@test.com>')]
772
+
773
+ let authCalled = false
774
+ const origAuth = plugin.auth
775
+ plugin.auth = () => {
776
+ authCalled = true
777
+ }
778
+ plugin.queue_forward(() => {}, connection)
779
+ plugin.auth = origAuth
780
+
781
+ assert.ok(authCalled)
782
+ done()
783
+ })
784
+
785
+ it('uses forwarding_host_pool when configured', (t, done) => {
786
+ const client = new MockSMTPClient()
787
+ let capturedCfg
788
+ restore = stubGetClientPlugin((plug, conn, cfg, cb) => {
789
+ capturedCfg = cfg
790
+ cb(null, client)
226
791
  })
792
+
793
+ plugin.cfg.main.forwarding_host_pool = '10.0.0.1:25'
794
+ delete plugin.cfg.main.host
795
+ plugin.queue_forward(() => {}, connection)
796
+
797
+ assert.ok(capturedCfg.forwarding_host_pool)
798
+ done()
799
+ })
800
+ })
801
+
802
+ // ─── get_mx_next_hop ─────────────────────────────────────────────────────────
803
+
804
+ describe('smtp_forward get_mx_next_hop', () => {
805
+ it('parses smtp URL with port', () => {
806
+ const mx = smtp_client_mod.smtp_client // not used; just accessing exports
807
+ const plugin = makePlugin()
808
+ const mx_val = plugin.get_mx_next_hop('smtp://10.0.0.1:587')
809
+ assert.equal(mx_val.exchange, '10.0.0.1')
810
+ assert.equal(mx_val.port, '587')
811
+ assert.equal(mx_val.priority, 0)
812
+ })
813
+
814
+ it('defaults port to 25 for smtp without explicit port', () => {
815
+ const plugin = makePlugin()
816
+ const mx_val = plugin.get_mx_next_hop('smtp://10.0.0.1')
817
+ assert.equal(mx_val.port, 25)
818
+ })
819
+
820
+ it('parses lmtp URL and sets using_lmtp=true with port 24', () => {
821
+ const plugin = makePlugin()
822
+ const mx_val = plugin.get_mx_next_hop('lmtp://10.0.0.2')
823
+ assert.equal(mx_val.using_lmtp, true)
824
+ assert.equal(mx_val.port, 24)
825
+ })
826
+
827
+ it('extracts auth credentials from URL', () => {
828
+ const plugin = makePlugin()
829
+ const mx_val = plugin.get_mx_next_hop('smtp://user:secret@10.0.0.1:25')
830
+ assert.equal(mx_val.auth_type, 'plain')
831
+ assert.equal(mx_val.auth_user, 'user')
832
+ assert.equal(mx_val.auth_pass, 'secret')
833
+ })
834
+ })
835
+
836
+ // ─── get_mx ───────────────────────────────────────────────────────────────────
837
+
838
+ describe('smtp_forward get_mx', () => {
839
+ let plugin, hmail
840
+
841
+ beforeEach(() => {
842
+ plugin = makePlugin()
843
+ hmail = makeHmail()
844
+ })
845
+
846
+ it('returns no route for undefined domains', (t, done) => {
847
+ plugin.get_mx(
848
+ (code, mx) => {
849
+ assert.equal(code, undefined)
850
+ assert.equal(mx, undefined)
851
+ done()
852
+ },
853
+ hmail,
854
+ 'undefined.com',
855
+ )
856
+ })
857
+
858
+ it('returns no route when queue.wants is not smtp_forward or outbound', (t, done) => {
859
+ hmail.todo.notes.set('queue.wants', 'some_other_queue')
860
+ plugin.get_mx(
861
+ (code, mx) => {
862
+ assert.equal(code, undefined)
863
+ done()
864
+ },
865
+ hmail,
866
+ 'test.com',
867
+ )
868
+ })
869
+
870
+ it('returns route from next_hop URL when queue.wants=smtp_forward', (t, done) => {
871
+ hmail.todo.notes.set('queue.wants', 'smtp_forward')
872
+ hmail.todo.notes.set('queue.next_hop', 'smtp://4.3.2.1:465')
873
+ plugin.get_mx(
874
+ (code, mx) => {
875
+ assert.equal(code, OK)
876
+ assert.equal(mx.exchange, '4.3.2.1')
877
+ assert.equal(mx.port, '465')
878
+ done()
879
+ },
880
+ hmail,
881
+ 'anything.com',
882
+ )
883
+ })
884
+
885
+ it('returns route for configured domain', (t, done) => {
886
+ plugin.get_mx(
887
+ (code, mx) => {
888
+ assert.equal(code, OK)
889
+ assert.equal(mx.exchange, '1.2.3.4')
890
+ assert.equal(mx.port, 2555)
891
+ assert.equal(mx.auth_user, 'postmaster@test.com')
892
+ assert.equal(mx.auth_pass, 'superDuperSecret')
893
+ done()
894
+ },
895
+ hmail,
896
+ 'test.com',
897
+ )
898
+ })
899
+
900
+ it('returns no route (DNS MX) for unconfigured domain when queue.wants=outbound', (t, done) => {
901
+ hmail.todo.notes.set('queue.wants', 'outbound')
902
+ plugin.get_mx(
903
+ (code, mx) => {
904
+ assert.equal(code, undefined)
905
+ done()
906
+ },
907
+ hmail,
908
+ 'notconfigured.com',
909
+ )
910
+ })
911
+
912
+ it('uses lmtp URL and sets using_lmtp when next_hop is lmtp', (t, done) => {
913
+ hmail.todo.notes.set('queue.wants', 'smtp_forward')
914
+ hmail.todo.notes.set('queue.next_hop', 'lmtp://4.3.2.1')
915
+ plugin.get_mx(
916
+ (code, mx) => {
917
+ assert.equal(code, OK)
918
+ assert.equal(mx.using_lmtp, true)
919
+ assert.equal(mx.port, 24)
920
+ done()
921
+ },
922
+ hmail,
923
+ 'anywhere.com',
924
+ )
925
+ })
926
+
927
+ it('uses mail_from host when domain_selector=mail_from', (t, done) => {
928
+ plugin.cfg.main.domain_selector = 'mail_from'
929
+ hmail.todo.mail_from = new Address('<sender@test.com>')
930
+ plugin.get_mx(
931
+ (code, mx) => {
932
+ assert.equal(code, OK)
933
+ assert.equal(mx.exchange, '1.2.3.4')
934
+ done()
935
+ },
936
+ hmail,
937
+ 'anything.com',
938
+ )
939
+ })
940
+
941
+ it('applies mx_opts from domain config', (t, done) => {
942
+ plugin.cfg['test.com'].bind = '192.168.1.1'
943
+ plugin.cfg['test.com'].bind_helo = 'relay.example.com'
944
+ plugin.get_mx(
945
+ (code, mx) => {
946
+ assert.equal(code, OK)
947
+ assert.equal(mx.bind, '192.168.1.1')
948
+ assert.equal(mx.bind_helo, 'relay.example.com')
949
+ done()
950
+ },
951
+ hmail,
952
+ 'test.com',
953
+ )
954
+ })
955
+ })
956
+
957
+ // ─── is_outbound_enabled ──────────────────────────────────────────────────────
958
+
959
+ describe('smtp_forward is_outbound_enabled', () => {
960
+ let plugin, connection
961
+
962
+ beforeEach(() => {
963
+ plugin = makePlugin()
964
+ connection = makeConnection()
965
+ })
966
+
967
+ it('enable_outbound is false by default (global)', () => {
968
+ assert.equal(plugin.is_outbound_enabled(plugin.cfg), false)
969
+ })
970
+
971
+ it('per-domain enable_outbound is false by default', () => {
972
+ connection.transaction.rcpt_to = [new Address('<postmaster@test.com>')]
973
+ const cfg = plugin.get_config(connection)
974
+ assert.equal(plugin.is_outbound_enabled(cfg), false)
975
+ })
976
+
977
+ it('per-domain enable_outbound can be set to true', () => {
978
+ plugin.cfg['test.com'].enable_outbound = true
979
+ connection.transaction.rcpt_to = [new Address('<postmaster@test.com>')]
980
+ const cfg = plugin.get_config(connection)
981
+ assert.equal(plugin.is_outbound_enabled(cfg), true)
982
+ })
983
+
984
+ it('per-domain enable_outbound overrides global false', () => {
985
+ plugin.cfg.main.enable_outbound = false
986
+ plugin.cfg['test.com'].enable_outbound = false
987
+ connection.transaction.rcpt_to = [new Address('<postmaster@test.com>')]
988
+ const cfg = plugin.get_config(connection)
989
+ assert.equal(plugin.is_outbound_enabled(cfg), false)
990
+ })
991
+
992
+ it('falls back to global enable_outbound when not in domain cfg', () => {
993
+ plugin.cfg.main.enable_outbound = true
994
+ connection.transaction.rcpt_to = [new Address('<user@example.com>')]
995
+ const cfg = plugin.get_config(connection) // returns cfg.main
996
+ assert.equal(plugin.is_outbound_enabled(cfg), true)
227
997
  })
228
998
  })