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.
@@ -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()', () => {
@@ -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', () => {
@@ -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
  })