haraka-plugin-karma 2.1.8 → 2.3.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,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
4
4
 
5
5
  ### Unreleased
6
6
 
7
+ ### [2.3.0] - 2026-05-05
8
+
9
+ - allow rspamd to greylist image-only spam
10
+ - allow rspamd to greylist Google Groups messages
11
+
12
+ ### [2.2.0] - 2026-03-31
13
+
14
+ #### Added
15
+
16
+ - allow rspamd to greylist ASNs when SA & rspamd agree
17
+
18
+ #### Changed
19
+
20
+ - replace to_object with arrays
21
+ - deps(all): updated to latest
22
+ - style: ES2024 updates throughout
23
+ - test: test runner is now node:test
24
+ - test: coverage 58% -> 86%
25
+ - remove unnecessary done callbacks in synchronous tests (#65)
26
+
7
27
  ### [2.1.8] - 2025-10-27
8
28
 
9
29
  - fix: use optional chaining in should_we_skip, fixes #63
@@ -149,3 +169,5 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
149
169
  [2.1.6]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.1.6
150
170
  [2.1.7]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.1.7
151
171
  [2.1.8]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.1.8
172
+ [2.2.0]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.2.0
173
+ [2.3.0]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.3.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,18 @@ 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
+ ; allow rspamd to greylist (DENYSOFT) instead of karma intercepting the denial.
68
+ ; This is only relevant when rspamd is NOT in [deny_excludes] plugins.
69
+ ;
70
+ ; asn[] = 55286 ; ASN numbers eligible for greylisting
71
+ ; asn[] = 33182
72
+ ; asn[] = 46717
73
+ ;
74
+ ; spamassassin_score = SpamAssassin hits score must exceed this value
75
+ ; spamassassin_score = 5
76
+
77
+
66
78
  [spammy_tlds]
67
79
  ; award negative karma to spammy TLDs
68
80
  ; 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,33 @@ 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 ?? 0,
406
+ )
407
+ const saThreshold = parseFloat(this.cfg.greylist?.spamassassin_score ?? 5)
408
+ if (saScore >= saThreshold) return true
409
+
410
+ if (connection.transaction.header.get('X-Google-Group-Id')) return true
411
+
412
+ // image-only spam: message body is a single embedded image
413
+ if (
414
+ connection.transaction.header
415
+ .get('content-type')
416
+ ?.startsWith('multipart/related')
417
+ )
418
+ return true
419
+
420
+ return false
421
+ }
422
+
417
423
  exports.should_we_deny = function (next, connection, hook) {
418
424
  const r = connection.results.get('karma')
419
425
  if (!r) return next()
@@ -435,7 +441,7 @@ exports.should_we_deny = function (next, connection, hook) {
435
441
  if (score > negative_limit) {
436
442
  return this.apply_tarpit(connection, hook, score, next)
437
443
  }
438
- if (!this.deny_hooks[hook]) {
444
+ if (!this.deny_hooks.includes(hook)) {
439
445
  return this.apply_tarpit(connection, hook, score, next)
440
446
  }
441
447
 
@@ -457,19 +463,21 @@ exports.should_we_deny = function (next, connection, hook) {
457
463
  exports.hook_deny = function (next, connection, params) {
458
464
  if (this.should_we_skip(connection)) return next()
459
465
 
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]
466
+ const [pi_rc, , pi_name, , , pi_hook] = params
466
467
 
467
468
  // exceptions, whose 'DENY' should not be captured
468
469
  if (pi_name) {
469
470
  if (pi_name === 'karma') return next()
470
- if (this.deny_exclude_plugins[pi_name]) return next()
471
+ if (this.deny_exclude_plugins.includes(pi_name)) return next()
472
+ // allow rspamd to greylist when configured ASN/score conditions are met
473
+ if (
474
+ pi_rc === constants.DENYSOFT &&
475
+ pi_name === 'rspamd' &&
476
+ this.should_rspamd_greylist(connection)
477
+ )
478
+ return next()
471
479
  }
472
- if (pi_hook && this.deny_exclude_hooks[pi_hook]) return next()
480
+ if (pi_hook && this.deny_exclude_hooks.includes(pi_hook)) return next()
473
481
 
474
482
  if (!connection.results) return next(constants.OK) // resume the connection
475
483
 
@@ -554,9 +562,7 @@ exports.hook_unrecognized_command = function (next, connection, params) {
554
562
  return this.should_we_deny(next, connection, 'unrecognized_command')
555
563
  }
556
564
 
557
- exports.ip_history_from_redis = function (next, connection) {
558
- const plugin = this
559
-
565
+ exports.ip_history_from_redis = async function (next, connection) {
560
566
  if (this.should_we_skip(connection)) return next()
561
567
 
562
568
  const expire = (this.cfg.redis.expire_days || 60) * 86400 // to days
@@ -565,48 +571,47 @@ exports.ip_history_from_redis = function (next, connection) {
565
571
  // redis plugin is emitting errors, no need to here
566
572
  if (!this.db) return next()
567
573
 
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
- }
574
+ try {
575
+ const dbr = await this.db.hGetAll(dbkey)
575
576
 
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
- }
577
+ if (dbr === null) {
578
+ this.init_ip(dbkey, connection.remote.ip, expire)
579
+ return next()
580
+ }
592
581
 
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
- }
582
+ this.db
583
+ .multi()
584
+ .hIncrBy(dbkey, 'connections', 1) // increment total conn
585
+ .expire(dbkey, expire) // extend expiration
586
+ .exec()
587
+ .catch((err) => {
588
+ connection.results.add(this, { err })
589
+ })
590
+
591
+ const results = {
592
+ good: dbr.good,
593
+ bad: dbr.bad,
594
+ connections: dbr.connections,
595
+ history: Number(dbr.good || 0) - Number(dbr.bad || 0),
596
+ emit: true,
597
+ }
598
+
599
+ // Careful: don't become self-fulfilling prophecy.
600
+ if (Number(dbr.good || 0) > 5 && Number(dbr.bad || 0) === 0) {
601
+ results.pass = 'all_good'
602
+ }
603
+ if (Number(dbr.bad || 0) > 5 && Number(dbr.good || 0) === 0) {
604
+ results.fail = 'all_bad'
605
+ }
600
606
 
601
- connection.results.add(plugin, results)
607
+ connection.results.add(this, results)
602
608
 
603
- plugin.check_awards(connection)
604
- next()
605
- })
606
- .catch((err) => {
607
- connection.results.add(plugin, { err })
608
- next()
609
- })
609
+ this.check_awards(connection)
610
+ next()
611
+ } catch (err) {
612
+ connection.results.add(this, { err })
613
+ next()
614
+ }
610
615
  }
611
616
 
612
617
  exports.hook_mail = function (next, connection, params) {
@@ -696,7 +701,7 @@ exports.hook_data_post = function (next, connection) {
696
701
  return this.should_we_deny(next, connection, 'data_post')
697
702
  }
698
703
 
699
- exports.increment = function (connection, key, val) {
704
+ exports.increment = function (connection, key, _val) {
700
705
  if (!this.db) return
701
706
 
702
707
  this.db.hIncrBy(`karma|${connection.remote.ip}`, key, 1)
@@ -739,19 +744,15 @@ exports.get_award_loc_from_note = function (connection, award) {
739
744
  if (obj) return obj
740
745
  }
741
746
 
742
- // connection.logdebug(this, `no txn note: ${award}`);
743
747
  const obj = this.assemble_note_obj(connection, award)
744
748
  if (obj) return obj
745
-
746
- // connection.logdebug(this, `no conn note: ${award}`);
747
- return
748
749
  }
749
750
 
750
751
  exports.get_award_loc_from_results = function (connection, loc_bits) {
751
752
  let pi_name = loc_bits[1]
752
753
  let notekey = loc_bits[2]
753
754
 
754
- if (phase_prefixes[pi_name]) {
755
+ if (phase_prefixes.includes(pi_name)) {
755
756
  pi_name = `${loc_bits[1]}.${loc_bits[2]}`
756
757
  notekey = loc_bits[3]
757
758
  }
@@ -759,11 +760,9 @@ exports.get_award_loc_from_results = function (connection, loc_bits) {
759
760
  let obj
760
761
  if (connection.transaction) obj = connection.transaction.results.get(pi_name)
761
762
 
762
- // connection.logdebug(this, `no txn results: ${pi_name}`);
763
763
  if (!obj) obj = connection.results.get(pi_name)
764
764
  if (!obj) return
765
765
 
766
- // connection.logdebug(this, `found results for ${pi_name}, ${notekey}`);
767
766
  if (notekey) return obj[notekey]
768
767
  return obj
769
768
  }
@@ -832,7 +831,7 @@ exports.check_awards = function (connection) {
832
831
  // test the desired condition
833
832
  const bits = award_terms.split(/\s+/)
834
833
  const award = parseFloat(bits[0])
835
- if (!bits[1] || bits[1] !== 'if') {
834
+ if (bits[1] !== 'if') {
836
835
  // no if conditions
837
836
  if (!note) continue // failed truth test
838
837
  if (!wants) {
@@ -844,8 +843,6 @@ exports.check_awards = function (connection) {
844
843
  if (note !== wants) continue // didn't match
845
844
  }
846
845
 
847
- // connection.loginfo(this, `check_awards, case matching for: ${wants}`
848
-
849
846
  // the matching logic here is inverted, weeding out misses (continue)
850
847
  // Matches fall through (break) to the apply_award below.
851
848
  const condition = bits[2]
@@ -861,7 +858,6 @@ exports.check_awards = function (connection) {
861
858
  break
862
859
  case 'match':
863
860
  if (Array.isArray(note)) {
864
- // connection.logerror(this, 'matching an array');
865
861
  if (new RegExp(wants, 'i').test(note)) break
866
862
  }
867
863
  if (note.toString().match(new RegExp(wants, 'i'))) break
@@ -891,7 +887,6 @@ exports.check_awards = function (connection) {
891
887
  break
892
888
  }
893
889
  case 'in': // if in pass whitelisted
894
- // let list = bits[3];
895
890
  if (bits[4]) {
896
891
  wants = bits[4]
897
892
  }
@@ -915,25 +910,21 @@ exports.apply_award = function (connection, nl, award) {
915
910
  return
916
911
  }
917
912
 
918
- const bits = nl.split('@')
919
- nl = bits[0] // strip off @... if present
913
+ nl = nl.split('@')[0] // strip off @... if present
920
914
 
921
915
  connection.results.incr(this, { score: award })
922
916
  connection.logdebug(this, `applied ${nl}:${award}`)
923
917
 
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
918
+ let trimmed
919
+ if (nl.startsWith('notes.')) trimmed = nl.slice(6)
920
+ else if (nl.startsWith('results.')) trimmed = nl.slice(8)
921
+ else if (nl.startsWith('transaction.results.')) trimmed = nl.slice(20)
922
+ else trimmed = nl
932
923
 
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)
924
+ if (trimmed.startsWith('rcpt_to.')) trimmed = trimmed.slice(8)
925
+ else if (trimmed.startsWith('mail_from.')) trimmed = trimmed.slice(10)
926
+ else if (trimmed.startsWith('connect.')) trimmed = trimmed.slice(8)
927
+ else if (trimmed.startsWith('data.')) trimmed = trimmed.slice(5)
937
928
 
938
929
  if (award > 0) connection.results.add(this, { pass: trimmed })
939
930
  if (award < 0) connection.results.add(this, { fail: trimmed })
@@ -944,7 +935,6 @@ exports.check_spammy_tld = function (mail_from, connection) {
944
935
  if (mail_from.isNull()) return // null sender (bounce)
945
936
 
946
937
  const from_tld = mail_from.host.split('.').pop()
947
- // connection.logdebug(this, `from_tld: ${from_tld}`);
948
938
 
949
939
  const tld_penalty = parseFloat(this.cfg.spammy_tlds[from_tld] || 0)
950
940
  if (tld_penalty === 0) return
@@ -967,7 +957,7 @@ exports.assemble_note_obj = function (prefix, key) {
967
957
  const parts = key.split('.')
968
958
  while (parts.length > 0) {
969
959
  let next = parts.shift()
970
- if (phase_prefixes[next]) {
960
+ if (phase_prefixes.includes(next)) {
971
961
  next = `${next}.${parts.shift()}`
972
962
  }
973
963
  note = note[next]
@@ -976,45 +966,44 @@ exports.assemble_note_obj = function (prefix, key) {
976
966
  return note
977
967
  }
978
968
 
979
- exports.check_asn = function (connection, asnkey) {
969
+ exports.check_asn = async function (connection, asnkey) {
980
970
  if (!this.db) return
981
971
 
982
972
  const report_as = { name: this.name }
983
973
  if (this.cfg.asn.report_as) report_as.name = this.cfg.asn.report_as
984
974
 
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
- }
975
+ try {
976
+ const res = await this.db.hGetAll(asnkey)
993
977
 
994
- this.db.hIncrBy(asnkey, 'connections', 1)
995
- const asn_score = parseInt(res.good || 0) - (res.bad || 0)
978
+ if (res === null) {
979
+ const expire = (this.cfg.redis.expire_days || 60) * 86400 // days
980
+ this.init_asn(asnkey, expire)
981
+ return
982
+ }
996
983
 
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
- }
984
+ this.db.hIncrBy(asnkey, 'connections', 1)
985
+ const asn_score = Number(res.good || 0) - Number(res.bad || 0)
1005
986
 
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' })
987
+ if (asn_score) {
988
+ connection.results.add(report_as, { asn_score })
989
+ if (asn_score < -5) {
990
+ connection.results.add(report_as, { fail: 'asn:history' })
991
+ } else if (asn_score > 5) {
992
+ connection.results.add(report_as, { pass: 'asn:history' })
1011
993
  }
994
+ }
1012
995
 
1013
- connection.results.add(report_as, { emit: true })
1014
- })
1015
- .catch((err) => {
1016
- connection.results.add(this, { err })
1017
- })
996
+ if (Number(res.bad || 0) > 5 && Number(res.good || 0) === 0) {
997
+ connection.results.add(report_as, { fail: 'asn:all_bad' })
998
+ }
999
+ if (Number(res.good || 0) > 5 && Number(res.bad || 0) === 0) {
1000
+ connection.results.add(report_as, { pass: 'asn:all_good' })
1001
+ }
1002
+
1003
+ connection.results.add(report_as, { emit: true })
1004
+ } catch (err) {
1005
+ connection.results.add(this, { err })
1006
+ }
1018
1007
  }
1019
1008
 
1020
1009
  exports.init_ip = async function (dbkey, rip, expire) {
@@ -1042,3 +1031,8 @@ exports.init_asn = function (asnkey, expire) {
1042
1031
  .expire(asnkey, expire * 2) // keep ASN longer
1043
1032
  .exec()
1044
1033
  }
1034
+
1035
+ exports.stringToArray = (input) => {
1036
+ if (typeof input !== 'string') throw new Error('Input must be a string')
1037
+ return input.split(/[\s,;]+/).filter((item) => item) // split and remove empty items
1038
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haraka-plugin-karma",
3
- "version": "2.1.8",
3
+ "version": "2.3.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,