Haraka 3.1.5 → 3.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prettierignore +1 -1
- package/{Changes.md → CHANGELOG.md} +22 -2
- package/CONTRIBUTORS.md +26 -26
- package/README.md +68 -93
- package/SECURITY.md +178 -0
- package/bin/haraka +7 -14
- package/config/plugins +0 -3
- 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/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/outbound/hmail.js +3 -2
- package/outbound/index.js +3 -0
- package/package.json +19 -30
- package/server.js +17 -7
- package/test/connection.js +234 -0
- package/test/endpoint.js +27 -0
- package/test/outbound/hmail.js +19 -0
- package/test/outbound/index.js +189 -0
- package/test/outbound/queue.js +92 -0
- package/test/server.js +172 -0
- package/test/tls_socket.js +138 -0
- package/tls_socket.js +2 -2
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,237 @@ 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
|
+
})
|
|
445
679
|
})
|
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()', () => {
|
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', () => {
|
package/test/outbound/queue.js
CHANGED
|
@@ -230,4 +230,96 @@ describe('outbound/queue', () => {
|
|
|
230
230
|
}
|
|
231
231
|
})
|
|
232
232
|
})
|
|
233
|
+
|
|
234
|
+
describe('queue maintenance', () => {
|
|
235
|
+
it('delete_dot_files removes leftover dot files only', async () => {
|
|
236
|
+
const tmpDir = path.join(os.tmpdir(), `haraka-dot-clean-${Date.now()}`)
|
|
237
|
+
fs.mkdirSync(tmpDir, { recursive: true })
|
|
238
|
+
const dotName = `${qfile.platformDOT}leftover`
|
|
239
|
+
const normalName = 'keep-me'
|
|
240
|
+
fs.writeFileSync(path.join(tmpDir, dotName), 'x')
|
|
241
|
+
fs.writeFileSync(path.join(tmpDir, normalName), 'x')
|
|
242
|
+
|
|
243
|
+
const originalQueueDir = queue.queue_dir
|
|
244
|
+
queue.queue_dir = tmpDir
|
|
245
|
+
try {
|
|
246
|
+
await queue.delete_dot_files()
|
|
247
|
+
assert.equal(fs.existsSync(path.join(tmpDir, dotName)), false)
|
|
248
|
+
assert.equal(fs.existsSync(path.join(tmpDir, normalName)), true)
|
|
249
|
+
} finally {
|
|
250
|
+
queue.queue_dir = originalQueueDir
|
|
251
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('_add_hmail pushes immediate items and schedules delayed ones', () => {
|
|
256
|
+
const originalPush = queue.delivery_queue.push
|
|
257
|
+
const originalAdd = queue.temp_fail_queue.add
|
|
258
|
+
const pushed = []
|
|
259
|
+
const delayed = []
|
|
260
|
+
let delayedCb
|
|
261
|
+
|
|
262
|
+
queue.delivery_queue.push = (item) => pushed.push(item)
|
|
263
|
+
queue.temp_fail_queue.add = (id, ms, cb) => {
|
|
264
|
+
delayed.push([id, ms])
|
|
265
|
+
delayedCb = cb
|
|
266
|
+
}
|
|
267
|
+
queue.cur_time = new Date()
|
|
268
|
+
|
|
269
|
+
const immediate = { filename: 'a', next_process: queue.cur_time - 1 }
|
|
270
|
+
const future = { filename: 'b', next_process: queue.cur_time.getTime() + 1000 }
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
queue._add_hmail(immediate)
|
|
274
|
+
queue._add_hmail(future)
|
|
275
|
+
assert.equal(pushed.length, 1)
|
|
276
|
+
assert.equal(delayed.length, 1)
|
|
277
|
+
assert.equal(delayed[0][0], 'b')
|
|
278
|
+
delayedCb()
|
|
279
|
+
assert.equal(pushed.length, 2)
|
|
280
|
+
} finally {
|
|
281
|
+
queue.delivery_queue.push = originalPush
|
|
282
|
+
queue.temp_fail_queue.add = originalAdd
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('scan_queue_pids returns unique pids from queue files', async () => {
|
|
287
|
+
populateTestQueue()
|
|
288
|
+
const originalQueueDir = queue.queue_dir
|
|
289
|
+
queue.queue_dir = testQueueDir
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
const pids = await queue.scan_queue_pids()
|
|
293
|
+
assert.ok(Array.isArray(pids))
|
|
294
|
+
assert.equal(pids.length >= 1, true)
|
|
295
|
+
} finally {
|
|
296
|
+
queue.queue_dir = originalQueueDir
|
|
297
|
+
clearTestQueue()
|
|
298
|
+
}
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('scan_queue_pids throws when queue dir cannot be read', async () => {
|
|
302
|
+
const originalQueueDir = queue.queue_dir
|
|
303
|
+
const badPath = path.join(os.tmpdir(), `queue-not-dir-${Date.now()}`)
|
|
304
|
+
fs.writeFileSync(badPath, 'x')
|
|
305
|
+
queue.queue_dir = badPath
|
|
306
|
+
try {
|
|
307
|
+
await assert.rejects(() => queue.scan_queue_pids())
|
|
308
|
+
} finally {
|
|
309
|
+
queue.queue_dir = originalQueueDir
|
|
310
|
+
fs.rmSync(badPath, { force: true })
|
|
311
|
+
}
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('delete_dot_files handles readdir errors without throwing', async () => {
|
|
315
|
+
const originalQueueDir = queue.queue_dir
|
|
316
|
+
queue.queue_dir = path.join(os.tmpdir(), `missing-dot-${Date.now()}`)
|
|
317
|
+
try {
|
|
318
|
+
await queue.delete_dot_files()
|
|
319
|
+
assert.ok(true)
|
|
320
|
+
} finally {
|
|
321
|
+
queue.queue_dir = originalQueueDir
|
|
322
|
+
}
|
|
323
|
+
})
|
|
324
|
+
})
|
|
233
325
|
})
|