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
package/test/server.js CHANGED
@@ -1,124 +1,202 @@
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')
3
9
 
4
10
  const endpoint = require('../endpoint')
5
11
  const message = require('haraka-email-message')
12
+ const { get_client } = require('../smtp_client')
6
13
 
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
23
-
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
14
+ // ─── CRAM-MD5 helper ──────────────────────────────────────────────────────────
32
15
 
33
- this.server.load_default_tls_config(() => {
34
- this.server.createServer({})
35
- setTimeout(() => {
36
- done()
37
- }, 200)
38
- })
16
+ /** Compute a CRAM-MD5 response to a server challenge. */
17
+ const cramMd5Response = (user, pass, challenge) => {
18
+ const decoded = Buffer.from(challenge, 'base64').toString()
19
+ const hmac = createHmac('md5', pass).update(decoded).digest('hex')
20
+ return Buffer.from(`${user} ${hmac}`).toString('base64')
39
21
  }
40
22
 
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
- }
23
+ // ─── Server lifecycle helpers ─────────────────────────────────────────────────
50
24
 
51
- describe('server', () => {
52
- describe('get_listen_addrs', () => {
53
- beforeEach(_set_up)
25
+ const setupServer = (ip_port) =>
26
+ new Promise((resolve) => {
27
+ process.env.YES_REALLY_DO_DISCARD = '1'
28
+ process.env.HARAKA_TEST_DIR = path.resolve('test')
29
+ const test_cfg_path = path.resolve('test')
54
30
 
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
- })
31
+ this.server = require('../server')
32
+ this.config = require('haraka-config').module_config(test_cfg_path)
33
+ this.server.logger.loglevel = 6
34
+ this.server.config = this.config.module_config(test_cfg_path)
35
+ this.server.plugins.config = this.config.module_config(test_cfg_path)
61
36
 
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
- })
37
+ this.server.load_smtp_ini()
38
+ this.server.cfg.main.listen = ip_port
39
+ this.server.cfg.main.smtps_port = 2465
68
40
 
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)
41
+ this.server.load_default_tls_config(() => {
42
+ this.server.createServer({})
43
+ setTimeout(resolve, 200)
72
44
  })
45
+ })
73
46
 
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
- })
47
+ const tearDownServer = () =>
48
+ new Promise((resolve) => {
49
+ delete process.env.YES_REALLY_DO_DISCARD
50
+ delete process.env.HARAKA_TEST_DIR
51
+ this.server.stopListeners()
52
+ this.server.plugins.registered_hooks = {}
53
+ setTimeout(resolve, 200)
54
+ })
80
55
 
81
- it('IPv6, default port', () => {
82
- const listeners = this.server.get_listen_addrs({ listen: '[::1]' })
83
- assert.deepEqual(['[::1]:25'], listeners)
84
- })
56
+ // ─── SMTP session helper ──────────────────────────────────────────────────────
57
+
58
+ /**
59
+ * Deliver a message via smtp_client and return a Promise that resolves on
60
+ * acceptance (dot event) or rejects on any SMTP error (bad_code event).
61
+ *
62
+ * When `user`/`pass` are provided, CRAM-MD5 authentication is performed
63
+ * before sending the message.
64
+ */
65
+ const sendMessage = ({
66
+ host = '127.0.0.1',
67
+ port,
68
+ from = '<test@haraka.local>',
69
+ to = '<discard@haraka.local>',
70
+ user,
71
+ pass,
72
+ body = 'Hello from smtp_client test',
73
+ } = {}) =>
74
+ new Promise((resolve, reject) => {
75
+ get_client(
76
+ { notes: {} },
77
+ (client) => {
78
+ let credsSent = false
79
+
80
+ client
81
+ .on('greeting', (cmd) => client.send_command(cmd, host))
82
+ .on('helo', () => {
83
+ if (user && !credsSent) {
84
+ client.authenticating = true
85
+ client.send_command('AUTH', 'CRAM-MD5')
86
+ } else {
87
+ client.send_command('MAIL', `FROM:${from}`)
88
+ }
89
+ })
90
+ .on('auth', () => {
91
+ if (client.authenticated) {
92
+ client.send_command('MAIL', `FROM:${from}`)
93
+ } else if (!credsSent) {
94
+ credsSent = true
95
+ const resp = cramMd5Response(user, pass, client.response[0])
96
+ // Write CRAM-MD5 response directly (no command prefix)
97
+ client.command = 'auth'
98
+ client.response = []
99
+ client.socket.write(`${resp}\r\n`)
100
+ }
101
+ })
102
+ .on('mail', () => client.send_command('RCPT', `TO:${to}`))
103
+ .on('rcpt', () => client.send_command('DATA'))
104
+ .on('data', () => {
105
+ const stream = new message.stream({ main: { spool_after: 1024 } }, 'testId')
106
+ stream.on('end', () => client.socket.write('.\r\n'))
107
+ stream.add_line('Subject: test\r\n')
108
+ stream.add_line('\r\n')
109
+ stream.add_line(`${body}\r\n`)
110
+ stream.add_line_end()
111
+ client.start_data(stream)
112
+ })
113
+ .on('dot', () => {
114
+ client.release()
115
+ resolve()
116
+ })
117
+ .on('bad_code', (code, msg) => {
118
+ client.release()
119
+ reject(new Error(`${code} ${msg}`))
120
+ })
121
+ },
122
+ { host, port, connect_timeout: 5 },
123
+ )
124
+ })
85
125
 
86
- it('IPv6, custom port', () => {
87
- const listeners = this.server.get_listen_addrs({ listen: '[::1]' }, 250)
88
- assert.deepEqual(['[::1]:250'], listeners)
89
- })
126
+ // ─── Tests ────────────────────────────────────────────────────────────────────
90
127
 
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)
128
+ describe('server', () => {
129
+ // ── get_listen_addrs ──────────────────────────────────────────────────────
130
+ describe('get_listen_addrs', () => {
131
+ beforeEach(() => {
132
+ this.config = require('haraka-config')
133
+ this.server = require('../server')
96
134
  })
97
135
 
98
- it('IPv4 & IPv6, default port', () => {
99
- const listeners = this.server.get_listen_addrs({
100
- listen: '127.0.0.1:25,[::1]',
136
+ const cases = [
137
+ {
138
+ desc: 'IPv4 fully qualified',
139
+ args: [{ listen: '127.0.0.1:25' }],
140
+ expected: ['127.0.0.1:25'],
141
+ },
142
+ {
143
+ desc: 'IPv4, default port',
144
+ args: [{ listen: '127.0.0.1' }],
145
+ expected: ['127.0.0.1:25'],
146
+ },
147
+ {
148
+ desc: 'IPv4, custom port',
149
+ args: [{ listen: '127.0.0.1' }, 250],
150
+ expected: ['127.0.0.1:250'],
151
+ },
152
+ {
153
+ desc: 'IPv6 fully qualified',
154
+ args: [{ listen: '[::1]:25' }],
155
+ expected: ['[::1]:25'],
156
+ },
157
+ {
158
+ desc: 'IPv6, default port',
159
+ args: [{ listen: '[::1]' }],
160
+ expected: ['[::1]:25'],
161
+ },
162
+ {
163
+ desc: 'IPv6, custom port',
164
+ args: [{ listen: '[::1]' }, 250],
165
+ expected: ['[::1]:250'],
166
+ },
167
+ {
168
+ desc: 'IPv4 & IPv6 fully qualified',
169
+ args: [{ listen: '127.0.0.1:25,[::1]:25' }],
170
+ expected: ['127.0.0.1:25', '[::1]:25'],
171
+ },
172
+ {
173
+ desc: 'IPv4 & IPv6, default port',
174
+ args: [{ listen: '127.0.0.1:25,[::1]' }],
175
+ expected: ['127.0.0.1:25', '[::1]:25'],
176
+ },
177
+ {
178
+ desc: 'IPv4 & IPv6, custom port',
179
+ args: [{ listen: '127.0.0.1,[::1]' }, 250],
180
+ expected: ['127.0.0.1:250', '[::1]:250'],
181
+ },
182
+ ]
183
+
184
+ for (const { desc, args, expected } of cases) {
185
+ it(desc, () => {
186
+ assert.deepEqual(this.server.get_listen_addrs(...args), expected)
101
187
  })
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
- })
188
+ }
114
189
  })
115
190
 
191
+ // ── load_smtp_ini ─────────────────────────────────────────────────────────
116
192
  describe('load_smtp_ini', () => {
117
- beforeEach(_set_up)
193
+ beforeEach(() => {
194
+ this.config = require('haraka-config')
195
+ this.server = require('../server')
196
+ })
118
197
 
119
198
  it('saves settings to Server.cfg', () => {
120
199
  this.server.load_smtp_ini()
121
- // console.log(this.server.cfg);
122
200
  const c = this.server.cfg.main
123
201
  assert.notEqual(c.daemonize, undefined)
124
202
  assert.notEqual(c.daemon_log_file, undefined)
@@ -126,387 +204,154 @@ describe('server', () => {
126
204
  })
127
205
  })
128
206
 
207
+ // ── get_smtp_server ───────────────────────────────────────────────────────
129
208
  describe('get_smtp_server', () => {
130
- beforeEach((done) => {
131
- this.config = require('haraka-config')
132
- this.config = this.config.module_config(path.resolve('test'))
133
-
209
+ beforeEach(async () => {
210
+ this.config = require('haraka-config').module_config(path.resolve('test'))
134
211
  this.server = require('../server')
135
212
  this.server.config = this.config
136
213
  this.server.plugins.config = this.config
137
-
138
- this.server.load_default_tls_config(() => {
139
- setTimeout(() => {
140
- done()
141
- }, 200)
214
+ await new Promise((resolve) => {
215
+ this.server.load_default_tls_config(() => setTimeout(resolve, 200))
142
216
  })
143
217
  })
144
218
 
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
- })
219
+ it('gets a net server object', async () => {
220
+ const server = await this.server.get_smtp_server(endpoint('0.0.0.0:2501'), 10)
221
+ if (!server) {
222
+ if (process.env.CI) return
223
+ assert.fail('unable to bind to 0.0.0.0:2501')
224
+ }
225
+ assert.ok(server)
226
+ assert.equal(server.has_tls, false)
227
+ const count = await new Promise((res) => server.getConnections((err, n) => res(n)))
228
+ assert.equal(count, 0)
158
229
  })
159
230
 
160
- it('gets a TLS net server object', (done) => {
231
+ it('gets a TLS net server object', async () => {
161
232
  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
- })
233
+ const server = await this.server.get_smtp_server(endpoint('0.0.0.0:2502'), 10)
234
+ if (!server) {
235
+ if (process.env.CI) return
236
+ assert.fail('unable to bind to 0.0.0.0:2502')
237
+ }
238
+ assert.ok(server)
239
+ assert.equal(server.has_tls, true)
240
+ const count = await new Promise((res) => server.getConnections((err, n) => res(n)))
241
+ assert.equal(count, 0)
174
242
  })
175
243
  })
176
244
 
245
+ // ── get_http_docroot ──────────────────────────────────────────────────────
177
246
  describe('get_http_docroot', () => {
178
- beforeEach(_set_up)
247
+ beforeEach(() => {
248
+ this.config = require('haraka-config')
249
+ this.server = require('../server')
250
+ })
179
251
 
180
252
  it('gets a fs path', () => {
181
253
  assert.ok(this.server.get_http_docroot())
182
254
  })
183
255
  })
184
256
 
185
- describe('smtp_client', () => {
186
- beforeEach((done) => {
187
- _setupServer('localhost:2500', done)
188
- })
189
-
190
- afterEach(_tearDownServer)
191
-
192
- it('accepts SMTP message', () => {
193
- const server = { notes: {} }
194
- const cfg = {
195
- connect_timeout: 2,
196
- }
257
+ // ── SMTP sessions ─────────────────────────────────────────────────────────
258
+ describe('SMTP sessions', () => {
259
+ beforeEach(async () => setupServer('127.0.0.1:2503'))
260
+ afterEach(async () => tearDownServer())
197
261
 
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
- )
262
+ it('accepts plain SMTP message', async () => {
263
+ await sendMessage({ port: 2503 })
239
264
  })
240
- })
241
265
 
242
- describe('nodemailer', () => {
243
- beforeEach((done) => {
244
- _setupServer('127.0.0.1:2503', done)
266
+ it('accepts CRAM-MD5 authenticated SMTP', async () => {
267
+ await sendMessage({ port: 2503, user: 'matt', pass: 'goodPass' })
245
268
  })
246
269
 
247
- afterEach(_tearDownServer)
248
-
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
- )
270
+ it('rejects invalid CRAM-MD5 credentials', async () => {
271
+ await assert.rejects(() => sendMessage({ port: 2503, user: 'matt', pass: 'badPass' }), /5\d\d/)
281
272
  })
282
273
 
283
- it('accepts authenticated SMTP', (done) => {
284
- const nodemailer = require('nodemailer')
285
- const transporter = nodemailer.createTransport({
286
- host: '127.0.0.1',
274
+ it('accepts message with custom headers', async () => {
275
+ await sendMessage({
287
276
  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,
296
- },
277
+ from: '<sender@haraka.local>',
278
+ to: '<discard@haraka.local>',
279
+ body: 'X-Custom: test-value\r\n\r\nBody text',
297
280
  })
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>',
310
- },
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()
319
- },
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,
335
- },
336
- })
337
-
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
- })
361
-
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
- })
372
-
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
- )
402
281
  })
403
282
  })
404
283
 
284
+ // ── requireAuthorized: SMTPS (implicit TLS) ───────────────────────────────
405
285
  describe('requireAuthorized_SMTPS', () => {
406
- beforeEach((done) => {
407
- _setupServer('127.0.0.1:2465', done)
408
- })
409
-
410
- afterEach(_tearDownServer)
411
-
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
286
+ beforeEach(async () => setupServer('127.0.0.1:2465'))
287
+ afterEach(async () => tearDownServer())
288
+
289
+ it('rejects non-validated SMTPS connection', async () => {
290
+ // Port 2465 is configured as SMTPS with requireAuthorized.
291
+ // In TLSv1.3 the handshake completes (secureConnect fires), then the server
292
+ // sends a post-handshake "certificate required" alert as a socket error.
293
+ const err = await new Promise((resolve) => {
294
+ const socket = tls.connect({
295
+ host: '127.0.0.1',
296
+ port: 2465,
420
297
  rejectUnauthorized: false,
421
- },
298
+ })
299
+ socket.on('error', resolve)
300
+ // secureConnect may fire before the post-handshake alert; keep waiting.
301
+ socket.on('secureConnect', () => {})
302
+ setTimeout(() => {
303
+ socket.destroy()
304
+ resolve(new Error('timeout'))
305
+ }, 3000)
422
306
  })
423
-
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)
307
+ assert.ok(
308
+ /socket hang up|disconnected before secure TLS|alert certificate required/.test(err.message),
309
+ `unexpected error: ${err.message}`,
310
+ )
459
311
  })
460
312
  })
461
313
 
314
+ // ── requireAuthorized: STARTTLS ───────────────────────────────────────────
462
315
  describe('requireAuthorized_STARTTLS', () => {
463
- beforeEach((done) => {
464
- _setupServer('127.0.0.1:2587', done)
465
- })
466
-
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
475
- rejectUnauthorized: false,
476
- },
477
- })
478
-
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
- }
316
+ beforeEach(async () => setupServer('127.0.0.1:2587'))
317
+ afterEach(async () => tearDownServer())
318
+
319
+ it('rejects non-validated STARTTLS connection', async () => {
320
+ // Port 2587 is plain SMTP; requireAuthorized enforces mutual TLS on STARTTLS upgrade.
321
+ // In TLSv1.3 secureConnect fires first, then the server sends a post-handshake
322
+ // "certificate required" alert. Use raw sockets to observe the TLS error.
323
+ // (smtp_client's upgrade path silently swallows the post-upgrade error.)
324
+ const err = await new Promise((resolve) => {
325
+ const sock = net.connect({ host: '127.0.0.1', port: 2587 })
326
+ let state = 'greeting'
327
+ let buf = ''
328
+ sock.on('data', (d) => {
329
+ buf += d.toString()
330
+ for (const line of buf.split('\r\n').slice(0, -1)) {
331
+ buf = buf.slice(line.length + 2)
332
+ if (line[3] === '-') continue // multi-line continuation
333
+ if (state === 'greeting') {
334
+ sock.write('EHLO test\r\n')
335
+ state = 'ehlo'
336
+ } else if (state === 'ehlo') {
337
+ sock.write('STARTTLS\r\n')
338
+ state = 'starttls'
339
+ } else if (state === 'starttls') {
340
+ state = 'tls'
341
+ const cleartext = tls.connect({ socket: sock, rejectUnauthorized: false })
342
+ cleartext.on('secureConnect', () => {})
343
+ cleartext.on('error', resolve)
344
+ cleartext.on('close', () => resolve(new Error('closed without error')))
505
345
  }
506
- done()
507
- },
508
- )
509
- }, 500)
346
+ }
347
+ })
348
+ sock.on('error', resolve)
349
+ setTimeout(() => resolve(new Error('timeout')), 3000)
350
+ })
351
+ assert.ok(
352
+ /alert certificate required|socket hang up|disconnected/.test(err.message),
353
+ `unexpected error: ${err.message}`,
354
+ )
510
355
  })
511
356
  })
512
357
  })