Haraka 3.1.4 → 3.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.prettierignore +1 -1
  2. package/{Changes.md → CHANGELOG.md} +34 -0
  3. package/CONTRIBUTORS.md +26 -26
  4. package/README.md +68 -93
  5. package/SECURITY.md +178 -0
  6. package/bin/haraka +7 -14
  7. package/config/plugins +0 -3
  8. package/docs/Connection.md +126 -39
  9. package/docs/CoreConfig.md +92 -74
  10. package/docs/HAProxy.md +41 -25
  11. package/docs/Logging.md +68 -38
  12. package/docs/Outbound.md +124 -179
  13. package/docs/Plugins.md +38 -59
  14. package/docs/Transaction.md +78 -83
  15. package/docs/Tutorial.md +122 -209
  16. package/docs/plugins/aliases.md +1 -141
  17. package/docs/plugins/auth/auth_ldap.md +2 -39
  18. package/docs/plugins/max_unrecognized_commands.md +4 -18
  19. package/docs/plugins/process_title.md +3 -3
  20. package/docs/plugins/reseed_rng.md +11 -13
  21. package/docs/plugins/tls.md +7 -7
  22. package/docs/plugins/toobusy.md +10 -4
  23. package/docs/tutorials/SettingUpOutbound.md +40 -48
  24. package/endpoint.js +32 -2
  25. package/outbound/hmail.js +3 -2
  26. package/outbound/index.js +3 -0
  27. package/package.json +21 -34
  28. package/plugins/queue/smtp_forward.js +4 -4
  29. package/run_tests +3 -15
  30. package/server.js +17 -7
  31. package/smtp_client.js +8 -6
  32. package/test/connection.js +234 -0
  33. package/test/endpoint.js +32 -4
  34. package/test/host_pool.js +57 -31
  35. package/test/logger.js +75 -135
  36. package/test/outbound/bounce_net_errors.js +87 -131
  37. package/test/outbound/bounce_rfc3464.js +177 -254
  38. package/test/outbound/hmail.js +19 -0
  39. package/test/outbound/index.js +189 -0
  40. package/test/outbound/queue.js +92 -0
  41. package/test/plugins/auth/auth_base.js +39 -44
  42. package/test/plugins/auth/auth_vpopmaild.js +8 -9
  43. package/test/plugins/queue/smtp_forward.js +953 -183
  44. package/test/plugins/rcpt_to.host_list_base.js +58 -93
  45. package/test/plugins/rcpt_to.in_host_list.js +126 -175
  46. package/test/plugins/record_envelope_addresses.js +8 -8
  47. package/test/plugins/status.js +10 -10
  48. package/test/plugins/tls.js +9 -19
  49. package/test/plugins/xclient.js +75 -110
  50. package/test/plugins.js +10 -13
  51. package/test/rfc1869.js +50 -70
  52. package/test/server.js +438 -421
  53. package/test/smtp_client.js +1192 -218
  54. package/test/tls_socket.js +242 -0
  55. package/tls_socket.js +18 -22
package/test/server.js CHANGED
@@ -1,124 +1,203 @@
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 { createHmac } = require('node:crypto')
6
+ const net = require('node:net')
2
7
  const path = require('node:path')
8
+ const tls = require('node:tls')
9
+ const constants = require('haraka-constants')
3
10
 
4
11
  const endpoint = require('../endpoint')
5
12
  const message = require('haraka-email-message')
13
+ const { get_client } = require('../smtp_client')
6
14
 
7
- const _set_up = (done) => {
8
- this.config = require('haraka-config')
9
- this.server = require('../server')
10
- done()
11
- }
12
-
13
- const _setupServer = (ip_port, done) => {
14
- process.env.YES_REALLY_DO_DISCARD = 1 // for queue/discard plugin
15
- process.env.HARAKA_TEST_DIR = path.resolve('test')
16
-
17
- // test sets the default path for plugin instances to the test dir
18
- const test_cfg_path = path.resolve('test')
19
-
20
- this.server = require('../server')
21
- this.config = require('haraka-config').module_config(test_cfg_path)
22
- this.server.logger.loglevel = 6 // INFO
15
+ // ─── CRAM-MD5 helper ──────────────────────────────────────────────────────────
23
16
 
24
- // set the default path for the plugin loader
25
- this.server.config = this.config.module_config(test_cfg_path)
26
- this.server.plugins.config = this.config.module_config(test_cfg_path)
27
- // this.server.outbound.config = this.config.module_config(this_cfg_path);
28
-
29
- this.server.load_smtp_ini()
30
- this.server.cfg.main.listen = ip_port
31
- this.server.cfg.main.smtps_port = 2465
32
-
33
- this.server.load_default_tls_config(() => {
34
- this.server.createServer({})
35
- setTimeout(() => {
36
- done()
37
- }, 200)
38
- })
17
+ /** Compute a CRAM-MD5 response to a server challenge. */
18
+ const cramMd5Response = (user, pass, challenge) => {
19
+ const decoded = Buffer.from(challenge, 'base64').toString()
20
+ const hmac = createHmac('md5', pass).update(decoded).digest('hex')
21
+ return Buffer.from(`${user} ${hmac}`).toString('base64')
39
22
  }
40
23
 
41
- const _tearDownServer = (done) => {
42
- delete process.env.YES_REALLY_DO_DISCARD
43
- delete process.env.HARAKA_TEST_DIR
44
- this.server.stopListeners()
45
- this.server.plugins.registered_hooks = {}
46
- setTimeout(() => {
47
- done()
48
- }, 200)
49
- }
24
+ // ─── Server lifecycle helpers ─────────────────────────────────────────────────
50
25
 
51
- describe('server', () => {
52
- describe('get_listen_addrs', () => {
53
- beforeEach(_set_up)
26
+ const setupServer = (ip_port) =>
27
+ new Promise((resolve) => {
28
+ process.env.YES_REALLY_DO_DISCARD = '1'
29
+ process.env.HARAKA_TEST_DIR = path.resolve('test')
30
+ const test_cfg_path = path.resolve('test')
54
31
 
55
- it('IPv4 fully qualified', () => {
56
- const listeners = this.server.get_listen_addrs({
57
- listen: '127.0.0.1:25',
58
- })
59
- assert.deepEqual(['127.0.0.1:25'], listeners)
60
- })
32
+ this.server = require('../server')
33
+ this.config = require('haraka-config').module_config(test_cfg_path)
34
+ this.server.logger.loglevel = 6
35
+ this.server.config = this.config.module_config(test_cfg_path)
36
+ this.server.plugins.config = this.config.module_config(test_cfg_path)
61
37
 
62
- it('IPv4, default port', () => {
63
- const listeners = this.server.get_listen_addrs({
64
- listen: '127.0.0.1',
65
- })
66
- assert.deepEqual(['127.0.0.1:25'], listeners)
67
- })
38
+ this.server.load_smtp_ini()
39
+ this.server.cfg.main.listen = ip_port
40
+ this.server.cfg.main.smtps_port = 2465
68
41
 
69
- it('IPv4, custom port', () => {
70
- const listeners = this.server.get_listen_addrs({ listen: '127.0.0.1' }, 250)
71
- assert.deepEqual(['127.0.0.1:250'], listeners)
42
+ this.server.load_default_tls_config(() => {
43
+ this.server.createServer({})
44
+ setTimeout(resolve, 200)
72
45
  })
46
+ })
73
47
 
74
- it('IPv6 fully qualified', () => {
75
- const listeners = this.server.get_listen_addrs({
76
- listen: '[::1]:25',
77
- })
78
- assert.deepEqual(['[::1]:25'], listeners)
79
- })
48
+ const tearDownServer = () =>
49
+ new Promise((resolve) => {
50
+ delete process.env.YES_REALLY_DO_DISCARD
51
+ delete process.env.HARAKA_TEST_DIR
52
+ this.server.stopListeners()
53
+ this.server.plugins.registered_hooks = {}
54
+ setTimeout(resolve, 200)
55
+ })
80
56
 
81
- it('IPv6, default port', () => {
82
- const listeners = this.server.get_listen_addrs({ listen: '[::1]' })
83
- assert.deepEqual(['[::1]:25'], listeners)
84
- })
57
+ // ─── SMTP session helper ──────────────────────────────────────────────────────
58
+
59
+ /**
60
+ * Deliver a message via smtp_client and return a Promise that resolves on
61
+ * acceptance (dot event) or rejects on any SMTP error (bad_code event).
62
+ *
63
+ * When `user`/`pass` are provided, CRAM-MD5 authentication is performed
64
+ * before sending the message.
65
+ */
66
+ const sendMessage = ({
67
+ host = '127.0.0.1',
68
+ port,
69
+ from = '<test@haraka.local>',
70
+ to = '<discard@haraka.local>',
71
+ user,
72
+ pass,
73
+ body = 'Hello from smtp_client test',
74
+ } = {}) =>
75
+ new Promise((resolve, reject) => {
76
+ get_client(
77
+ { notes: {} },
78
+ (client) => {
79
+ let credsSent = false
80
+
81
+ client
82
+ .on('greeting', (cmd) => client.send_command(cmd, host))
83
+ .on('helo', () => {
84
+ if (user && !credsSent) {
85
+ client.authenticating = true
86
+ client.send_command('AUTH', 'CRAM-MD5')
87
+ } else {
88
+ client.send_command('MAIL', `FROM:${from}`)
89
+ }
90
+ })
91
+ .on('auth', () => {
92
+ if (client.authenticated) {
93
+ client.send_command('MAIL', `FROM:${from}`)
94
+ } else if (!credsSent) {
95
+ credsSent = true
96
+ const resp = cramMd5Response(user, pass, client.response[0])
97
+ // Write CRAM-MD5 response directly (no command prefix)
98
+ client.command = 'auth'
99
+ client.response = []
100
+ client.socket.write(`${resp}\r\n`)
101
+ }
102
+ })
103
+ .on('mail', () => client.send_command('RCPT', `TO:${to}`))
104
+ .on('rcpt', () => client.send_command('DATA'))
105
+ .on('data', () => {
106
+ const stream = new message.stream({ main: { spool_after: 1024 } }, 'testId')
107
+ stream.on('end', () => client.socket.write('.\r\n'))
108
+ stream.add_line('Subject: test\r\n')
109
+ stream.add_line('\r\n')
110
+ stream.add_line(`${body}\r\n`)
111
+ stream.add_line_end()
112
+ client.start_data(stream)
113
+ })
114
+ .on('dot', () => {
115
+ client.release()
116
+ resolve()
117
+ })
118
+ .on('bad_code', (code, msg) => {
119
+ client.release()
120
+ reject(new Error(`${code} ${msg}`))
121
+ })
122
+ },
123
+ { host, port, connect_timeout: 5 },
124
+ )
125
+ })
85
126
 
86
- it('IPv6, custom port', () => {
87
- const listeners = this.server.get_listen_addrs({ listen: '[::1]' }, 250)
88
- assert.deepEqual(['[::1]:250'], listeners)
89
- })
127
+ // ─── Tests ────────────────────────────────────────────────────────────────────
90
128
 
91
- it('IPv4 & IPv6 fully qualified', () => {
92
- const listeners = this.server.get_listen_addrs({
93
- listen: '127.0.0.1:25,[::1]:25',
94
- })
95
- assert.deepEqual(['127.0.0.1:25', '[::1]:25'], listeners)
129
+ describe('server', () => {
130
+ // ── get_listen_addrs ──────────────────────────────────────────────────────
131
+ describe('get_listen_addrs', () => {
132
+ beforeEach(() => {
133
+ this.config = require('haraka-config')
134
+ this.server = require('../server')
96
135
  })
97
136
 
98
- it('IPv4 & IPv6, default port', () => {
99
- const listeners = this.server.get_listen_addrs({
100
- listen: '127.0.0.1:25,[::1]',
137
+ const cases = [
138
+ {
139
+ desc: 'IPv4 fully qualified',
140
+ args: [{ listen: '127.0.0.1:25' }],
141
+ expected: ['127.0.0.1:25'],
142
+ },
143
+ {
144
+ desc: 'IPv4, default port',
145
+ args: [{ listen: '127.0.0.1' }],
146
+ expected: ['127.0.0.1:25'],
147
+ },
148
+ {
149
+ desc: 'IPv4, custom port',
150
+ args: [{ listen: '127.0.0.1' }, 250],
151
+ expected: ['127.0.0.1:250'],
152
+ },
153
+ {
154
+ desc: 'IPv6 fully qualified',
155
+ args: [{ listen: '[::1]:25' }],
156
+ expected: ['[::1]:25'],
157
+ },
158
+ {
159
+ desc: 'IPv6, default port',
160
+ args: [{ listen: '[::1]' }],
161
+ expected: ['[::1]:25'],
162
+ },
163
+ {
164
+ desc: 'IPv6, custom port',
165
+ args: [{ listen: '[::1]' }, 250],
166
+ expected: ['[::1]:250'],
167
+ },
168
+ {
169
+ desc: 'IPv4 & IPv6 fully qualified',
170
+ args: [{ listen: '127.0.0.1:25,[::1]:25' }],
171
+ expected: ['127.0.0.1:25', '[::1]:25'],
172
+ },
173
+ {
174
+ desc: 'IPv4 & IPv6, default port',
175
+ args: [{ listen: '127.0.0.1:25,[::1]' }],
176
+ expected: ['127.0.0.1:25', '[::1]:25'],
177
+ },
178
+ {
179
+ desc: 'IPv4 & IPv6, custom port',
180
+ args: [{ listen: '127.0.0.1,[::1]' }, 250],
181
+ expected: ['127.0.0.1:250', '[::1]:250'],
182
+ },
183
+ ]
184
+
185
+ for (const { desc, args, expected } of cases) {
186
+ it(desc, () => {
187
+ assert.deepEqual(this.server.get_listen_addrs(...args), expected)
101
188
  })
102
- assert.deepEqual(['127.0.0.1:25', '[::1]:25'], listeners)
103
- })
104
-
105
- it('IPv4 & IPv6, custom port', () => {
106
- const listeners = this.server.get_listen_addrs(
107
- {
108
- listen: '127.0.0.1,[::1]',
109
- },
110
- 250,
111
- )
112
- assert.deepEqual(['127.0.0.1:250', '[::1]:250'], listeners)
113
- })
189
+ }
114
190
  })
115
191
 
192
+ // ── load_smtp_ini ─────────────────────────────────────────────────────────
116
193
  describe('load_smtp_ini', () => {
117
- beforeEach(_set_up)
194
+ beforeEach(() => {
195
+ this.config = require('haraka-config')
196
+ this.server = require('../server')
197
+ })
118
198
 
119
199
  it('saves settings to Server.cfg', () => {
120
200
  this.server.load_smtp_ini()
121
- // console.log(this.server.cfg);
122
201
  const c = this.server.cfg.main
123
202
  assert.notEqual(c.daemonize, undefined)
124
203
  assert.notEqual(c.daemon_log_file, undefined)
@@ -126,387 +205,325 @@ describe('server', () => {
126
205
  })
127
206
  })
128
207
 
208
+ // ── get_smtp_server ───────────────────────────────────────────────────────
129
209
  describe('get_smtp_server', () => {
130
- beforeEach((done) => {
131
- this.config = require('haraka-config')
132
- this.config = this.config.module_config(path.resolve('test'))
133
-
210
+ beforeEach(async () => {
211
+ this.config = require('haraka-config').module_config(path.resolve('test'))
134
212
  this.server = require('../server')
135
213
  this.server.config = this.config
136
214
  this.server.plugins.config = this.config
137
-
138
- this.server.load_default_tls_config(() => {
139
- setTimeout(() => {
140
- done()
141
- }, 200)
215
+ await new Promise((resolve) => {
216
+ this.server.load_default_tls_config(() => setTimeout(resolve, 200))
142
217
  })
143
218
  })
144
219
 
145
- it('gets a net server object', (done) => {
146
- this.server.get_smtp_server(endpoint('0.0.0.0:2501'), 10).then((server) => {
147
- if (!server) {
148
- console.error('unable to bind to 0.0.0.0:2501')
149
- if (process.env.CI) return // can't bind to IP/port (fails on Travis)
150
- }
151
- assert.ok(server)
152
- assert.equal(server.has_tls, false)
153
- server.getConnections((err, count) => {
154
- assert.equal(0, count)
155
- done()
156
- })
157
- })
220
+ it('gets a net server object', async () => {
221
+ const server = await this.server.get_smtp_server(endpoint('0.0.0.0:2501'), 10)
222
+ if (!server) {
223
+ if (process.env.CI) return
224
+ assert.fail('unable to bind to 0.0.0.0:2501')
225
+ }
226
+ assert.ok(server)
227
+ assert.equal(server.has_tls, false)
228
+ const count = await new Promise((res) => server.getConnections((err, n) => res(n)))
229
+ assert.equal(count, 0)
158
230
  })
159
231
 
160
- it('gets a TLS net server object', (done) => {
232
+ it('gets a TLS net server object', async () => {
161
233
  this.server.cfg.main.smtps_port = 2502
162
- this.server.get_smtp_server(endpoint('0.0.0.0:2502'), 10).then((server) => {
163
- if (!server) {
164
- console.error('unable to bind to 0.0.0.0:2502')
165
- if (process.env.CI) return // can't bind to IP/port (fails on Travis)
166
- }
167
- assert.ok(server)
168
- assert.equal(server.has_tls, true)
169
- server.getConnections((err, count) => {
170
- assert.equal(0, count)
171
- done()
172
- })
173
- })
234
+ const server = await this.server.get_smtp_server(endpoint('0.0.0.0:2502'), 10)
235
+ if (!server) {
236
+ if (process.env.CI) return
237
+ assert.fail('unable to bind to 0.0.0.0:2502')
238
+ }
239
+ assert.ok(server)
240
+ assert.equal(server.has_tls, true)
241
+ const count = await new Promise((res) => server.getConnections((err, n) => res(n)))
242
+ assert.equal(count, 0)
174
243
  })
175
244
  })
176
245
 
246
+ // ── get_http_docroot ──────────────────────────────────────────────────────
177
247
  describe('get_http_docroot', () => {
178
- beforeEach(_set_up)
248
+ beforeEach(() => {
249
+ this.config = require('haraka-config')
250
+ this.server = require('../server')
251
+ })
179
252
 
180
253
  it('gets a fs path', () => {
181
254
  assert.ok(this.server.get_http_docroot())
182
255
  })
183
256
  })
184
257
 
185
- describe('smtp_client', () => {
186
- beforeEach((done) => {
187
- _setupServer('localhost:2500', done)
258
+ describe('lifecycle helpers', () => {
259
+ beforeEach(() => {
260
+ this.server = require('../server')
261
+ this.server.cfg = this.server.cfg || { main: {} }
262
+ this.server.cfg.main = this.server.cfg.main || {}
188
263
  })
189
264
 
190
- afterEach(_tearDownServer)
265
+ it('init_child_respond OK path starts HTTP listeners', () => {
266
+ let called = 0
267
+ const original = this.server.setup_http_listeners
268
+ this.server.setup_http_listeners = () => {
269
+ called++
270
+ }
271
+ try {
272
+ this.server.init_child_respond(constants.ok)
273
+ assert.equal(called, 1)
274
+ } finally {
275
+ this.server.setup_http_listeners = original
276
+ }
277
+ })
191
278
 
192
- it('accepts SMTP message', () => {
193
- const server = { notes: {} }
194
- const cfg = {
195
- connect_timeout: 2,
279
+ it('init_child_respond error path kills master and exits', () => {
280
+ process.env.CLUSTER_MASTER_PID = '12345'
281
+ const originalKill = process.kill
282
+ const originalDump = this.server.logger.dump_and_exit
283
+ let killed = null
284
+ let exitCode = null
285
+ process.kill = (pid) => {
286
+ killed = pid
196
287
  }
288
+ this.server.logger.dump_and_exit = (code) => {
289
+ exitCode = code
290
+ }
291
+ try {
292
+ this.server.init_child_respond(constants.deny, 'nope')
293
+ assert.equal(killed, '12345')
294
+ assert.equal(exitCode, 1)
295
+ } finally {
296
+ process.kill = originalKill
297
+ this.server.logger.dump_and_exit = originalDump
298
+ delete process.env.CLUSTER_MASTER_PID
299
+ }
300
+ })
197
301
 
198
- const smtp_client = require('../smtp_client')
199
-
200
- smtp_client.get_client(
201
- server,
202
- (client) => {
203
- client
204
- .on('greeting', (command) => {
205
- client.send_command('HELO', 'haraka.local')
206
- })
207
- .on('helo', () => {
208
- client.send_command('MAIL', 'FROM:<test@haraka.local>')
209
- })
210
- .on('mail', () => {
211
- client.send_command('RCPT', 'TO:<nobody-will-see-this@haraka.local>')
212
- })
213
- .on('rcpt', () => {
214
- client.send_command('DATA')
215
- })
216
- .on('data', () => {
217
- const message_stream = new message.stream({ main: { spool_after: 1024 } }, 'theMessageId')
218
-
219
- message_stream.on('end', () => {
220
- client.socket.write('.\r\n')
221
- })
222
- message_stream.add_line('Header: test\r\n')
223
- message_stream.add_line('\r\n')
224
- message_stream.add_line('I am body text\r\n')
225
- message_stream.add_line_end()
226
-
227
- client.start_data(message_stream)
228
- })
229
- .on('dot', () => {
230
- assert.ok(1)
231
- client.release()
232
- })
233
- .on('bad_code', (code, msg) => {
234
- client.release()
235
- })
236
- },
237
- { port: 2500, host: 'localhost', cfg },
238
- )
302
+ it('listening applies configured uid/gid and marks ready', () => {
303
+ this.server.cfg.main.group = 'staff'
304
+ this.server.cfg.main.user = 'nobody'
305
+ const originalGetGid = process.getgid
306
+ const originalSetGid = process.setgid
307
+ const originalGetUid = process.getuid
308
+ const originalSetUid = process.setuid
309
+ const calls = { setgid: 0, setuid: 0 }
310
+ process.getgid = () => 20
311
+ process.setgid = () => {
312
+ calls.setgid++
313
+ }
314
+ process.getuid = () => 501
315
+ process.setuid = () => {
316
+ calls.setuid++
317
+ }
318
+ try {
319
+ this.server.listening()
320
+ assert.equal(calls.setgid, 1)
321
+ assert.equal(calls.setuid, 1)
322
+ assert.equal(this.server.ready, 1)
323
+ } finally {
324
+ process.getgid = originalGetGid
325
+ process.setgid = originalSetGid
326
+ process.getuid = originalGetUid
327
+ process.setuid = originalSetUid
328
+ delete this.server.cfg.main.group
329
+ delete this.server.cfg.main.user
330
+ }
239
331
  })
240
- })
241
332
 
242
- describe('nodemailer', () => {
243
- beforeEach((done) => {
244
- _setupServer('127.0.0.1:2503', done)
333
+ it('sendToMaster calls receiveAsMaster when not clustered', () => {
334
+ const originalCluster = this.server.cluster
335
+ const originalReceive = this.server.receiveAsMaster
336
+ const seen = []
337
+ this.server.cluster = null
338
+ this.server.receiveAsMaster = (cmd, params) => {
339
+ seen.push([cmd, params])
340
+ }
341
+ try {
342
+ this.server.sendToMaster('flushQueue', ['example.com'])
343
+ assert.deepEqual(seen[0], ['flushQueue', ['example.com']])
344
+ } finally {
345
+ this.server.cluster = originalCluster
346
+ this.server.receiveAsMaster = originalReceive
347
+ }
245
348
  })
246
349
 
247
- afterEach(_tearDownServer)
350
+ it('receiveAsMaster ignores invalid commands and executes valid ones', () => {
351
+ const errors = []
352
+ const originalLogError = this.server.logerror
353
+ this.server.logerror = (msg) => errors.push(msg)
354
+ this.server._testCommand = (a, b) => {
355
+ this.server.notes.received = [a, b]
356
+ }
357
+ try {
358
+ this.server.receiveAsMaster('notACommand', [])
359
+ assert.equal(errors.length > 0, true)
360
+
361
+ this.server.receiveAsMaster('_testCommand', ['x', 'y'])
362
+ assert.deepEqual(this.server.notes.received, ['x', 'y'])
363
+ } finally {
364
+ this.server.logerror = originalLogError
365
+ delete this.server._testCommand
366
+ }
367
+ })
368
+ })
248
369
 
249
- it('accepts SMTP message', (done) => {
250
- const nodemailer = require('nodemailer')
251
- const transporter = nodemailer.createTransport({
252
- host: '127.0.0.1',
253
- port: 2503,
254
- tls: {
255
- // do not fail on invalid certs
256
- rejectUnauthorized: false,
257
- },
258
- })
259
- transporter.sendMail(
260
- {
261
- from: '"Testalicious Matt" <harakamail@gmail.com>',
262
- to: 'nobody-will-see-this@haraka.local',
263
- envelope: {
264
- from: 'Haraka Test <test@haraka.local>',
265
- to: 'Discard Queue <discard@haraka.local>',
266
- },
267
- subject: 'Hello ✔',
268
- text: 'Hello world ?',
269
- html: '<b>Hello world ?</b>',
270
- },
271
- (error, info) => {
272
- if (error) {
273
- console.log(error)
274
- return
275
- }
276
- assert.deepEqual(info.accepted, ['discard@haraka.local'])
277
- console.log(`Message sent: ${info.response}`)
278
- done()
279
- },
280
- )
370
+ describe('HTTP helpers', () => {
371
+ beforeEach(() => {
372
+ this.server = require('../server')
281
373
  })
282
374
 
283
- it('accepts authenticated SMTP', (done) => {
284
- const nodemailer = require('nodemailer')
285
- const transporter = nodemailer.createTransport({
286
- host: '127.0.0.1',
287
- port: 2503,
288
- auth: {
289
- user: 'matt',
290
- pass: 'goodPass',
291
- },
292
- requireTLS: true,
293
- tls: {
294
- // do not fail on invalid certs
295
- rejectUnauthorized: false,
375
+ it('handle404 serves html/json/text based on request accepts', () => {
376
+ const makeReq = (kind) => ({
377
+ accepts(type) {
378
+ return type === kind
296
379
  },
297
380
  })
298
-
299
- transporter.sendMail(
300
- {
301
- from: '"Testalicious Matt" <harakamail@gmail.com>',
302
- to: 'nobody-will-see-this@haraka.local',
303
- envelope: {
304
- from: 'Haraka Test <test@haraka.local>',
305
- to: 'Discard Queue <discard@haraka.local>',
306
- },
307
- subject: 'Hello ✔',
308
- text: 'Hello world ?',
309
- html: '<b>Hello world ?</b>',
381
+ const responses = []
382
+ const makeRes = () => ({
383
+ status(code) {
384
+ responses.push({ code })
385
+ return this
310
386
  },
311
- (error, info) => {
312
- if (error) {
313
- console.log(error)
314
- return
315
- }
316
- assert.deepEqual(info.accepted, ['discard@haraka.local'])
317
- console.log(`Message sent: ${info.response}`)
318
- done()
387
+ sendFile(name, opts) {
388
+ responses.push({ type: 'html', name, opts })
319
389
  },
320
- )
321
- })
322
-
323
- it('rejects invalid auth', (done) => {
324
- const nodemailer = require('nodemailer')
325
- const transporter = nodemailer.createTransport({
326
- host: '127.0.0.1',
327
- port: 2503,
328
- auth: {
329
- user: 'matt',
330
- pass: 'badPass',
331
- },
332
- tls: {
333
- // do not fail on invalid certs
334
- rejectUnauthorized: false,
390
+ send(body) {
391
+ responses.push({ type: 'body', body })
335
392
  },
336
393
  })
337
394
 
338
- transporter.sendMail(
339
- {
340
- from: '"Testalicious Matt" <harakamail@gmail.com>',
341
- to: 'nobody-will-see-this@haraka.local',
342
- envelope: {
343
- from: 'Haraka Test <test@haraka.local>',
344
- to: 'Discard Queue <discard@haraka.local>',
345
- },
346
- subject: 'Hello ✔',
347
- text: 'Hello world ?',
348
- html: '<b>Hello world ?</b>',
349
- },
350
- (error, info) => {
351
- if (error) {
352
- assert.equal(error.code, 'EAUTH')
353
- // console.log(error);
354
- return done()
355
- }
356
- console.log(info.response)
357
- done()
358
- },
359
- )
360
- })
395
+ this.server.handle404(makeReq('html'), makeRes())
396
+ this.server.handle404(makeReq('json'), makeRes())
397
+ this.server.handle404(makeReq('none'), makeRes())
361
398
 
362
- it('DKIM validates signed message', (done) => {
363
- const nodemailer = require('nodemailer')
364
- const transporter = nodemailer.createTransport({
365
- host: '127.0.0.1',
366
- port: 2503,
367
- tls: {
368
- // do not fail on invalid certs
369
- rejectUnauthorized: false,
370
- },
371
- })
399
+ assert.equal(responses[0].code, 404)
400
+ assert.equal(responses[1].type, 'html')
401
+ assert.equal(responses[3].type, 'body')
402
+ assert.deepEqual(responses[3].body, { err: 'Not found' })
403
+ assert.equal(responses[5].body, 'Not found!')
404
+ })
372
405
 
373
- transporter.sendMail(
374
- {
375
- from: '"Testalicious Matt" <harakamail@gmail.com>',
376
- to: 'nobody-will-see-this@haraka.local',
377
- envelope: {
378
- from: 'Haraka Test <test@haraka.local>',
379
- to: 'Discard Queue <discard@haraka.local>',
380
- },
381
- subject: 'Hello ✔',
382
- text: 'Hello world ?',
383
- html: '<b>Hello world ?</b>',
384
- dkim: {
385
- domainName: 'test.simerson.com',
386
- keySelector: 'harakatest2017',
387
- privateKey:
388
- '-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEAxqoUAnQ9GB3iNnkS7coj0Iggd0nyryW062tpK95NC5UXmmAwIpUMfkYdiHY2o2duWYGF0Bp237M/QXKhJYTXfsgkwP/bq9OGWtRZxHPHhbhdjbiI\nqObi6zvYcxrI77gpWDDvruhMeS9Hwa1R99pLUWd4PsuYTzbV/jwu2pz+XZXXXNEU\nVxzDAAj0yF7mwxHMLzQfR+hdhWcrgN0stUP0o7hm7hoOP8IWgcSW3JiQYavIKoI4\nm4+I9I1LzDJN2rHVnQvmjUrqqpG7X6SyFVFtuTWGaMqf1Cj/t8eSvU9VdgLFllS8\ntThqUZHq5S5hm8M8VzLuQLG9U0dtFolcFmJkbQIDAQABAoIBAB4fUbNhjpXmihM6\nXm1htfZ7fXi45Kw76me7vJGjPklgTNjidsn3kZJf7UBwtC4ok6nMos6ABMA8fH3e\n9KIst0QI8tG0ucke5INHKWlJKNqUrtK7RTVe9M84HsStLgRzBwnRObZqkJXbXmT2\nc7RCDCOGrcvPsQNpzB6lX3FUVpk3x24RXpQV1qSgH8yuHSPc1C6rssXwPAgnESfS\nK3MHRx2CLZvTTkq/YCsT+wS/O9RWPCVOYuWaa5DDDAIp3Yw1wYq9Upoh0BdIFC3U\nWm+5Cr3o9wxcvS6+W2RA6I51eymzvCU5ZakWt/bnUDb6/ByxsWOn5rL4WfPpCwE4\nnuC72v0CgYEA9imEq6a0GoaEsMoR7cxT7uXKimQH+Jaq3CGkuh0iN32F4FXhuUKz\nLYKSLCZzpb1MiDJv6BBchV6uSQ6ATo1cZ8WzYQISikk175bf0SPom591OZElvKA2\nSOrTrXtbl33YbWZEgyEcpTgelVi5ys9rj4eKkMvM0lwRmW6gctEFXRcCgYEAzpqc\nR/wqPjgPhpF1CZtdEwOZg4kkOig8CBcuQ7o/hDG7N69A9ZbeJO8eD+gKDrHRfkYr\nTH/UdkZGjilBk/lxnpIZpyBLxQ6UdhNPuwtxXKAvuSN+aQ0pdJn8tg03OSj2OzTK\nJ4hMsO/wt1xM8EDRobLZEosMadaYZUHzx8VU5RsCgYEAvFZbuXEcT0cocpLIUOaK\nOTf7VRLfvmSYaUAcZoEv0sDpExDiWPodWO6To8/vn5lL2tCsKiOKhkhAlIjRxkgF\nsSfj7I7HXKJS7/LBX6RXrem8qMTS2JTDs9pnBk5hb3DLjDg4pxNIdWiQjbeKvw8f\nvnr3m30yQqhKlte7Tt15exUCgYBzq7RbyR6Nfy2SFdYE7usJPjawohOaS/RwQyov\n2RK+nGlJH+GqnjD5VLbsCOm4mG3F2NtdFSSKo4XVCdwhUMMAGKQsIbTKOwN7qAw3\nmIx7Y2PUr76SakAPfDc0ZenJItnZBBE6WOE3Ht8Siaa5zFCRy2QlMZxdlTv1VRt7\neUuyiQKBgQDdXJO5+3h1HPxbYZcmNm/2CJUNw2ehU8vCiBXCcWPn7JukayHx+TXy\nyj0j/b1SvmKgjB+4JWluiqIU+QBjRjvb397QY1YoCEaGZd0zdFjTZwQksQ5AFst9\nCiD9OFXe/kkmIUQQra6aw1CoppyAfvAblp8uevLWb57xU3VUB3xeGg==\n-----END RSA PRIVATE KEY-----\n',
389
- },
390
- },
391
- (error, info) => {
392
- // console.log(info);
393
- if (error) {
394
- console.log(error)
395
- return
396
- }
397
- assert.deepEqual(info.accepted, ['discard@haraka.local'])
398
- console.log(`Message sent: ${info.response}`)
399
- done()
400
- },
401
- )
406
+ it('init_http_respond logs and returns when ws is unavailable', () => {
407
+ const Module = require('node:module')
408
+ const originalRequire = Module.prototype.require
409
+ const originalLogError = this.server.logerror
410
+ const errors = []
411
+ this.server.logerror = (msg) => {
412
+ errors.push(msg)
413
+ }
414
+ this.server.http = { server: {} }
415
+ Module.prototype.require = function (id) {
416
+ if (id === 'ws') throw new Error('ws missing')
417
+ return originalRequire.apply(this, arguments)
418
+ }
419
+ try {
420
+ this.server.init_http_respond()
421
+ assert.equal(errors.length > 0, true)
422
+ } finally {
423
+ Module.prototype.require = originalRequire
424
+ this.server.logerror = originalLogError
425
+ }
402
426
  })
403
427
  })
404
428
 
405
- describe('requireAuthorized_SMTPS', () => {
406
- beforeEach((done) => {
407
- _setupServer('127.0.0.1:2465', done)
408
- })
429
+ // ── SMTP sessions ─────────────────────────────────────────────────────────
430
+ describe('SMTP sessions', () => {
431
+ beforeEach(async () => setupServer('127.0.0.1:2503'))
432
+ afterEach(async () => tearDownServer())
409
433
 
410
- afterEach(_tearDownServer)
434
+ it('accepts plain SMTP message', async () => {
435
+ await sendMessage({ port: 2503 })
436
+ })
411
437
 
412
- it('rejects non-validated SMTPS connection', (done) => {
413
- const nodemailer = require('nodemailer')
414
- const transporter = nodemailer.createTransport({
415
- host: '127.0.0.1',
416
- port: 2465,
417
- secure: true,
418
- tls: {
419
- // do not fail on invalid certs
420
- rejectUnauthorized: false,
421
- },
422
- })
438
+ it('accepts CRAM-MD5 authenticated SMTP', async () => {
439
+ await sendMessage({ port: 2503, user: 'matt', pass: 'goodPass' })
440
+ })
423
441
 
424
- // give the SMTPS listener a second to start listening
425
- setTimeout(() => {
426
- transporter.sendMail(
427
- {
428
- from: '"Testalicious Matt" <harakamail@gmail.com>',
429
- to: 'nobody-will-see-this@haraka.local',
430
- envelope: {
431
- from: 'Haraka Test <test@haraka.local>',
432
- to: 'Discard Queue <discard@haraka.local>',
433
- },
434
- subject: 'Hello ✔',
435
- text: 'Hello world ?',
436
- html: '<b>Hello world ?</b>',
437
- },
438
- (error, info) => {
439
- if (error) {
440
- // console.log(error);
441
- if (error.message === 'socket hang up') {
442
- // node 6 & 8
443
- assert.equal(error.message, 'socket hang up')
444
- } else if (/alert certificate required/.test(error.message)) {
445
- // node 18
446
- assert.ok(/alert certificate required/.test(error.message))
447
- } else {
448
- // node 10+
449
- assert.equal(
450
- error.message,
451
- 'Client network socket disconnected before secure TLS connection was established',
452
- )
453
- }
454
- }
455
- done()
456
- },
457
- )
458
- }, 500)
442
+ it('rejects invalid CRAM-MD5 credentials', async () => {
443
+ await assert.rejects(() => sendMessage({ port: 2503, user: 'matt', pass: 'badPass' }), /5\d\d/)
459
444
  })
460
- })
461
445
 
462
- describe('requireAuthorized_STARTTLS', () => {
463
- beforeEach((done) => {
464
- _setupServer('127.0.0.1:2587', done)
446
+ it('accepts message with custom headers', async () => {
447
+ await sendMessage({
448
+ port: 2503,
449
+ from: '<sender@haraka.local>',
450
+ to: '<discard@haraka.local>',
451
+ body: 'X-Custom: test-value\r\n\r\nBody text',
452
+ })
465
453
  })
454
+ })
466
455
 
467
- it('rejects non-validated STARTTLS connection', (done) => {
468
- const nodemailer = require('nodemailer')
469
- const transporter = nodemailer.createTransport({
470
- host: '127.0.0.1',
471
- port: 2587,
472
- secure: false,
473
- tls: {
474
- // do not fail on invalid certs
456
+ // ── requireAuthorized: SMTPS (implicit TLS) ───────────────────────────────
457
+ describe('requireAuthorized_SMTPS', () => {
458
+ beforeEach(async () => setupServer('127.0.0.1:2465'))
459
+ afterEach(async () => tearDownServer())
460
+
461
+ it('rejects non-validated SMTPS connection', async () => {
462
+ // Port 2465 is configured as SMTPS with requireAuthorized.
463
+ // In TLSv1.3 the handshake completes (secureConnect fires), then the server
464
+ // sends a post-handshake "certificate required" alert as a socket error.
465
+ const err = await new Promise((resolve) => {
466
+ const socket = tls.connect({
467
+ host: '127.0.0.1',
468
+ port: 2465,
475
469
  rejectUnauthorized: false,
476
- },
470
+ })
471
+ socket.on('error', resolve)
472
+ // secureConnect may fire before the post-handshake alert; keep waiting.
473
+ socket.on('secureConnect', () => {})
474
+ setTimeout(() => {
475
+ socket.destroy()
476
+ resolve(new Error('timeout'))
477
+ }, 3000)
477
478
  })
479
+ assert.ok(
480
+ /socket hang up|disconnected before secure TLS|alert certificate required/.test(err.message),
481
+ `unexpected error: ${err.message}`,
482
+ )
483
+ })
484
+ })
478
485
 
479
- // give the SMTPS listener a half second to start listening
480
- setTimeout(() => {
481
- transporter.sendMail(
482
- {
483
- from: '"Testalicious Matt" <harakamail@gmail.com>',
484
- to: 'nobody-will-see-this@haraka.local',
485
- envelope: {
486
- from: 'Haraka Test <test@haraka.local>',
487
- to: 'Discard Queue <discard@haraka.local>',
488
- },
489
- subject: 'Hello ✔',
490
- text: 'Hello world ?',
491
- html: '<b>Hello world ?</b>',
492
- },
493
- (error, info) => {
494
- if (error) {
495
- // console.log(error);
496
- if (/alert certificate required/.test(error.message)) {
497
- // node 18
498
- assert.ok(/alert certificate required/.test(error.message))
499
- } else {
500
- assert.equal(
501
- error.message,
502
- 'Client network socket disconnected before secure TLS connection was established',
503
- )
504
- }
486
+ // ── requireAuthorized: STARTTLS ───────────────────────────────────────────
487
+ describe('requireAuthorized_STARTTLS', () => {
488
+ beforeEach(async () => setupServer('127.0.0.1:2587'))
489
+ afterEach(async () => tearDownServer())
490
+
491
+ it('rejects non-validated STARTTLS connection', async () => {
492
+ // Port 2587 is plain SMTP; requireAuthorized enforces mutual TLS on STARTTLS upgrade.
493
+ // In TLSv1.3 secureConnect fires first, then the server sends a post-handshake
494
+ // "certificate required" alert. Use raw sockets to observe the TLS error.
495
+ // (smtp_client's upgrade path silently swallows the post-upgrade error.)
496
+ const err = await new Promise((resolve) => {
497
+ const sock = net.connect({ host: '127.0.0.1', port: 2587 })
498
+ let state = 'greeting'
499
+ let buf = ''
500
+ sock.on('data', (d) => {
501
+ buf += d.toString()
502
+ for (const line of buf.split('\r\n').slice(0, -1)) {
503
+ buf = buf.slice(line.length + 2)
504
+ if (line[3] === '-') continue // multi-line continuation
505
+ if (state === 'greeting') {
506
+ sock.write('EHLO test\r\n')
507
+ state = 'ehlo'
508
+ } else if (state === 'ehlo') {
509
+ sock.write('STARTTLS\r\n')
510
+ state = 'starttls'
511
+ } else if (state === 'starttls') {
512
+ state = 'tls'
513
+ const cleartext = tls.connect({ socket: sock, rejectUnauthorized: false })
514
+ cleartext.on('secureConnect', () => {})
515
+ cleartext.on('error', resolve)
516
+ cleartext.on('close', () => resolve(new Error('closed without error')))
505
517
  }
506
- done()
507
- },
508
- )
509
- }, 500)
518
+ }
519
+ })
520
+ sock.on('error', resolve)
521
+ setTimeout(() => resolve(new Error('timeout')), 3000)
522
+ })
523
+ assert.ok(
524
+ /alert certificate required|socket hang up|disconnected/.test(err.message),
525
+ `unexpected error: ${err.message}`,
526
+ )
510
527
  })
511
528
  })
512
529
  })