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
@@ -10,7 +10,6 @@ for both inbound and outbound delivery.
10
10
  The path to the `qmail-queue` binary. Default: `/var/qmail/bin/qmail-queue`
11
11
 
12
12
  - qmail-queue.ini
13
-
14
13
  - enable_outbound=true
15
14
 
16
15
  Deliver outbound email to qmail. Set to false to use Haraka's
package/logger.js CHANGED
@@ -255,9 +255,9 @@ logger.log_if_level = (level, key, origin) =>
255
255
  if (Object.hasOwn(data, 'uuid')) logobj.uuid = data.uuid
256
256
  if (data.todo?.uuid) logobj.uuid = data.todo.uuid // outbound/hmail
257
257
  } else if (logger.format === logger.formats.LOGFMT && data.constructor === Object) {
258
- logobj = Object.assign(logobj, data)
258
+ logobj = { ...logobj, ...data }
259
259
  } else if (logger.format === logger.formats.JSON && data.constructor === Object) {
260
- logobj = Object.assign(logobj, data)
260
+ logobj = { ...logobj, ...data }
261
261
  } else if (Object.hasOwn(data, 'uuid')) {
262
262
  // outbound/client_pool
263
263
  logobj.uuid = data.uuid
package/outbound/hmail.js CHANGED
@@ -1,7 +1,8 @@
1
1
  'use strict'
2
2
 
3
3
  const events = require('node:events')
4
- const fs = require('node:fs')
4
+ const fs = require('node:fs/promises')
5
+ const { createReadStream } = require('node:fs')
5
6
  const dns = require('node:dns')
6
7
  const net = require('node:net')
7
8
  const path = require('node:path')
@@ -68,20 +69,15 @@ class HMailItem extends events.EventEmitter {
68
69
  }
69
70
 
70
71
  data_stream() {
71
- return fs.createReadStream(this.path, {
72
+ return createReadStream(this.path, {
72
73
  start: this.data_start,
73
74
  end: this.file_size,
74
75
  })
75
76
  }
76
77
 
77
- size_file() {
78
- fs.stat(this.path, (err, stats) => {
79
- if (err) {
80
- // we are fucked... guess I need somewhere for this to go
81
- this.logerror(`Error obtaining file size: ${err}`)
82
- this.temp_fail('Error obtaining file size')
83
- return
84
- }
78
+ async size_file() {
79
+ try {
80
+ const stats = await fs.stat(this.path)
85
81
  if (stats.size === 0) {
86
82
  this.logerror(`Error reading queue file ${this.filename}: zero bytes`)
87
83
  this.emit('error', `Error reading queue file ${this.filename}: zero bytes`)
@@ -90,74 +86,73 @@ class HMailItem extends events.EventEmitter {
90
86
 
91
87
  this.file_size = stats.size
92
88
  this.read_todo()
93
- })
89
+ } catch (err) {
90
+ // we are fucked... guess I need somewhere for this to go
91
+ this.logerror(`Error obtaining file size: ${err}`)
92
+ this.temp_fail('Error obtaining file size')
93
+ }
94
94
  }
95
95
 
96
- read_todo() {
97
- this._stream_bytes_from(this.path, { start: 0, end: 3 }, (err, bytes) => {
98
- if (err) {
99
- const errMsg = `Error reading queue file ${this.filename}: ${err}`
100
- this.logerror(errMsg)
101
- this.temp_fail(errMsg)
102
- return
103
- }
96
+ async read_todo() {
97
+ try {
98
+ const bytes = await this._stream_bytes_from(this.path, { start: 0, end: 3 })
104
99
 
105
100
  const todo_len = bytes.readUInt32BE(0)
106
101
  this.logdebug(`todo header length: ${todo_len}`)
107
102
  this.data_start = todo_len + 4
108
103
 
109
- this._stream_bytes_from(this.path, { start: 4, end: todo_len + 3 }, (err2, todo_bytes) => {
110
- if (todo_bytes.length !== todo_len) {
111
- const wrongLength = `Didn't find right amount of data in todo!: ${err2} ${this.path}`
112
- this.logcrit(wrongLength)
113
- fs.rename(this.path, path.join(queue_dir, `error.${this.filename}`), (err3) => {
114
- if (err3) {
115
- this.logerror(`Error creating (error.${this.filename}): ${err3}`)
116
- }
117
- })
118
- this.emit('error', wrongLength) // Note nothing picks this up yet
119
- return
120
- }
121
-
122
- // we read everything
123
- const todo_json = todo_bytes.toString().trim()
124
- const last_char = todo_json.charAt(todo_json.length - 1)
125
- if (last_char !== '}') {
126
- this.emit(
127
- 'error',
128
- `invalid todo header end char: ${last_char} at pos ${todo_len} of ${this.filename}`,
129
- )
130
- return
104
+ const todo_bytes = await this._stream_bytes_from(this.path, { start: 4, end: todo_len + 3 })
105
+ if (todo_bytes.length !== todo_len) {
106
+ const wrongLength = `Didn't find right amount of data in todo: ${this.path}`
107
+ this.logcrit(wrongLength)
108
+ try {
109
+ await fs.rename(this.path, path.join(queue_dir, `error.${this.filename}`))
110
+ } catch (renameErr) {
111
+ this.logerror(`Failed to move corrupt todo file ${this.path} to error queue: ${renameErr}`)
131
112
  }
132
- this.todo = JSON.parse(todo_json)
133
- this.todo.mail_from = new Address(this.todo.mail_from)
134
- this.todo.rcpt_to = this.todo.rcpt_to.map((a) => new Address(a))
135
- this.todo.notes = new Notes(this.todo.notes)
136
- this.emit('ready')
137
- })
138
- })
139
- }
113
+ this.emit('error', wrongLength) // Note nothing picks this up yet
114
+ return
115
+ }
140
116
 
141
- _stream_bytes_from(file_path, opts, done) {
142
- if (opts.encoding !== undefined) {
143
- // passing an encoding to fs.createReadStream will change the type of data returned
144
- // ex: instead of returning a buffer, it may return a String, which will cause
145
- // Buffer.concat to barf. There's a reason this function has 'bytes' in the name
146
- done(new Error('Thar be dragons here! Encode/decode on the result of this function'))
147
- return
117
+ // we read everything
118
+ const todo_json = todo_bytes.toString().trim()
119
+ const last_char = todo_json.charAt(todo_json.length - 1)
120
+ if (last_char !== '}') {
121
+ this.emit('error', `invalid todo header end char: ${last_char} at pos ${todo_len} of ${this.filename}`)
122
+ return
123
+ }
124
+ this.todo = JSON.parse(todo_json)
125
+ this.todo.mail_from = new Address(this.todo.mail_from)
126
+ this.todo.rcpt_to = this.todo.rcpt_to.map((a) => new Address(a))
127
+ this.todo.notes = new Notes(this.todo.notes)
128
+ this.emit('ready')
129
+ } catch (err) {
130
+ const errMsg = `Error reading queue file ${this.filename}: ${err}`
131
+ this.logerror(errMsg)
132
+ this.temp_fail(errMsg)
148
133
  }
134
+ }
149
135
 
150
- const stream = fs.createReadStream(file_path, opts)
151
-
152
- stream.on('error', done)
136
+ _stream_bytes_from(file_path, opts) {
137
+ return new Promise((resolve, reject) => {
138
+ if (opts.encoding !== undefined) {
139
+ // passing an encoding to fs.createReadStream will change the type of data returned
140
+ // ex: instead of returning a buffer, it may return a String, which will cause
141
+ // Buffer.concat to barf. There's a reason this function has 'bytes' in the name
142
+ reject(new Error('Thar be dragons here! Encode/decode on the result of this function'))
143
+ return
144
+ }
145
+ const stream = createReadStream(file_path, opts)
146
+ stream.on('error', reject)
153
147
 
154
- let raw_bytes = Buffer.alloc(0)
155
- stream.on('data', (data) => {
156
- raw_bytes = Buffer.concat([raw_bytes, data])
157
- })
148
+ let raw_bytes = Buffer.alloc(0)
149
+ stream.on('data', (data) => {
150
+ raw_bytes = Buffer.concat([raw_bytes, data])
151
+ })
158
152
 
159
- stream.on('end', () => {
160
- done(null, raw_bytes)
153
+ stream.on('end', () => {
154
+ resolve(raw_bytes)
155
+ })
161
156
  })
162
157
  }
163
158
 
@@ -365,7 +360,7 @@ class HMailItem extends events.EventEmitter {
365
360
  socket.emit('error', `socket timeout waiting on ${command}`)
366
361
  })
367
362
 
368
- socket.once('error', (err) => {
363
+ socket.on('error', (err) => {
369
364
  if (!processing_mail) return
370
365
 
371
366
  self.logerror(`Ongoing connection failed to ${host}:${port} : ${err}`)
@@ -1290,7 +1285,7 @@ class HMailItem extends events.EventEmitter {
1290
1285
 
1291
1286
  double_bounce(err) {
1292
1287
  this.lognotice(`Double bounce: ${err}`)
1293
- fs.unlink(this.path, () => {})
1288
+ fs.unlink(this.path).catch(() => {})
1294
1289
  this.next_cb()
1295
1290
  // TODO: fill this in... ?
1296
1291
  // One strategy is perhaps log to an mbox file. What do other servers do?
@@ -1320,7 +1315,7 @@ class HMailItem extends events.EventEmitter {
1320
1315
  this.refcount--
1321
1316
  if (this.refcount === 0) {
1322
1317
  // Remove the file.
1323
- fs.unlink(this.path, () => {})
1318
+ fs.unlink(this.path).catch(() => {})
1324
1319
  this.next_cb()
1325
1320
  }
1326
1321
  }
@@ -1349,7 +1344,7 @@ class HMailItem extends events.EventEmitter {
1349
1344
  plugins.run_hooks('deferred', this, { delay, err, ...(extra || {}) })
1350
1345
  }
1351
1346
 
1352
- deferred_respond(retval, msg, params) {
1347
+ async deferred_respond(retval, msg, params) {
1353
1348
  if (retval !== constants.cont && retval !== constants.denysoft) {
1354
1349
  this.loginfo(`plugin responded with: ${retval}. Not deferring. Deleting mail.`)
1355
1350
  return this.discard() // calls next_cb
@@ -1367,11 +1362,8 @@ class HMailItem extends events.EventEmitter {
1367
1362
  parts.attempts = this.num_failures
1368
1363
  const new_filename = _qfile.name(parts)
1369
1364
 
1370
- fs.rename(this.path, path.join(queue_dir, new_filename), (err) => {
1371
- if (err) {
1372
- return this.bounce(`Error re-queueing email: ${err}`)
1373
- }
1374
-
1365
+ try {
1366
+ await fs.rename(this.path, path.join(queue_dir, new_filename))
1375
1367
  this.path = path.join(queue_dir, new_filename)
1376
1368
  this.filename = new_filename
1377
1369
 
@@ -1380,7 +1372,9 @@ class HMailItem extends events.EventEmitter {
1380
1372
  temp_fail_queue.add(this.filename, delay, () => {
1381
1373
  delivery_queue.push(this)
1382
1374
  })
1383
- })
1375
+ } catch (err) {
1376
+ return this.bounce(`Error re-queueing email: ${err}`)
1377
+ }
1384
1378
  }
1385
1379
 
1386
1380
  // The following handler impacts outgoing mail. It removes the queue file.
@@ -1472,18 +1466,17 @@ class HMailItem extends events.EventEmitter {
1472
1466
  err_handler(err, 'hmail.data_stream reader')
1473
1467
  })
1474
1468
  rs.on('end', () => {
1475
- ws.on('close', () => {
1476
- const dest_path = path.join(queue_dir, fname)
1477
- fs.rename(tmp_path, dest_path, (err) => {
1478
- if (err) {
1479
- err_handler(err, 'tmp file rename')
1480
- return
1481
- }
1469
+ ws.on('close', async () => {
1470
+ try {
1471
+ const dest_path = path.join(queue_dir, fname)
1472
+ await fs.rename(tmp_path, dest_path)
1482
1473
  const split_mail = new HMailItem(fname, dest_path, hmail.notes)
1483
1474
  split_mail.once('ready', () => {
1484
1475
  cb(split_mail)
1485
1476
  })
1486
- })
1477
+ } catch (err) {
1478
+ err_handler(err, 'tmp file rename')
1479
+ }
1487
1480
  })
1488
1481
  ws.destroySoon()
1489
1482
  })
package/outbound/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const fs = require('node:fs')
3
+ const fs = require('node:fs/promises')
4
4
  const path = require('node:path')
5
5
 
6
6
  const { Address } = require('address-rfc2821')
@@ -42,30 +42,35 @@ const qlfns = [
42
42
  'flush_queue',
43
43
  'load_pid_queue',
44
44
  'ensure_queue_dir',
45
- 'load_queue',
45
+ 'init_queue',
46
46
  'stats',
47
47
  ]
48
48
  for (const n of qlfns) {
49
49
  exports[n] = queuelib[n]
50
50
  }
51
51
 
52
- process.on('message', (msg) => {
52
+ process.on('message', async (msg) => {
53
53
  if (!msg.event) return
54
54
 
55
- if (msg.event === 'outbound.load_pid_queue') {
56
- exports.load_pid_queue(msg.data)
57
- return
58
- }
59
- if (msg.event === 'outbound.flush_queue') {
60
- exports.flush_queue(msg.domain, process.pid)
61
- return
62
- }
63
- if (msg.event === 'outbound.shutdown') {
64
- logger.info(exports, 'Shutting down temp fail queue')
65
- temp_fail_queue.shutdown()
55
+ try {
56
+ if (msg.event === 'outbound.load_pid_queue') {
57
+ await exports.load_pid_queue(msg.data)
58
+ return
59
+ }
60
+ if (msg.event === 'outbound.flush_queue') {
61
+ await exports.flush_queue(msg.domain, process.pid)
62
+ return
63
+ }
64
+ if (msg.event === 'outbound.shutdown') {
65
+ logger.info(exports, 'Shutting down temp fail queue')
66
+ temp_fail_queue.shutdown()
67
+ return
68
+ }
69
+ // ignores the message
70
+ } catch (err) {
71
+ logger.error(exports, err)
66
72
  return
67
73
  }
68
- // ignores the message
69
74
  })
70
75
 
71
76
  exports.send_email = function (from, to, contents, next, options = {}) {
@@ -120,11 +125,11 @@ exports.send_email = function (from, to, contents, next, options = {}) {
120
125
  while ((match = utils.line_regexp.exec(contents))) {
121
126
  let line = match[1]
122
127
  line = line.replace(/\r?\n?$/, '\r\n') // make sure it ends in \r\n
123
- if (dot_stuffed === false && line.length >= 3 && line.substr(0, 1) === '.') {
128
+ if (dot_stuffed === false && line.length >= 3 && line.substring(0, 1) === '.') {
124
129
  line = `.${line}`
125
130
  }
126
131
  transaction.add_data(Buffer.from(line))
127
- contents = contents.substr(match[1].length)
132
+ contents = contents.substring(match[1].length)
128
133
  if (contents.length === 0) {
129
134
  break
130
135
  }
@@ -252,7 +257,7 @@ exports.send_trans_email = function (transaction, next) {
252
257
  }
253
258
  } catch (err) {
254
259
  for (let i = 0, l = ok_paths.length; i < l; i++) {
255
- fs.unlink(ok_paths[i], () => {})
260
+ await fs.unlink(ok_paths[i]).catch(() => {})
256
261
  }
257
262
  transaction.results.add({ name: 'outbound' }, { err })
258
263
  if (next) next(constants.denysoft, err)
@@ -279,32 +284,29 @@ exports.process_delivery = function (ok_paths, todo, hmails) {
279
284
  flags: constants.WRITE_EXCL,
280
285
  })
281
286
 
282
- ws.on('close', () => {
287
+ ws.on('close', async () => {
283
288
  const dest_path = path.join(queue_dir, fname)
284
- fs.rename(tmp_path, dest_path, (err) => {
285
- if (err) {
286
- logger.error(exports, `Unable to rename tmp file!: ${err}`)
287
- fs.unlink(tmp_path, () => {})
288
- reject('Queue error')
289
- } else {
290
- hmails.push(new HMailItem(fname, dest_path, todo.notes))
291
- ok_paths.push(dest_path)
292
- resolve()
293
- }
294
- })
289
+ try {
290
+ await fs.rename(tmp_path, dest_path)
291
+ hmails.push(new HMailItem(fname, dest_path, todo.notes))
292
+ ok_paths.push(dest_path)
293
+ resolve()
294
+ } catch (err) {
295
+ logger.error(exports, `Unable to rename tmp file: ${err}`)
296
+ await fs.unlink(tmp_path).catch(() => {})
297
+ reject('Queue error')
298
+ }
295
299
  })
296
300
 
297
- ws.on('error', (err) => {
301
+ ws.on('error', async (err) => {
298
302
  logger.error(exports, `Unable to write queue file (${fname}): ${err}`)
299
303
  ws.destroy()
300
- fs.unlink(tmp_path, () => {})
304
+ await fs.unlink(tmp_path).catch(() => {})
301
305
  reject('Queueing failed')
302
306
  })
303
307
 
304
308
  this.build_todo(todo, ws, () => {
305
- // SUNSET: dot_stuffing was renamed to dot_stuffed, remove it after 2026-01
306
309
  todo.message_stream.pipe(ws, {
307
- dot_stuffing: true,
308
310
  dot_stuffed: false,
309
311
  })
310
312
  })