haraka-plugin-karma 2.1.8 → 2.2.0

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/CHANGELOG.md CHANGED
@@ -4,6 +4,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
4
4
 
5
5
  ### Unreleased
6
6
 
7
+ ### [2.2.0] - 2026-03-31
8
+
9
+ #### Added
10
+
11
+ - allow rspamd to greylist ASNs when SA & rspamd agree
12
+
13
+ #### Changed
14
+
15
+ - replace to_object with arrays
16
+ - deps(all): updated to latest
17
+ - style: ES2024 updates throughout
18
+ - test: test runner is now node:test
19
+ - test: coverage 58% -> 86%
20
+ - remove unnecessary done callbacks in synchronous tests (#65)
21
+
7
22
  ### [2.1.8] - 2025-10-27
8
23
 
9
24
  - fix: use optional chaining in should_we_skip, fixes #63
@@ -149,3 +164,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
149
164
  [2.1.6]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.1.6
150
165
  [2.1.7]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.1.7
151
166
  [2.1.8]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.1.8
167
+ [2.2.0]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.2.0
package/README.md CHANGED
@@ -1,5 +1,6 @@
1
1
  [![Build Status][ci-img]][ci-url]
2
2
  [![Code Climate][clim-img]][clim-url]
3
+ [![Code Coverage][cov-img]][cov-url]
3
4
 
4
5
  # Karma - A heuristics based reputation engine for the Haraka MTA
5
6
 
@@ -197,5 +198,5 @@ Expect to use karma _with_ content filters.
197
198
  [ci-url]: https://github.com/haraka/haraka-plugin-karma/actions/workflows/ci.yml
198
199
  [cov-img]: https://codecov.io/github/haraka/haraka-plugin-karma/coverage.svg
199
200
  [cov-url]: https://codecov.io/github/haraka/haraka-plugin-karma
200
- [clim-img]: https://codeclimate.com/github/haraka/haraka-plugin-karma/badges/gpa.svg
201
- [clim-url]: https://codeclimate.com/github/haraka/haraka-plugin-karma
201
+ [clim-img]: https://qlty.sh/gh/haraka/projects/haraka-plugin-karma/maintainability.svg
202
+ [clim-url]: https://qlty.sh/gh/haraka/projects/haraka-plugin-karma
package/config/karma.ini CHANGED
@@ -63,6 +63,23 @@ plugins=send_email, tls, access, helo.checks, headers, rspamd, spamassassin, cla
63
63
  hooks=rcpt, queue, queue_outbound
64
64
 
65
65
 
66
+ [greylist]
67
+ ; When a connection comes from one of the listed ASNs and both SpamAssassin
68
+ ; and rspamd scores exceed their respective thresholds, allow rspamd to
69
+ ; greylist (DENYSOFT) instead of karma intercepting the denial.
70
+ ; This is only relevant when rspamd is NOT in [deny_excludes] plugins.
71
+ ;
72
+ ; asn[] = 55286 ; ASN numbers eligible for greylisting
73
+ ; asn[] = 33182
74
+ ; asn[] = 46717
75
+ ;
76
+ ; spamassassin_score = SpamAssassin hits score must exceed this value
77
+ ; spamassassin_score = 5
78
+ ;
79
+ ; rspamd_score = rspamd score must exceed this value
80
+ ; rspamd_score = 6
81
+
82
+
66
83
  [spammy_tlds]
67
84
  ; award negative karma to spammy TLDs
68
85
  ; caution, awarding karma > msg_negative_limit may blacklist that TLD
package/index.js CHANGED
@@ -3,38 +3,31 @@
3
3
 
4
4
  const constants = require('haraka-constants')
5
5
  const redis = require('redis')
6
- const utils = require('haraka-utils')
7
6
 
8
- const phase_prefixes = utils.to_object([
9
- 'connect',
10
- 'helo',
11
- 'mail_from',
12
- 'rcpt_to',
13
- 'data',
14
- ])
7
+ const phase_prefixes = ['connect', 'helo', 'mail_from', 'rcpt_to', 'data']
15
8
 
16
9
  exports.register = function () {
17
10
  this.inherits('haraka-plugin-redis')
18
11
 
19
12
  // set up defaults
20
- this.deny_hooks = utils.to_object([
13
+ this.deny_hooks = [
21
14
  'unrecognized_command',
22
15
  'helo',
23
16
  'data',
24
17
  'data_post',
25
18
  'queue',
26
19
  'queue_outbound',
27
- ])
28
- this.deny_exclude_hooks = utils.to_object('rcpt_to queue queue_outbound')
29
- this.deny_exclude_plugins = utils.to_object([
20
+ ]
21
+ this.deny_exclude_hooks = ['rcpt_to', 'queue', 'queue_outbound']
22
+ this.deny_exclude_plugins = [
30
23
  'access',
31
24
  'helo.checks',
32
- 'data.headers',
25
+ 'headers',
33
26
  'spamassassin',
34
27
  'mail_from.is_resolvable',
35
28
  'clamd',
36
29
  'tls',
37
- ])
30
+ ]
38
31
 
39
32
  this.load_karma_ini()
40
33
 
@@ -46,38 +39,35 @@ exports.register = function () {
46
39
  }
47
40
 
48
41
  exports.load_karma_ini = function () {
49
- const plugin = this
50
-
51
- plugin.cfg = plugin.config.get(
42
+ this.cfg = this.config.get(
52
43
  'karma.ini',
53
44
  {
54
45
  booleans: ['+asn.enable'],
55
46
  },
56
- function () {
57
- plugin.load_karma_ini()
58
- },
47
+ () => this.load_karma_ini(),
59
48
  )
60
49
 
61
- plugin.merge_redis_ini()
50
+ this.merge_redis_ini()
62
51
 
63
- const cfg = plugin.cfg
64
- if (cfg.deny && cfg.deny.hooks) {
65
- plugin.deny_hooks = utils.to_object(cfg.deny.hooks)
52
+ const cfg = this.cfg
53
+ if (cfg.deny?.hooks) {
54
+ this.deny_hooks = this.stringToArray(cfg.deny.hooks)
66
55
  }
67
56
 
68
57
  const e = cfg.deny_excludes
69
- if (e && e.hooks) {
70
- plugin.deny_exclude_hooks = utils.to_object(e.hooks)
58
+ if (e?.hooks) {
59
+ this.deny_exclude_hooks = this.stringToArray(e.hooks)
71
60
  }
72
-
73
- if (e && e.plugins) {
74
- plugin.deny_exclude_plugins = utils.to_object(e.plugins)
61
+ if (e?.plugins) {
62
+ this.deny_exclude_plugins = this.stringToArray(e.plugins)
75
63
  }
76
64
 
77
65
  if (cfg.result_awards) {
78
- plugin.preparse_result_awards()
66
+ this.preparse_result_awards()
79
67
  }
80
68
 
69
+ this.greylist_asns = cfg.greylist?.asn ?? []
70
+
81
71
  if (!cfg.redis) cfg.redis = {}
82
72
  if (!cfg.redis.host && cfg.redis.server_ip) {
83
73
  cfg.redis.host = cfg.redis.server_ip // backwards compat
@@ -103,8 +93,7 @@ exports.results_init = async function (next, connection) {
103
93
  // When discovered, apply the awards value
104
94
  const todo = {}
105
95
  for (const key in this.cfg.awards) {
106
- const award = this.cfg.awards[key].toString()
107
- todo[key] = award
96
+ todo[key] = this.cfg.awards[key].toString()
108
97
  }
109
98
  connection.results.add(this, { score: 0, todo })
110
99
  } else {
@@ -158,18 +147,16 @@ exports.preparse_result_awards = function () {
158
147
  }
159
148
 
160
149
  exports.check_result = function (connection, message) {
161
- // connection.loginfo(this, message);
162
150
  // {"plugin":"karma","result":{"fail":"spamassassin.hits"}}
163
151
  // {"plugin":"geoip","result":{"country":"CN"}}
164
152
 
165
153
  const m = JSON.parse(message)
166
- if (m && m.result && m.result.asn) {
154
+ if (m?.result?.asn) {
167
155
  this.check_result_asn(m.result.asn, connection)
168
156
  }
169
157
  if (!this.result_awards[m.plugin]) return // no awards for plugin
170
158
 
171
159
  for (const r of Object.keys(m.result)) {
172
- // each result in mess
173
160
  if (r === 'emit') continue // r: pass, fail, skip, err, ...
174
161
 
175
162
  const pi_prop = this.result_awards[m.plugin][r]
@@ -185,7 +172,6 @@ exports.check_result = function (connection, message) {
185
172
 
186
173
  // do any award conditions match this result?
187
174
  for (const thisAward of pi_prop) {
188
- // each award...
189
175
  // { id: '011', operator: 'equals', value: 'all_bad', award: '-2'}
190
176
  const thisResArr = this.result_as_array(thisResult)
191
177
  switch (thisAward.operator) {
@@ -216,13 +202,7 @@ exports.result_as_array = function (result) {
216
202
  if (typeof result === 'number') return [result]
217
203
  if (typeof result === 'boolean') return [result]
218
204
  if (Array.isArray(result)) return result
219
- if (typeof result === 'object') {
220
- const array = []
221
- Object.keys(result).forEach((tr) => {
222
- array.push(result[tr])
223
- })
224
- return array
225
- }
205
+ if (typeof result === 'object') return Object.values(result)
226
206
  this.loginfo(`what format is result: ${result}`)
227
207
  return result
228
208
  }
@@ -288,7 +268,7 @@ exports.check_result_match = function (thisResult, thisAward, conn) {
288
268
 
289
269
  exports.check_result_length = function (thisResult, thisAward, conn) {
290
270
  for (const element of thisResult) {
291
- const [operator, qty] = thisAward.value.split(/\s+/) // requires node 6+
271
+ const [operator, qty] = thisAward.value.split(/\s+/)
292
272
 
293
273
  switch (operator) {
294
274
  case 'eq':
@@ -313,9 +293,8 @@ exports.check_result_length = function (thisResult, thisAward, conn) {
313
293
  }
314
294
 
315
295
  exports.check_result_exists = function (thisResult, thisAward, conn) {
316
- /* eslint-disable no-unused-vars */
317
- for (const r of thisResult) {
318
- const [operator, qty] = thisAward.value.split(/\s+/)
296
+ for (const _r of thisResult) {
297
+ const [operator] = thisAward.value.split(/\s+/)
319
298
 
320
299
  switch (operator) {
321
300
  case 'any':
@@ -336,7 +315,7 @@ exports.apply_tarpit = function (connection, hook, score, next) {
336
315
 
337
316
  // If tarpit is enabled on the reset_transaction hook, Haraka doesn't
338
317
  // wait. Then bad things happen, like a Haraka crash.
339
- if (utils.in_array(hook, ['reset_transaction', 'queue'])) return next()
318
+ if (['reset_transaction', 'queue'].includes(hook)) return next()
340
319
 
341
320
  // no delay for senders with good karma
342
321
  const k = connection.results.get('karma')
@@ -394,7 +373,7 @@ exports.tarpit_delay_msa = function (connection, delay, k) {
394
373
  // Reduce delay for good ASN history
395
374
  let asn = connection.results.get('asn')
396
375
  if (!asn) asn = connection.results.get('geoip')
397
- if (asn && asn.asn && asn.asn_score > 0) {
376
+ if (asn?.asn && asn.asn_score > 0) {
398
377
  connection.logdebug(this, `${trg} neighbors: ${delay}`)
399
378
  delay = delay - 2
400
379
  }
@@ -414,6 +393,36 @@ exports.should_we_skip = function (connection) {
414
393
  return false
415
394
  }
416
395
 
396
+ exports.should_rspamd_greylist = function (connection) {
397
+ // check connection's ASN is in the configured list
398
+ let asnr = connection.results.get('asn')
399
+ if (!asnr?.asn) asnr = connection.results.get('geoip')
400
+ if (!asnr?.asn) return false
401
+ if (!this.greylist_asns.includes(String(asnr.asn))) return false
402
+
403
+ // check SpamAssassin score exceeds configured threshold
404
+ const saScore = parseFloat(
405
+ connection.transaction?.results?.get('spamassassin')?.hits,
406
+ )
407
+ const saThreshold = parseFloat(this.cfg.greylist?.spamassassin_score)
408
+ if (isNaN(saScore) || isNaN(saThreshold) || saScore <= saThreshold)
409
+ return false
410
+
411
+ // check rspamd score exceeds configured threshold
412
+ const rspamdScore = parseFloat(
413
+ connection.transaction?.results?.get('rspamd')?.score,
414
+ )
415
+ const rspamdThreshold = parseFloat(this.cfg.greylist?.rspamd_score)
416
+ if (
417
+ isNaN(rspamdScore) ||
418
+ isNaN(rspamdThreshold) ||
419
+ rspamdScore <= rspamdThreshold
420
+ )
421
+ return false
422
+
423
+ return true
424
+ }
425
+
417
426
  exports.should_we_deny = function (next, connection, hook) {
418
427
  const r = connection.results.get('karma')
419
428
  if (!r) return next()
@@ -435,7 +444,7 @@ exports.should_we_deny = function (next, connection, hook) {
435
444
  if (score > negative_limit) {
436
445
  return this.apply_tarpit(connection, hook, score, next)
437
446
  }
438
- if (!this.deny_hooks[hook]) {
447
+ if (!this.deny_hooks.includes(hook)) {
439
448
  return this.apply_tarpit(connection, hook, score, next)
440
449
  }
441
450
 
@@ -457,19 +466,21 @@ exports.should_we_deny = function (next, connection, hook) {
457
466
  exports.hook_deny = function (next, connection, params) {
458
467
  if (this.should_we_skip(connection)) return next()
459
468
 
460
- // let pi_deny = params[0]; // (constants.deny, denysoft, ok)
461
- // let pi_message = params[1];
462
- const pi_name = params[2]
463
- // let pi_function = params[3];
464
- // let pi_params = params[4];
465
- const pi_hook = params[5]
469
+ const [pi_rc, , pi_name, , , pi_hook] = params
466
470
 
467
471
  // exceptions, whose 'DENY' should not be captured
468
472
  if (pi_name) {
469
473
  if (pi_name === 'karma') return next()
470
- if (this.deny_exclude_plugins[pi_name]) return next()
474
+ if (this.deny_exclude_plugins.includes(pi_name)) return next()
475
+ // allow rspamd to greylist when configured ASN/score conditions are met
476
+ if (
477
+ pi_rc === constants.DENYSOFT &&
478
+ pi_name === 'rspamd' &&
479
+ this.should_rspamd_greylist(connection)
480
+ )
481
+ return next()
471
482
  }
472
- if (pi_hook && this.deny_exclude_hooks[pi_hook]) return next()
483
+ if (pi_hook && this.deny_exclude_hooks.includes(pi_hook)) return next()
473
484
 
474
485
  if (!connection.results) return next(constants.OK) // resume the connection
475
486
 
@@ -554,9 +565,7 @@ exports.hook_unrecognized_command = function (next, connection, params) {
554
565
  return this.should_we_deny(next, connection, 'unrecognized_command')
555
566
  }
556
567
 
557
- exports.ip_history_from_redis = function (next, connection) {
558
- const plugin = this
559
-
568
+ exports.ip_history_from_redis = async function (next, connection) {
560
569
  if (this.should_we_skip(connection)) return next()
561
570
 
562
571
  const expire = (this.cfg.redis.expire_days || 60) * 86400 // to days
@@ -565,48 +574,47 @@ exports.ip_history_from_redis = function (next, connection) {
565
574
  // redis plugin is emitting errors, no need to here
566
575
  if (!this.db) return next()
567
576
 
568
- this.db
569
- .hGetAll(dbkey)
570
- .then((dbr) => {
571
- if (dbr === null) {
572
- plugin.init_ip(dbkey, connection.remote.ip, expire)
573
- return next()
574
- }
577
+ try {
578
+ const dbr = await this.db.hGetAll(dbkey)
575
579
 
576
- plugin.db
577
- .multi()
578
- .hIncrBy(dbkey, 'connections', 1) // increment total conn
579
- .expire(dbkey, expire) // extend expiration
580
- .exec()
581
- .catch((err) => {
582
- connection.results.add(plugin, { err })
583
- })
584
-
585
- const results = {
586
- good: dbr.good,
587
- bad: dbr.bad,
588
- connections: dbr.connections,
589
- history: parseInt((dbr.good || 0) - (dbr.bad || 0)),
590
- emit: true,
591
- }
580
+ if (dbr === null) {
581
+ this.init_ip(dbkey, connection.remote.ip, expire)
582
+ return next()
583
+ }
592
584
 
593
- // Careful: don't become self-fulfilling prophecy.
594
- if (parseInt(dbr.good) > 5 && parseInt(dbr.bad) === 0) {
595
- results.pass = 'all_good'
596
- }
597
- if (parseInt(dbr.bad) > 5 && parseInt(dbr.good) === 0) {
598
- results.fail = 'all_bad'
599
- }
585
+ this.db
586
+ .multi()
587
+ .hIncrBy(dbkey, 'connections', 1) // increment total conn
588
+ .expire(dbkey, expire) // extend expiration
589
+ .exec()
590
+ .catch((err) => {
591
+ connection.results.add(this, { err })
592
+ })
593
+
594
+ const results = {
595
+ good: dbr.good,
596
+ bad: dbr.bad,
597
+ connections: dbr.connections,
598
+ history: Number(dbr.good || 0) - Number(dbr.bad || 0),
599
+ emit: true,
600
+ }
601
+
602
+ // Careful: don't become self-fulfilling prophecy.
603
+ if (Number(dbr.good || 0) > 5 && Number(dbr.bad || 0) === 0) {
604
+ results.pass = 'all_good'
605
+ }
606
+ if (Number(dbr.bad || 0) > 5 && Number(dbr.good || 0) === 0) {
607
+ results.fail = 'all_bad'
608
+ }
600
609
 
601
- connection.results.add(plugin, results)
610
+ connection.results.add(this, results)
602
611
 
603
- plugin.check_awards(connection)
604
- next()
605
- })
606
- .catch((err) => {
607
- connection.results.add(plugin, { err })
608
- next()
609
- })
612
+ this.check_awards(connection)
613
+ next()
614
+ } catch (err) {
615
+ connection.results.add(this, { err })
616
+ next()
617
+ }
610
618
  }
611
619
 
612
620
  exports.hook_mail = function (next, connection, params) {
@@ -696,7 +704,7 @@ exports.hook_data_post = function (next, connection) {
696
704
  return this.should_we_deny(next, connection, 'data_post')
697
705
  }
698
706
 
699
- exports.increment = function (connection, key, val) {
707
+ exports.increment = function (connection, key, _val) {
700
708
  if (!this.db) return
701
709
 
702
710
  this.db.hIncrBy(`karma|${connection.remote.ip}`, key, 1)
@@ -739,19 +747,15 @@ exports.get_award_loc_from_note = function (connection, award) {
739
747
  if (obj) return obj
740
748
  }
741
749
 
742
- // connection.logdebug(this, `no txn note: ${award}`);
743
750
  const obj = this.assemble_note_obj(connection, award)
744
751
  if (obj) return obj
745
-
746
- // connection.logdebug(this, `no conn note: ${award}`);
747
- return
748
752
  }
749
753
 
750
754
  exports.get_award_loc_from_results = function (connection, loc_bits) {
751
755
  let pi_name = loc_bits[1]
752
756
  let notekey = loc_bits[2]
753
757
 
754
- if (phase_prefixes[pi_name]) {
758
+ if (phase_prefixes.includes(pi_name)) {
755
759
  pi_name = `${loc_bits[1]}.${loc_bits[2]}`
756
760
  notekey = loc_bits[3]
757
761
  }
@@ -759,11 +763,9 @@ exports.get_award_loc_from_results = function (connection, loc_bits) {
759
763
  let obj
760
764
  if (connection.transaction) obj = connection.transaction.results.get(pi_name)
761
765
 
762
- // connection.logdebug(this, `no txn results: ${pi_name}`);
763
766
  if (!obj) obj = connection.results.get(pi_name)
764
767
  if (!obj) return
765
768
 
766
- // connection.logdebug(this, `found results for ${pi_name}, ${notekey}`);
767
769
  if (notekey) return obj[notekey]
768
770
  return obj
769
771
  }
@@ -832,7 +834,7 @@ exports.check_awards = function (connection) {
832
834
  // test the desired condition
833
835
  const bits = award_terms.split(/\s+/)
834
836
  const award = parseFloat(bits[0])
835
- if (!bits[1] || bits[1] !== 'if') {
837
+ if (bits[1] !== 'if') {
836
838
  // no if conditions
837
839
  if (!note) continue // failed truth test
838
840
  if (!wants) {
@@ -844,8 +846,6 @@ exports.check_awards = function (connection) {
844
846
  if (note !== wants) continue // didn't match
845
847
  }
846
848
 
847
- // connection.loginfo(this, `check_awards, case matching for: ${wants}`
848
-
849
849
  // the matching logic here is inverted, weeding out misses (continue)
850
850
  // Matches fall through (break) to the apply_award below.
851
851
  const condition = bits[2]
@@ -861,7 +861,6 @@ exports.check_awards = function (connection) {
861
861
  break
862
862
  case 'match':
863
863
  if (Array.isArray(note)) {
864
- // connection.logerror(this, 'matching an array');
865
864
  if (new RegExp(wants, 'i').test(note)) break
866
865
  }
867
866
  if (note.toString().match(new RegExp(wants, 'i'))) break
@@ -891,7 +890,6 @@ exports.check_awards = function (connection) {
891
890
  break
892
891
  }
893
892
  case 'in': // if in pass whitelisted
894
- // let list = bits[3];
895
893
  if (bits[4]) {
896
894
  wants = bits[4]
897
895
  }
@@ -915,25 +913,21 @@ exports.apply_award = function (connection, nl, award) {
915
913
  return
916
914
  }
917
915
 
918
- const bits = nl.split('@')
919
- nl = bits[0] // strip off @... if present
916
+ nl = nl.split('@')[0] // strip off @... if present
920
917
 
921
918
  connection.results.incr(this, { score: award })
922
919
  connection.logdebug(this, `applied ${nl}:${award}`)
923
920
 
924
- let trimmed =
925
- nl.substring(0, 5) === 'notes'
926
- ? nl.substring(6)
927
- : nl.substring(0, 7) === 'results'
928
- ? nl.substring(8)
929
- : nl.substring(0, 19) === 'transaction.results'
930
- ? nl.substring(20)
931
- : nl
921
+ let trimmed
922
+ if (nl.startsWith('notes.')) trimmed = nl.slice(6)
923
+ else if (nl.startsWith('results.')) trimmed = nl.slice(8)
924
+ else if (nl.startsWith('transaction.results.')) trimmed = nl.slice(20)
925
+ else trimmed = nl
932
926
 
933
- if (trimmed.substring(0, 7) === 'rcpt_to') trimmed = trimmed.substring(8)
934
- if (trimmed.substring(0, 7) === 'mail_from') trimmed = trimmed.substring(10)
935
- if (trimmed.substring(0, 7) === 'connect') trimmed = trimmed.substring(8)
936
- if (trimmed.substring(0, 4) === 'data') trimmed = trimmed.substring(5)
927
+ if (trimmed.startsWith('rcpt_to.')) trimmed = trimmed.slice(8)
928
+ else if (trimmed.startsWith('mail_from.')) trimmed = trimmed.slice(10)
929
+ else if (trimmed.startsWith('connect.')) trimmed = trimmed.slice(8)
930
+ else if (trimmed.startsWith('data.')) trimmed = trimmed.slice(5)
937
931
 
938
932
  if (award > 0) connection.results.add(this, { pass: trimmed })
939
933
  if (award < 0) connection.results.add(this, { fail: trimmed })
@@ -944,7 +938,6 @@ exports.check_spammy_tld = function (mail_from, connection) {
944
938
  if (mail_from.isNull()) return // null sender (bounce)
945
939
 
946
940
  const from_tld = mail_from.host.split('.').pop()
947
- // connection.logdebug(this, `from_tld: ${from_tld}`);
948
941
 
949
942
  const tld_penalty = parseFloat(this.cfg.spammy_tlds[from_tld] || 0)
950
943
  if (tld_penalty === 0) return
@@ -967,7 +960,7 @@ exports.assemble_note_obj = function (prefix, key) {
967
960
  const parts = key.split('.')
968
961
  while (parts.length > 0) {
969
962
  let next = parts.shift()
970
- if (phase_prefixes[next]) {
963
+ if (phase_prefixes.includes(next)) {
971
964
  next = `${next}.${parts.shift()}`
972
965
  }
973
966
  note = note[next]
@@ -976,45 +969,44 @@ exports.assemble_note_obj = function (prefix, key) {
976
969
  return note
977
970
  }
978
971
 
979
- exports.check_asn = function (connection, asnkey) {
972
+ exports.check_asn = async function (connection, asnkey) {
980
973
  if (!this.db) return
981
974
 
982
975
  const report_as = { name: this.name }
983
976
  if (this.cfg.asn.report_as) report_as.name = this.cfg.asn.report_as
984
977
 
985
- this.db
986
- .hGetAll(asnkey)
987
- .then((res) => {
988
- if (res === null) {
989
- const expire = (this.cfg.redis.expire_days || 60) * 86400 // days
990
- this.init_asn(asnkey, expire)
991
- return
992
- }
978
+ try {
979
+ const res = await this.db.hGetAll(asnkey)
993
980
 
994
- this.db.hIncrBy(asnkey, 'connections', 1)
995
- const asn_score = parseInt(res.good || 0) - (res.bad || 0)
981
+ if (res === null) {
982
+ const expire = (this.cfg.redis.expire_days || 60) * 86400 // days
983
+ this.init_asn(asnkey, expire)
984
+ return
985
+ }
996
986
 
997
- if (asn_score) {
998
- connection.results.add(report_as, { asn_score: asn_score })
999
- if (asn_score < -5) {
1000
- connection.results.add(report_as, { fail: 'asn:history' })
1001
- } else if (asn_score > 5) {
1002
- connection.results.add(report_as, { pass: 'asn:history' })
1003
- }
1004
- }
987
+ this.db.hIncrBy(asnkey, 'connections', 1)
988
+ const asn_score = Number(res.good || 0) - Number(res.bad || 0)
1005
989
 
1006
- if (parseInt(res.bad) > 5 && parseInt(res.good) === 0) {
1007
- connection.results.add(report_as, { fail: 'asn:all_bad' })
1008
- }
1009
- if (parseInt(res.good) > 5 && parseInt(res.bad) === 0) {
1010
- connection.results.add(report_as, { pass: 'asn:all_good' })
990
+ if (asn_score) {
991
+ connection.results.add(report_as, { asn_score })
992
+ if (asn_score < -5) {
993
+ connection.results.add(report_as, { fail: 'asn:history' })
994
+ } else if (asn_score > 5) {
995
+ connection.results.add(report_as, { pass: 'asn:history' })
1011
996
  }
997
+ }
1012
998
 
1013
- connection.results.add(report_as, { emit: true })
1014
- })
1015
- .catch((err) => {
1016
- connection.results.add(this, { err })
1017
- })
999
+ if (Number(res.bad || 0) > 5 && Number(res.good || 0) === 0) {
1000
+ connection.results.add(report_as, { fail: 'asn:all_bad' })
1001
+ }
1002
+ if (Number(res.good || 0) > 5 && Number(res.bad || 0) === 0) {
1003
+ connection.results.add(report_as, { pass: 'asn:all_good' })
1004
+ }
1005
+
1006
+ connection.results.add(report_as, { emit: true })
1007
+ } catch (err) {
1008
+ connection.results.add(this, { err })
1009
+ }
1018
1010
  }
1019
1011
 
1020
1012
  exports.init_ip = async function (dbkey, rip, expire) {
@@ -1042,3 +1034,8 @@ exports.init_asn = function (asnkey, expire) {
1042
1034
  .expire(asnkey, expire * 2) // keep ASN longer
1043
1035
  .exec()
1044
1036
  }
1037
+
1038
+ exports.stringToArray = (input) => {
1039
+ if (typeof input !== 'string') throw new Error('Input must be a string')
1040
+ return input.split(/[\s,;]+/).filter((item) => item) // split and remove empty items
1041
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haraka-plugin-karma",
3
- "version": "2.1.8",
3
+ "version": "2.2.0",
4
4
  "description": "A heuristics scoring and reputation engine for SMTP connections",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -8,14 +8,16 @@
8
8
  "config"
9
9
  ],
10
10
  "scripts": {
11
+ "clean": "rm -rf node_modules package-lock.json",
11
12
  "format": "npm run prettier:fix && npm run lint:fix",
12
- "lint": "npx eslint@^9 *.js test",
13
- "lint:fix": "npx eslint@^9 *.js test --fix",
13
+ "lint": "npx eslint *.js test",
14
+ "lint:fix": "npx eslint *.js test --fix",
14
15
  "prettier": "npx prettier . --check",
15
16
  "prettier:fix": "npx prettier . --write --log-level=warn",
16
- "test": "npx mocha@^11",
17
- "versions": "npx dependency-version-checker check",
18
- "versions:fix": "npx dependency-version-checker update"
17
+ "test": "node --test",
18
+ "test:coverage": "npx c8 --reporter=text --reporter=text-summary npm test",
19
+ "versions": "npx npm-dep-mgr check",
20
+ "versions:fix": "npx npm-dep-mgr update"
19
21
  },
20
22
  "repository": {
21
23
  "type": "git",
@@ -31,15 +33,13 @@
31
33
  },
32
34
  "homepage": "https://github.com/haraka/haraka-plugin-karma#readme",
33
35
  "dependencies": {
34
- "address-rfc2821": "^2.1.2",
35
- "haraka-constants": "^1.0.6",
36
- "haraka-utils": "^1.1.1",
37
- "haraka-plugin-redis": "^2.0.6",
38
- "redis": "^4.6.13"
36
+ "address-rfc2821": "^2.1.5",
37
+ "haraka-constants": "^1.0.7",
38
+ "haraka-plugin-redis": "^2.0.11"
39
39
  },
40
40
  "devDependencies": {
41
- "@haraka/eslint-config": "^2.0.2",
42
- "haraka-test-fixtures": "^1.3.4"
41
+ "@haraka/eslint-config": "^2.0.4",
42
+ "haraka-test-fixtures": "^1.4.1"
43
43
  },
44
44
  "prettier": {
45
45
  "singleQuote": true,