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.
- package/.prettierignore +1 -1
- package/{Changes.md → CHANGELOG.md} +54 -3
- package/CONTRIBUTORS.md +26 -26
- package/Plugins.md +99 -99
- package/README.md +68 -93
- package/SECURITY.md +178 -0
- package/bin/haraka +7 -14
- package/config/plugins +0 -3
- package/config/smtp_forward.ini +10 -0
- package/config/smtp_proxy.ini +10 -0
- package/connection.js +25 -8
- package/docs/Connection.md +126 -39
- package/docs/CoreConfig.md +92 -74
- package/docs/HAProxy.md +41 -25
- package/docs/Logging.md +68 -38
- package/docs/Outbound.md +124 -179
- package/docs/Plugins.md +38 -59
- package/docs/Transaction.md +78 -83
- package/docs/Tutorial.md +122 -209
- package/docs/plugins/aliases.md +1 -141
- package/docs/plugins/auth/auth_ldap.md +2 -39
- package/docs/plugins/max_unrecognized_commands.md +4 -18
- package/docs/plugins/process_title.md +3 -3
- package/docs/plugins/queue/smtp_forward.md +19 -3
- package/docs/plugins/queue/smtp_proxy.md +10 -2
- package/docs/plugins/reseed_rng.md +11 -13
- package/docs/plugins/tls.md +7 -7
- package/docs/plugins/toobusy.md +10 -4
- package/docs/tutorials/SettingUpOutbound.md +40 -48
- package/endpoint.js +32 -2
- package/haraka.js +1 -1
- package/outbound/hmail.js +42 -41
- package/outbound/index.js +7 -4
- package/outbound/tls.js +2 -43
- package/package.json +51 -61
- package/plugins/auth/auth_base.js +9 -3
- package/plugins/auth/auth_proxy.js +14 -11
- package/plugins/block_me.js +4 -2
- package/plugins/prevent_credential_leaks.js +3 -1
- package/plugins/process_title.js +6 -6
- package/plugins/queue/qmail-queue.js +15 -19
- package/plugins/queue/smtp_forward.js +12 -4
- package/plugins/queue/smtp_proxy.js +14 -3
- package/plugins/tls.js +13 -5
- package/plugins/xclient.js +3 -1
- package/server.js +22 -10
- package/smtp_client.js +20 -11
- package/test/config/block_me.recipient +1 -0
- package/test/config/block_me.senders +1 -0
- package/test/connection.js +258 -0
- package/test/endpoint.js +27 -0
- package/test/outbound/bounce_net_errors.js +3 -2
- package/test/outbound/hmail.js +19 -0
- package/test/outbound/index.js +189 -0
- package/test/outbound/queue.js +92 -0
- package/test/plugins/auth/auth_bridge.js +80 -0
- package/test/plugins/auth/flat_file.js +128 -0
- package/test/plugins/block_me.js +157 -0
- package/test/plugins/data.signatures.js +114 -0
- package/test/plugins/delay_deny.js +263 -0
- package/test/plugins/prevent_credential_leaks.js +178 -0
- package/test/plugins/process_title.js +135 -0
- package/test/plugins/queue/deliver.js +99 -0
- package/test/plugins/queue/discard.js +79 -0
- package/test/plugins/queue/lmtp.js +138 -0
- package/test/plugins/queue/qmail-queue.js +99 -0
- package/test/plugins/queue/quarantine.js +81 -0
- package/test/plugins/queue/smtp_bridge.js +154 -0
- package/test/plugins/queue/smtp_forward.js +42 -6
- package/test/plugins/queue/smtp_proxy.js +139 -0
- package/test/plugins/reseed_rng.js +34 -0
- package/test/plugins/tarpit.js +91 -0
- package/test/plugins/tls.js +25 -0
- package/test/plugins/toobusy.js +21 -0
- package/test/plugins/xclient.js +14 -0
- package/test/server.js +231 -0
- package/test/smtp_client.js +45 -12
- package/test/tls_socket.js +220 -0
- 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
|
-
|
|
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.
|
|
316
|
-
serverBanned = net_utils.ip_in_list(smtp_client.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
436
|
+
return smtp_client.emit('error', `AUTH ${auth_type} not implemented`)
|
|
428
437
|
default:
|
|
429
|
-
|
|
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.
|
|
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
|
package/test/connection.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
104
|
+
status: '5.1.10',
|
|
104
105
|
trigger: (h) => HMailItem.prototype.found_mx.apply(h, [[{ priority: 0, exchange: '' }]]),
|
|
105
106
|
},
|
|
106
107
|
{
|
package/test/outbound/hmail.js
CHANGED
|
@@ -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
|
|
package/test/outbound/index.js
CHANGED
|
@@ -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', () => {
|