Haraka 3.1.5 → 3.1.7

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 (79) hide show
  1. package/.prettierignore +1 -1
  2. package/{Changes.md → CHANGELOG.md} +54 -3
  3. package/CONTRIBUTORS.md +26 -26
  4. package/Plugins.md +99 -99
  5. package/README.md +68 -93
  6. package/SECURITY.md +178 -0
  7. package/bin/haraka +7 -14
  8. package/config/plugins +0 -3
  9. package/config/smtp_forward.ini +10 -0
  10. package/config/smtp_proxy.ini +10 -0
  11. package/connection.js +25 -8
  12. package/docs/Connection.md +126 -39
  13. package/docs/CoreConfig.md +92 -74
  14. package/docs/HAProxy.md +41 -25
  15. package/docs/Logging.md +68 -38
  16. package/docs/Outbound.md +124 -179
  17. package/docs/Plugins.md +38 -59
  18. package/docs/Transaction.md +78 -83
  19. package/docs/Tutorial.md +122 -209
  20. package/docs/plugins/aliases.md +1 -141
  21. package/docs/plugins/auth/auth_ldap.md +2 -39
  22. package/docs/plugins/max_unrecognized_commands.md +4 -18
  23. package/docs/plugins/process_title.md +3 -3
  24. package/docs/plugins/queue/smtp_forward.md +19 -3
  25. package/docs/plugins/queue/smtp_proxy.md +10 -2
  26. package/docs/plugins/reseed_rng.md +11 -13
  27. package/docs/plugins/tls.md +7 -7
  28. package/docs/plugins/toobusy.md +10 -4
  29. package/docs/tutorials/SettingUpOutbound.md +40 -48
  30. package/endpoint.js +32 -2
  31. package/haraka.js +1 -1
  32. package/outbound/hmail.js +42 -41
  33. package/outbound/index.js +7 -4
  34. package/outbound/tls.js +2 -43
  35. package/package.json +51 -61
  36. package/plugins/auth/auth_base.js +9 -3
  37. package/plugins/auth/auth_proxy.js +14 -11
  38. package/plugins/block_me.js +4 -2
  39. package/plugins/prevent_credential_leaks.js +3 -1
  40. package/plugins/process_title.js +6 -6
  41. package/plugins/queue/qmail-queue.js +15 -19
  42. package/plugins/queue/smtp_forward.js +12 -4
  43. package/plugins/queue/smtp_proxy.js +14 -3
  44. package/plugins/tls.js +13 -5
  45. package/plugins/xclient.js +3 -1
  46. package/server.js +22 -10
  47. package/smtp_client.js +20 -11
  48. package/test/config/block_me.recipient +1 -0
  49. package/test/config/block_me.senders +1 -0
  50. package/test/connection.js +258 -0
  51. package/test/endpoint.js +27 -0
  52. package/test/outbound/bounce_net_errors.js +3 -2
  53. package/test/outbound/hmail.js +19 -0
  54. package/test/outbound/index.js +189 -0
  55. package/test/outbound/queue.js +92 -0
  56. package/test/plugins/auth/auth_bridge.js +80 -0
  57. package/test/plugins/auth/flat_file.js +128 -0
  58. package/test/plugins/block_me.js +157 -0
  59. package/test/plugins/data.signatures.js +114 -0
  60. package/test/plugins/delay_deny.js +263 -0
  61. package/test/plugins/prevent_credential_leaks.js +178 -0
  62. package/test/plugins/process_title.js +135 -0
  63. package/test/plugins/queue/deliver.js +99 -0
  64. package/test/plugins/queue/discard.js +79 -0
  65. package/test/plugins/queue/lmtp.js +138 -0
  66. package/test/plugins/queue/qmail-queue.js +99 -0
  67. package/test/plugins/queue/quarantine.js +81 -0
  68. package/test/plugins/queue/smtp_bridge.js +154 -0
  69. package/test/plugins/queue/smtp_forward.js +42 -6
  70. package/test/plugins/queue/smtp_proxy.js +139 -0
  71. package/test/plugins/reseed_rng.js +34 -0
  72. package/test/plugins/tarpit.js +91 -0
  73. package/test/plugins/tls.js +25 -0
  74. package/test/plugins/toobusy.js +21 -0
  75. package/test/plugins/xclient.js +14 -0
  76. package/test/server.js +231 -0
  77. package/test/smtp_client.js +45 -12
  78. package/test/tls_socket.js +220 -0
  79. package/tls_socket.js +52 -2
package/smtp_client.js CHANGED
@@ -192,7 +192,7 @@ class SMTPClient extends events.EventEmitter {
192
192
  client.socket.on('end', closed('ended'))
193
193
  }
194
194
 
195
- load_tls_config(opts = {}) {
195
+ load_tls_options(opts = {}) {
196
196
  this.tls_options = { servername: this.host, ...opts }
197
197
  }
198
198
 
@@ -312,8 +312,8 @@ exports.onCapabilitiesOutbound = (smtp_client, secured, connection, config, on_s
312
312
  // Check if there are any banned TLS hosts
313
313
  if (smtp_client.tls_options.no_tls_hosts) {
314
314
  // If there are check if these hosts are in the blacklist
315
- hostBanned = net_utils.ip_in_list(smtp_client.tls_config.no_tls_hosts, config.host)
316
- serverBanned = net_utils.ip_in_list(smtp_client.tls_config.no_tls_hosts, smtp_client.remote_ip)
315
+ hostBanned = net_utils.ip_in_list(smtp_client.tls_options.no_tls_hosts, config.host)
316
+ serverBanned = net_utils.ip_in_list(smtp_client.tls_options.no_tls_hosts, smtp_client.remote_ip)
317
317
  }
318
318
 
319
319
  if (!hostBanned && !serverBanned && config.enable_tls) {
@@ -358,7 +358,7 @@ exports.get_client_plugin = (plugin, connection, c, callback) => {
358
358
 
359
359
  let secured = false
360
360
 
361
- smtp_client.load_tls_config(plugin.tls_options)
361
+ smtp_client.load_tls_options(plugin.tls_options)
362
362
 
363
363
  smtp_client.call_next = function (retval, msg) {
364
364
  if (this.next) {
@@ -369,7 +369,10 @@ exports.get_client_plugin = (plugin, connection, c, callback) => {
369
369
  }
370
370
 
371
371
  smtp_client.on('client_protocol', (line) => {
372
- connection.logprotocol(plugin, `C: ${line}`)
372
+ // Don't leak SASL credentials (e.g. AUTH PLAIN <base64>) into
373
+ // protocol logs.
374
+ const safe = String(line).replace(/^(AUTH\s+\S+\s+).+$/i, '$1[redacted]')
375
+ connection.logprotocol(plugin, `C: ${safe}`)
373
376
  })
374
377
 
375
378
  smtp_client.on('server_protocol', (line) => {
@@ -401,21 +404,27 @@ exports.get_client_plugin = (plugin, connection, c, callback) => {
401
404
  if (!c.auth || smtp_client.authenticated) {
402
405
  if (smtp_client.is_dead_sender(plugin, connection)) return
403
406
 
404
- smtp_client.send_command('MAIL', `FROM:${connection.transaction.mail_from.format(!smtp_client.smtp_utf8)}`)
407
+ smtp_client.send_command('MAIL', `FROM:${connection.transaction.mail_from.format(!smtp_client.smtputf8)}`)
405
408
  return
406
409
  }
407
410
 
408
411
  if (c.auth.type === null || typeof c.auth.type === 'undefined') return // Ignore blank
409
412
  const auth_type = c.auth.type.toLowerCase()
413
+ // This listener runs from the socket line handler; an uncaught
414
+ // throw here crashes the forwarding worker. Route failures
415
+ // through the existing smtp_client 'error' flow (logwarn +
416
+ // call_next) so a misconfigured/hostile upstream degrades to a
417
+ // normal SMTP error path instead.
410
418
  if (!smtp_client.auth_capabilities.includes(auth_type)) {
411
- throw new Error(
419
+ return smtp_client.emit(
420
+ 'error',
412
421
  `Auth type "${auth_type}" not supported by server (supports: ${smtp_client.auth_capabilities.join(',')})`,
413
422
  )
414
423
  }
415
424
  switch (auth_type) {
416
425
  case 'plain':
417
426
  if (!c.auth.user || !c.auth.pass) {
418
- throw new Error('Must include auth.user and auth.pass for PLAIN auth.')
427
+ return smtp_client.emit('error', 'Must include auth.user and auth.pass for PLAIN auth.')
419
428
  }
420
429
  logger.debug(`[smtp_client] uuid=${smtp_client.uuid} authenticating as "${c.auth.user}"`)
421
430
  smtp_client.send_command(
@@ -424,9 +433,9 @@ exports.get_client_plugin = (plugin, connection, c, callback) => {
424
433
  )
425
434
  break
426
435
  case 'cram-md5':
427
- throw new Error('Not implemented')
436
+ return smtp_client.emit('error', `AUTH ${auth_type} not implemented`)
428
437
  default:
429
- throw new Error(`Unknown AUTH type: ${auth_type}`)
438
+ return smtp_client.emit('error', `Unknown AUTH type: ${auth_type}`)
430
439
  }
431
440
  })
432
441
 
@@ -437,7 +446,7 @@ exports.get_client_plugin = (plugin, connection, c, callback) => {
437
446
  if (smtp_client.is_dead_sender(plugin, connection)) return
438
447
 
439
448
  smtp_client.authenticated = true
440
- smtp_client.send_command('MAIL', `FROM:${connection.transaction.mail_from.format(!smtp_client.smtp_utf8)}`)
449
+ smtp_client.send_command('MAIL', `FROM:${connection.transaction.mail_from.format(!smtp_client.smtputf8)}`)
441
450
  })
442
451
 
443
452
  // these errors only get thrown when the connection is still active
@@ -0,0 +1 @@
1
+ blocklist@example.com
@@ -0,0 +1 @@
1
+ sender@example.com
@@ -5,6 +5,7 @@ const assert = require('node:assert/strict')
5
5
 
6
6
  const constants = require('haraka-constants')
7
7
  const DSN = require('haraka-dsn')
8
+ const { Address } = require('address-rfc2821')
8
9
 
9
10
  const connection = require('../connection')
10
11
  const Server = require('../server')
@@ -442,4 +443,261 @@ describe('connection', () => {
442
443
  })
443
444
  })
444
445
  })
446
+
447
+ describe('queue responses', () => {
448
+ beforeEach(setUp)
449
+
450
+ const prepQueueTestConnection = () => {
451
+ const calls = { respond: [], reset: 0, disconnect: 0, queue_ok: 0, results: [] }
452
+ const plugins = require('../plugins')
453
+ const originalRunHooks = plugins.run_hooks
454
+
455
+ this.connection.transaction = {
456
+ uuid: 'txn-123',
457
+ msg_status: null,
458
+ results: {
459
+ add(_meta, payload) {
460
+ calls.results.push(payload)
461
+ },
462
+ },
463
+ }
464
+
465
+ this.connection.respond = (code, msg, cb) => {
466
+ calls.respond.push({ code, msg })
467
+ if (cb) cb()
468
+ }
469
+ this.connection.reset_transaction = (cb) => {
470
+ calls.reset++
471
+ this.connection.transaction = this.connection.transaction || {}
472
+ if (cb) cb()
473
+ }
474
+ this.connection.disconnect = () => {
475
+ calls.disconnect++
476
+ }
477
+ plugins.run_hooks = (hook) => {
478
+ if (hook === 'queue_ok') calls.queue_ok++
479
+ }
480
+
481
+ return {
482
+ calls,
483
+ restore() {
484
+ plugins.run_hooks = originalRunHooks
485
+ },
486
+ }
487
+ }
488
+
489
+ it('queue_respond handles denydisconnect and marks message rejected', () => {
490
+ const harness = prepQueueTestConnection()
491
+ try {
492
+ this.connection.queue_respond(constants.denydisconnect)
493
+ assert.equal(harness.calls.respond[0].code, 550)
494
+ assert.equal(this.connection.msg_count.reject, 1)
495
+ assert.equal(this.connection.transaction.msg_status, 'rejected')
496
+ assert.equal(harness.calls.disconnect, 1)
497
+ assert.deepEqual(harness.calls.results[0], { fail: 'Message denied' })
498
+ } finally {
499
+ harness.restore()
500
+ }
501
+ })
502
+
503
+ it('queue_respond handles denysoft and resets transaction', () => {
504
+ const harness = prepQueueTestConnection()
505
+ try {
506
+ this.connection.queue_respond(constants.denysoft)
507
+ assert.equal(harness.calls.respond[0].code, 450)
508
+ assert.equal(this.connection.msg_count.tempfail, 1)
509
+ assert.equal(this.connection.transaction.msg_status, 'deferred')
510
+ assert.equal(harness.calls.reset, 1)
511
+ assert.deepEqual(harness.calls.results[0], { fail: 'Message denied temporarily' })
512
+ } finally {
513
+ harness.restore()
514
+ }
515
+ })
516
+
517
+ it('queue_respond handles denysoftdisconnect and disconnects', () => {
518
+ const harness = prepQueueTestConnection()
519
+ try {
520
+ this.connection.queue_respond(constants.denysoftdisconnect)
521
+ assert.equal(harness.calls.respond[0].code, 450)
522
+ assert.equal(this.connection.msg_count.tempfail, 1)
523
+ assert.equal(this.connection.transaction.msg_status, 'deferred')
524
+ assert.equal(harness.calls.disconnect, 1)
525
+ } finally {
526
+ harness.restore()
527
+ }
528
+ })
529
+
530
+ it('queue_respond default path returns 451 and resets transaction', () => {
531
+ const harness = prepQueueTestConnection()
532
+ try {
533
+ this.connection.queue_respond(constants.cont)
534
+ assert.equal(harness.calls.respond[0].code, 451)
535
+ assert.equal(this.connection.msg_count.tempfail, 1)
536
+ assert.equal(this.connection.transaction.msg_status, 'deferred')
537
+ assert.equal(harness.calls.reset, 1)
538
+ } finally {
539
+ harness.restore()
540
+ }
541
+ })
542
+
543
+ it('queue_ok_respond accepts and resets transaction', () => {
544
+ const harness = prepQueueTestConnection()
545
+ try {
546
+ this.connection.queue_ok_respond(constants.ok, null, 'queued')
547
+ assert.equal(harness.calls.respond[0].code, 250)
548
+ assert.equal(this.connection.msg_count.accept, 1)
549
+ assert.equal(this.connection.transaction.msg_status, 'accepted')
550
+ assert.equal(harness.calls.reset, 1)
551
+ } finally {
552
+ harness.restore()
553
+ }
554
+ })
555
+ })
556
+
557
+ describe('smtp command/response branches', () => {
558
+ beforeEach(setUp)
559
+
560
+ it('rcpt_respond deny removes recipient and records reject', () => {
561
+ const plugins = require('../plugins')
562
+ const originalRunHooks = plugins.run_hooks
563
+ const rcpt = new Address('<to@example.com>')
564
+ const sender = new Address('<from@example.com>')
565
+ const actions = []
566
+
567
+ this.connection.transaction = {
568
+ rcpt_to: [rcpt],
569
+ mail_from: sender,
570
+ results: { push() {} },
571
+ }
572
+ this.connection.rcpt_incr = (_rcpt, action) => actions.push(action)
573
+ this.connection.respond = (_code, _msg, cb) => cb && cb()
574
+ plugins.run_hooks = () => {}
575
+
576
+ try {
577
+ this.connection.rcpt_respond(constants.deny, 'no')
578
+ assert.equal(actions[0], 'reject')
579
+ assert.equal(this.connection.transaction.rcpt_to.length, 0)
580
+ } finally {
581
+ plugins.run_hooks = originalRunHooks
582
+ }
583
+ })
584
+
585
+ it('rcpt_respond ok runs rcpt_ok hook', () => {
586
+ const plugins = require('../plugins')
587
+ const originalRunHooks = plugins.run_hooks
588
+ const rcpt = new Address('<to@example.com>')
589
+ const sender = new Address('<from@example.com>')
590
+ const hooks = []
591
+
592
+ this.connection.transaction = {
593
+ rcpt_to: [rcpt],
594
+ mail_from: sender,
595
+ results: { push() {} },
596
+ }
597
+ this.connection.respond = (_code, _msg, cb) => cb && cb()
598
+ plugins.run_hooks = (hook) => hooks.push(hook)
599
+
600
+ try {
601
+ this.connection.rcpt_respond(constants.ok, 'ok')
602
+ assert.equal(hooks.includes('rcpt_ok'), true)
603
+ assert.equal(this.connection.last_rcpt_msg, 'ok')
604
+ } finally {
605
+ plugins.run_hooks = originalRunHooks
606
+ }
607
+ })
608
+
609
+ it('cmd_proxy rejects when not allowed', () => {
610
+ let code
611
+ this.connection.proxy.allowed = false
612
+ this.connection.respond = (c) => {
613
+ code = c
614
+ }
615
+ this.connection.disconnect = () => {}
616
+ this.connection.cmd_proxy('TCP4 1.2.3.4 5.6.7.8 100 25')
617
+ assert.equal(code, 421)
618
+ })
619
+
620
+ it('cmd_proxy accepts valid TCP4 proxy line and runs connect_init', () => {
621
+ const plugins = require('../plugins')
622
+ const originalRunHooks = plugins.run_hooks
623
+ const hooks = []
624
+ this.connection.proxy.allowed = true
625
+ this.connection.remote.ip = '10.0.0.1'
626
+ this.connection.reset_transaction = (cb) => cb && cb()
627
+ this.connection.respond = () => {}
628
+ plugins.run_hooks = (hook) => hooks.push(hook)
629
+
630
+ try {
631
+ this.connection.cmd_proxy('TCP4 1.2.3.4 5.6.7.8 100 25')
632
+ assert.equal(this.connection.proxy.type, 'haproxy')
633
+ assert.equal(this.connection.remote.ip, '1.2.3.4')
634
+ assert.equal(this.connection.local.ip, '5.6.7.8')
635
+ assert.equal(hooks.includes('connect_init'), true)
636
+ } finally {
637
+ plugins.run_hooks = originalRunHooks
638
+ }
639
+ })
640
+
641
+ it('cmd_data validates argument/transaction/recipient preconditions', () => {
642
+ const responses = []
643
+ this.connection.respond = (code, msg) => {
644
+ responses.push([code, msg])
645
+ }
646
+
647
+ this.connection.cmd_data('unexpected')
648
+ this.connection.cmd_data()
649
+ this.connection.transaction = { rcpt_to: [] }
650
+ this.connection.cmd_data()
651
+
652
+ assert.equal(responses[0][0], 501)
653
+ assert.equal(responses[1][0], 503)
654
+ assert.equal(responses[2][0], 503)
655
+ })
656
+
657
+ it('data_respond denysoftdisconnect disconnects and default enters DATA', () => {
658
+ const responses = []
659
+ let disconnected = 0
660
+ this.connection.transaction = { data_bytes: 5 }
661
+ this.connection.respond = (code, _msg, cb) => {
662
+ responses.push(code)
663
+ if (cb) cb()
664
+ }
665
+ this.connection.disconnect = () => {
666
+ disconnected++
667
+ }
668
+
669
+ this.connection.data_respond(constants.denysoftdisconnect, 'tmpfail')
670
+ this.connection.data_respond(constants.ok, 'ok')
671
+
672
+ assert.equal(responses[0], 451)
673
+ assert.equal(disconnected, 1)
674
+ assert.equal(responses[1], 354)
675
+ assert.equal(this.connection.state, constants.connection.state.DATA)
676
+ assert.equal(this.connection.transaction.data_bytes, 0)
677
+ })
678
+ })
679
+
680
+ describe('header injection', () => {
681
+ beforeEach(setUp)
682
+
683
+ it('cmd_helo rejects control chars in the host', () => {
684
+ const r = this.connection.cmd_helo('evil\rINJECTED')
685
+ assert.match(String(r), /^501/)
686
+ assert.equal(this.connection.hello.host, null)
687
+ })
688
+
689
+ it('cmd_ehlo rejects control chars in the host', () => {
690
+ const r = this.connection.cmd_ehlo('evil\r\nINJECTED')
691
+ assert.match(String(r), /^501/)
692
+ assert.equal(this.connection.hello.host, null)
693
+ })
694
+
695
+ it('auth_results strips CR/LF so it cannot inject a header', () => {
696
+ const out = this.connection.auth_results('auth=fail smtp.auth=evil\r\nInjected-Header: pwned')
697
+ assert.ok(!out.includes('\r\nInjected-Header:'))
698
+ // the only CRLF present is the legitimate folding (;\r\n\t)
699
+ assert.equal(out.replace(/;\r\n\t/g, '').includes('\r'), false)
700
+ assert.equal(out.replace(/;\r\n\t/g, '').includes('\n'), false)
701
+ })
702
+ })
445
703
  })
package/test/endpoint.js CHANGED
@@ -20,6 +20,20 @@ describe('endpoint', () => {
20
20
  assert.deepEqual(endpoint(25), { host: '::0', port: 25 })
21
21
  })
22
22
 
23
+ it('Unbracketed IPv6 host uses default port', () => {
24
+ assert.deepEqual(endpoint('::0', 25), {
25
+ host: '::0',
26
+ port: 25,
27
+ })
28
+ })
29
+
30
+ it('Unbracketed IPv6 host:port parses correctly (PR #3552 compatibility)', () => {
31
+ assert.deepEqual(endpoint('::0:25'), {
32
+ host: '::0',
33
+ port: 25,
34
+ })
35
+ })
36
+
23
37
  it('Default port if only host', () => {
24
38
  assert.deepEqual(endpoint('10.0.0.3', 42), {
25
39
  host: '10.0.0.3',
@@ -27,6 +41,13 @@ describe('endpoint', () => {
27
41
  })
28
42
  })
29
43
 
44
+ it('Bracketed IPv6 host is normalized to lowercase', () => {
45
+ assert.deepEqual(endpoint('[ABCD::EF01]:2525'), {
46
+ host: 'abcd::ef01',
47
+ port: 2525,
48
+ })
49
+ })
50
+
30
51
  it('Unix socket', () => {
31
52
  assert.deepEqual(endpoint('/foo/bar.sock'), {
32
53
  path: '/foo/bar.sock',
@@ -39,6 +60,12 @@ describe('endpoint', () => {
39
60
  mode: '770',
40
61
  })
41
62
  })
63
+
64
+ it('Invalid unbracketed IPv6 host with non-numeric tail returns Error', () => {
65
+ const ep = endpoint('::0:port')
66
+ assert.equal(ep instanceof Error, true)
67
+ assert.match(ep.message, /Invalid socket address/)
68
+ })
42
69
  })
43
70
 
44
71
  describe('bind()', () => {
@@ -98,9 +98,10 @@ const testCases = [
98
98
  trigger: (h) => HMailItem.prototype.get_mx_error.apply(h, [{ code: 'SOME-OTHER-ERR' }, {}]),
99
99
  },
100
100
  {
101
- name: 'found_mx with empty exchange triggers bounce with dsn_status 5.1.2',
101
+ // RFC 7505 NULL MX DSN.addr_null_mx 5.1.10 (per haraka-dsn).
102
+ name: 'found_mx with NULL MX (RFC 7505) triggers bounce with dsn_status 5.1.10',
102
103
  method: 'bounce',
103
- status: '5.1.2',
104
+ status: '5.1.10',
104
105
  trigger: (h) => HMailItem.prototype.found_mx.apply(h, [[{ priority: 0, exchange: '' }]]),
105
106
  },
106
107
  {
@@ -167,6 +167,25 @@ describe('outbound/hmail.HMailItem — queue file loading', () => {
167
167
  assert.ok(h)
168
168
  })
169
169
 
170
+ it('releases queue slot when stat fails on exhausted-retry item (regression #3560)', async () => {
171
+ // When fs.stat fails and num_failures already equals temp_fail_intervals.length,
172
+ // temp_fail() calls convert_temp_failed_to_bounce() while this.todo is null.
173
+ // That must not crash, and must call next_cb() to release the queue slot.
174
+ // attempts=12 in the filename causes num_failures=12 at construction; after
175
+ // temp_fail() increments it to 13 (> temp_fail_intervals.length=12) the
176
+ // overflow path fires with this.todo still null.
177
+ const fname = '1508455115683_1508455115683_12_90253_9Q4o4V_1_haraka'
178
+ const h = new Hmail(fname, '/nonexistent/path/that/cannot/be/stat/ed', {})
179
+ await new Promise((resolve, reject) => {
180
+ const timer = setTimeout(() => reject(new Error('next_cb was never called')), 2000)
181
+ h.next_cb = () => {
182
+ clearTimeout(timer)
183
+ resolve()
184
+ }
185
+ })
186
+ assert.equal(h.todo, null, 'todo must remain null — file was never readable')
187
+ })
188
+
170
189
  it('lifecycle: reads and writes a queue file', async () => {
171
190
  const h = new Hmail('1507509981169_1507509981169_0_61403_e0Y0Ym_2_qfile', 'test/fixtures/todo_qfile.txt', {})
172
191
 
@@ -152,6 +152,195 @@ describe('outbound', () => {
152
152
  })
153
153
  assert.ok(true)
154
154
  })
155
+
156
+ it('waits for drain when stream backpressure is applied', async () => {
157
+ const todo = {
158
+ queue_time: Date.now(),
159
+ domain: 'example.com',
160
+ rcpt_to: [],
161
+ mail_from: {},
162
+ notes: {},
163
+ uuid: 'u1',
164
+ }
165
+ let drained = false
166
+
167
+ await new Promise((resolve) => {
168
+ const ws = {
169
+ write() {
170
+ return false
171
+ },
172
+ once(event, cb) {
173
+ assert.equal(event, 'drain')
174
+ setImmediate(() => {
175
+ drained = true
176
+ cb()
177
+ resolve()
178
+ })
179
+ },
180
+ }
181
+ outbound.build_todo(todo, ws, () => {})
182
+ })
183
+
184
+ assert.equal(drained, true)
185
+ })
186
+ })
187
+
188
+ describe('send_trans_email', () => {
189
+ const queueDir = path.resolve('test', 'test-queue')
190
+
191
+ beforeEach(() => {
192
+ process.env.HARAKA_TEST_DIR = path.resolve('test')
193
+ fs.mkdirSync(queueDir, { recursive: true })
194
+ })
195
+
196
+ afterEach(() => {
197
+ delete process.env.HARAKA_TEST_DIR
198
+ try {
199
+ for (const f of fs.readdirSync(queueDir)) {
200
+ fs.unlinkSync(path.join(queueDir, f))
201
+ }
202
+ } catch (ignore) {}
203
+ })
204
+
205
+ // Regression test for haraka/Haraka#3551:
206
+ // When dkim_verify (data_post) pipes the message_stream and DKIMVerifyStream
207
+ // fires its callback early via process.nextTick (no DKIM-Signature found),
208
+ // the chain runs synchronously into process_delivery → pipe() while the
209
+ // first pipe is still in flight. pre_send_trans_email_respond must yield
210
+ // (via setImmediate) before opening a new pipe.
211
+ it('yields to setImmediate before opening process_delivery pipes', async () => {
212
+ const stream = require('node:stream')
213
+ const Transaction = require('../../transaction')
214
+ const Address = require('address-rfc2821').Address
215
+ const outbound = require('../../outbound')
216
+ const plugins = require('../../plugins')
217
+
218
+ const txn = Transaction.createTransaction()
219
+ const origRunHooks = plugins.run_hooks
220
+ try {
221
+ txn.mail_from = new Address('<from@example.com>')
222
+ txn.rcpt_to = [new Address('<to@example.com>')]
223
+ txn.message_stream.add_line(Buffer.from('From: from@example.com\r\n'))
224
+ txn.message_stream.add_line(Buffer.from('To: to@example.com\r\n'))
225
+ txn.message_stream.add_line(Buffer.from('\r\n'))
226
+ txn.message_stream.add_line(Buffer.from('body\r\n'))
227
+ await new Promise((r) => txn.message_stream.add_line_end(r))
228
+
229
+ // Start a pipe on the message_stream and fire a synchronous callback
230
+ // before it drains — this models what dkim_verify does.
231
+ const verifierFiredCb = new Promise((resolve) => {
232
+ let scheduled = false
233
+ const verifier = new stream.Writable({
234
+ write(_chunk, _enc, cb) {
235
+ if (!scheduled) {
236
+ scheduled = true
237
+ process.nextTick(resolve)
238
+ }
239
+ cb()
240
+ },
241
+ })
242
+ txn.message_stream.pipe(verifier)
243
+ })
244
+ await verifierFiredCb
245
+
246
+ // Now invoke send_trans_email — its pre_send_trans_email_respond
247
+ // should yield (await setImmediate) before calling process_delivery,
248
+ // letting the verifier pipe drain so the new pipe can succeed.
249
+ await new Promise((resolve, reject) => {
250
+ // Stub the heavy bits: we only care that the chain doesn't throw
251
+ // "Cannot pipe while currently piping" before queuing happens.
252
+ plugins.run_hooks = (hook, obj) => {
253
+ if (hook === 'pre_send_trans_email') {
254
+ // Mimic empty-hook synchronous callback (no plugins)
255
+ obj.pre_send_trans_email_respond(constants.cont).catch(reject)
256
+ } else {
257
+ origRunHooks.call(plugins, hook, obj)
258
+ }
259
+ }
260
+
261
+ outbound.send_trans_email(txn, (retval) => {
262
+ if (retval === constants.ok) resolve()
263
+ else reject(new Error(`unexpected retval ${retval}`))
264
+ })
265
+ })
266
+ } finally {
267
+ plugins.run_hooks = origRunHooks
268
+ txn.message_stream.destroy()
269
+ }
270
+ })
271
+
272
+ it('adds missing Message-Id/Date and prepends Received before queueing', async () => {
273
+ process.env.HARAKA_TEST_DIR = path.resolve('test')
274
+ const Address = require('address-rfc2821').Address
275
+ const outbound = require('../../outbound')
276
+ const plugins = require('../../plugins')
277
+
278
+ const added = []
279
+ const leading = []
280
+ const queued = []
281
+ const transaction = {
282
+ uuid: 'txn-add-headers',
283
+ header: {
284
+ get_all(_name) {
285
+ return []
286
+ },
287
+ get() {
288
+ return null
289
+ },
290
+ },
291
+ rcpt_to: [new Address('<user@example.com>')],
292
+ notes: {},
293
+ add_header(name, value) {
294
+ added.push([name, value])
295
+ },
296
+ remove_header() {},
297
+ add_leading_header(name, value) {
298
+ leading.push([name, value])
299
+ },
300
+ results: {
301
+ add() {},
302
+ },
303
+ }
304
+
305
+ const originalRunHooks = plugins.run_hooks
306
+ const originalProcessDelivery = outbound.process_delivery
307
+ const originalPush = outbound.delivery_queue.push
308
+ outbound.delivery_queue.push = (hmail) => {
309
+ queued.push(hmail)
310
+ }
311
+ outbound.process_delivery = async (_okPaths, _todo, hmails) => {
312
+ hmails.push({ queued: true })
313
+ }
314
+ plugins.run_hooks = (hook, conn) => {
315
+ if (hook === 'pre_send_trans_email') {
316
+ conn.pre_send_trans_email_respond(constants.cont)
317
+ }
318
+ }
319
+
320
+ try {
321
+ const result = await new Promise((resolve) => {
322
+ outbound.send_trans_email(transaction, (retval, msg) => resolve({ retval, msg }))
323
+ })
324
+
325
+ assert.equal(result.retval, constants.ok)
326
+ assert.match(result.msg, /Message Queued/)
327
+ assert.equal(queued.length, 1)
328
+ assert.equal(
329
+ added.some(([name]) => name === 'Message-Id'),
330
+ true,
331
+ )
332
+ assert.equal(
333
+ added.some(([name]) => name === 'Date'),
334
+ true,
335
+ )
336
+ assert.equal(leading[0][0], 'Received')
337
+ } finally {
338
+ plugins.run_hooks = originalRunHooks
339
+ outbound.process_delivery = originalProcessDelivery
340
+ outbound.delivery_queue.push = originalPush
341
+ delete process.env.HARAKA_TEST_DIR
342
+ }
343
+ })
155
344
  })
156
345
 
157
346
  describe('timer_queue', () => {