haraka-plugin-karma 2.3.0 → 2.4.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,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
4
4
 
5
5
  ### Unreleased
6
6
 
7
+ ### [2.4.0] - 2026-05-06
8
+
9
+ - extend spammy_tlds to support multi-label suffixes
10
+ - allow rspamd to deny(soft) image-only spam from specific ASNs
11
+
7
12
  ### [2.3.0] - 2026-05-05
8
13
 
9
14
  - allow rspamd to greylist image-only spam
@@ -11,12 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
11
16
 
12
17
  ### [2.2.0] - 2026-03-31
13
18
 
14
- #### Added
15
-
16
19
  - allow rspamd to greylist ASNs when SA & rspamd agree
17
-
18
- #### Changed
19
-
20
20
  - replace to_object with arrays
21
21
  - deps(all): updated to latest
22
22
  - style: ES2024 updates throughout
@@ -171,3 +171,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
171
171
  [2.1.8]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.1.8
172
172
  [2.2.0]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.2.0
173
173
  [2.3.0]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.3.0
174
+ [2.4.0]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.4.0
package/config/karma.ini CHANGED
@@ -63,20 +63,21 @@ 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.
66
+ [rspamd_deny]
67
+ ; allow rspamd to greylist (DENYSOFT) or reject (DENY) instead of karma intercepting
68
+ ; the denial. Only relevant when rspamd is NOT in [deny_excludes] plugins.
69
69
  ;
70
70
  ; asn[] = 55286 ; ASN numbers eligible for greylisting
71
71
  ; asn[] = 33182
72
- ; asn[] = 46717
72
+ ; asn[] = 15169
73
73
  ;
74
74
  ; spamassassin_score = SpamAssassin hits score must exceed this value
75
75
  ; spamassassin_score = 5
76
76
 
77
77
 
78
78
  [spammy_tlds]
79
- ; award negative karma to spammy TLDs
79
+ ; award negative karma to spammy TLDs or second-level domains
80
+ ; multi-label suffixes are supported, e.g.: sa.com=-3
80
81
  ; caution, awarding karma > msg_negative_limit may blacklist that TLD
81
82
  bid=-3
82
83
  biz=-2
package/index.js CHANGED
@@ -50,23 +50,15 @@ exports.load_karma_ini = function () {
50
50
  this.merge_redis_ini()
51
51
 
52
52
  const cfg = this.cfg
53
- if (cfg.deny?.hooks) {
54
- this.deny_hooks = this.stringToArray(cfg.deny.hooks)
55
- }
53
+ if (cfg.deny?.hooks) this.deny_hooks = this.stringToArray(cfg.deny.hooks)
56
54
 
57
55
  const e = cfg.deny_excludes
58
- if (e?.hooks) {
59
- this.deny_exclude_hooks = this.stringToArray(e.hooks)
60
- }
61
- if (e?.plugins) {
62
- this.deny_exclude_plugins = this.stringToArray(e.plugins)
63
- }
56
+ if (e?.hooks) this.deny_exclude_hooks = this.stringToArray(e.hooks)
57
+ if (e?.plugins) this.deny_exclude_plugins = this.stringToArray(e.plugins)
64
58
 
65
- if (cfg.result_awards) {
66
- this.preparse_result_awards()
67
- }
59
+ if (cfg.result_awards) this.preparse_result_awards()
68
60
 
69
- this.greylist_asns = cfg.greylist?.asn ?? []
61
+ this.rspamdDenyAsns = cfg.rspamd_deny?.asn ?? cfg.greylist?.asn ?? []
70
62
 
71
63
  if (!cfg.redis) cfg.redis = {}
72
64
  if (!cfg.redis.host && cfg.redis.server_ip) {
@@ -393,29 +385,24 @@ exports.should_we_skip = function (connection) {
393
385
  return false
394
386
  }
395
387
 
396
- exports.should_rspamd_greylist = function (connection) {
388
+ exports.should_rspamd_deny = function (connection) {
397
389
  // 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
390
+ const cr = connection.results
391
+ const asn = cr.get('asn')?.asn ?? cr.get('geoip')?.asn
392
+ if (!this.rspamdDenyAsns.includes(String(asn))) return false
402
393
 
403
394
  // check SpamAssassin score exceeds configured threshold
404
395
  const saScore = parseFloat(
405
396
  connection.transaction?.results?.get('spamassassin')?.hits ?? 0,
406
397
  )
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
398
+ const saThreshold = parseFloat(this.cfg.rspamd_deny?.spamassassin_score ?? 5)
399
+ if (saScore >= saThreshold) return true // SA & rspamd agree
411
400
 
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
401
+ const gGroups = connection.transaction.header.get('X-Google-Group-Id')
402
+ const imgOnly = connection.transaction.header
403
+ .get('content-type')
404
+ ?.startsWith('multipart/related')
405
+ if (gGroups && imgOnly) return true
419
406
 
420
407
  return false
421
408
  }
@@ -445,10 +432,7 @@ exports.should_we_deny = function (next, connection, hook) {
445
432
  return this.apply_tarpit(connection, hook, score, next)
446
433
  }
447
434
 
448
- let rejectMsg = 'very bad karma score: {score}'
449
- if (this.cfg.deny && this.cfg.deny.message) {
450
- rejectMsg = this.cfg.deny.message
451
- }
435
+ let rejectMsg = this.cfg.deny?.message ?? `very bad karma score: ${score}`
452
436
 
453
437
  if (/\{/.test(rejectMsg)) {
454
438
  rejectMsg = rejectMsg.replace(/\{score\}/, score)
@@ -463,18 +447,13 @@ exports.should_we_deny = function (next, connection, hook) {
463
447
  exports.hook_deny = function (next, connection, params) {
464
448
  if (this.should_we_skip(connection)) return next()
465
449
 
466
- const [pi_rc, , pi_name, , , pi_hook] = params
450
+ const [_pi_rc, , pi_name, , , pi_hook] = params
467
451
 
468
452
  // exceptions, whose 'DENY' should not be captured
469
453
  if (pi_name) {
470
454
  if (pi_name === 'karma') return next()
471
455
  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
- )
456
+ if (pi_name === 'rspamd' && this.should_rspamd_deny(connection))
478
457
  return next()
479
458
  }
480
459
  if (pi_hook && this.deny_exclude_hooks.includes(pi_hook)) return next()
@@ -934,13 +913,17 @@ exports.check_spammy_tld = function (mail_from, connection) {
934
913
  if (!this.cfg.spammy_tlds) return
935
914
  if (mail_from.isNull()) return // null sender (bounce)
936
915
 
937
- const from_tld = mail_from.host.split('.').pop()
916
+ const labels = mail_from.host.split('.')
938
917
 
939
- const tld_penalty = parseFloat(this.cfg.spammy_tlds[from_tld] || 0)
940
- if (tld_penalty === 0) return
918
+ for (let i = 0; i < labels.length; i++) {
919
+ const suffix = labels.slice(i).join('.')
920
+ const tld_penalty = parseFloat(this.cfg.spammy_tlds[suffix] || 0)
921
+ if (tld_penalty === 0) continue
941
922
 
942
- connection.results.incr(this, { score: tld_penalty })
943
- connection.results.add(this, { fail: 'spammy.TLD' })
923
+ connection.results.incr(this, { score: tld_penalty })
924
+ connection.results.add(this, { fail: 'spammy.TLD' })
925
+ return
926
+ }
944
927
  }
945
928
 
946
929
  exports.check_syntax_RcptTo = function (connection) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haraka-plugin-karma",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "A heuristics scoring and reputation engine for SMTP connections",
5
5
  "main": "index.js",
6
6
  "files": [