Skip to content

Spam filtering & antivirus

Clacks works alone: inbound mail is checked with SPF, DKIM, DMARC, and DNSBLs, scored, and filed to Junk past a threshold. Two optional sidecars raise the bar considerably. Both fail open — if a sidecar is down, mail keeps flowing.

ClamAV (antivirus)

A ClamAV sidecar scans every inbound message. A detection is rejected at SMTP with a 550 — the message never touches storage, and the sending server bounces it to the real sender.

yaml
services:
  clacks:
    # …
    environment:
      CLACKS_ANTIVIRUS_ENABLED: "true"
      CLACKS_ANTIVIRUS_HOST: "127.0.0.1"
      CLACKS_ANTIVIRUS_PORT: "3310"

  clamav:
    image: clamav/clamav:stable
    restart: unless-stopped
    ports:
      - "127.0.0.1:3310:3310"     # loopback only — not exposed publicly
    volumes:
      - clamav-db:/var/lib/clamav

volumes:
  clamav-db:

Resources

ClamAV wants 1–2 GB of RAM and downloads its ~1 GB signature database on first boot (a minute or two). The volume keeps it across restarts.

Verify it end to end: email yourself the EICAR test string from an external account — the sender should get a 550 … Eicar-Test-Signature bounce.

Rspamd (spam filtering that learns)

When enabled, Rspamd replaces the built-in checks and becomes the spam authority. Spam is only ever filed to Junk — never bounced. The key feature is bidirectional learning:

  • Move a message into Junk → trained as spam.
  • Move a message out of Junk → trained as ham.
  • Deleting spam (Junk → Trash) trains nothing.

This works from the webmail and any IMAP client, since they all do folder moves. Training is stored in Redis, so it survives restarts.

Rspamd needs three companions: Redis (persists learning), Unbound (a local DNS resolver — public resolvers are blocked by the DNSBLs Rspamd uses), and — for the strongest blocklist data — a free Spamhaus DQS key (sign up at spamhaus.com).

All Rspamd configuration lives inline in the compose file's top-level configs: block — no external .conf files to mount. This stack runs on Bayes, SPF/DKIM/DMARC, and fuzzy hashing, and works without a DQS key:

yaml
services:
  clacks:
    # …existing clacks service; add the Rspamd env vars…
    environment:
      CLACKS_RSPAMD_ENABLED: "true"
      CLACKS_RSPAMD_HOST: "127.0.0.1"                 # loopback (host networking)
      CLACKS_RSPAMD_SCAN_PORT: "11333"
      CLACKS_RSPAMD_CONTROLLER_PORT: "11334"
      CLACKS_RSPAMD_PASSWORD: "${RSPAMD_PASSWORD}"    # also set in the controller config below
      CLACKS_RSPAMD_REDIS_HOST: "127.0.0.1"           # include learning in backups
      CLACKS_RSPAMD_REDIS_PORT: "6379"

  rspamd:
    image: rspamd/rspamd:latest
    restart: unless-stopped
    depends_on: [redis, unbound]
    ports:
      - "127.0.0.1:11333:11333"   # scanner
      - "127.0.0.1:11334:11334"   # controller (learning)
    volumes:
      - rspamd-data:/var/lib/rspamd
    configs:                       # inline content, defined under `configs:` below
      - source: rspamd_redis
        target: /etc/rspamd/local.d/redis.conf
      - source: rspamd_controller
        target: /etc/rspamd/local.d/worker-controller.inc
      - source: rspamd_normal
        target: /etc/rspamd/local.d/worker-normal.inc
      - source: rspamd_options
        target: /etc/rspamd/local.d/options.inc

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    ports:
      - "127.0.0.1:6379:6379"     # loopback — reached by clacks for backups
    volumes:
      - redis-data:/data

  unbound:                         # local resolver; the DNSBLs block public ones
    image: mvance/unbound:latest
    restart: unless-stopped

configs:
  rspamd_redis:
    content: |
      servers = "redis:6379";
  rspamd_controller:
    content: |
      bind_socket = "*:11334";
      password = "${RSPAMD_PASSWORD}";
  rspamd_normal:
    content: |
      bind_socket = "*:11333";
  rspamd_options:
    content: |
      dns {
        nameserver = ["unbound:53"];
      }

volumes:
  rspamd-data:
  redis-data:

For the strongest blocklist data, wire in a free Spamhaus DQS key — also inline. Add two mounts to the rspamd service's configs: list and two matching entries to the top-level configs: block; ${SPAMHAUS_DQS_KEY} is substituted from your environment. Without a DQS key, leave these out and run on Bayes + SPF/DKIM/DMARC + fuzzy.

Spamhaus DQS blocklist config (inline)

Add to the rspamd service's configs: list:

yaml
      - source: rspamd_rbl
        target: /etc/rspamd/local.d/rbl.conf
      - source: rspamd_rbl_group
        target: /etc/rspamd/local.d/rbl_group.conf

Add to the top-level configs: block:

yaml
  rspamd_rbl:
    content: |
      rbls {
          spamhaus {
              # De-duped: spamhaus_from (SPAMHAUS_ZEN) already scores the connecting IP;
              # disable this stock rule's granular RBL_SPAMHAUS_* to avoid double-counting.
              enabled = false;
          }
          spamhaus_from {
              from = true;
              received = false;
              rbl = "${SPAMHAUS_DQS_KEY}.zen.dq.spamhaus.net";
              returncodes {
                  # Add a generic hit on ZEN. It's safe to tag as spam if the last untrusted relay is in this combined list
                  #SPAMHAUS_ZEN_SBL = "127.0.0.2";
                  #SPAMHAUS_ZEN_CSS = "127.0.0.3";
                  #SPAMHAUS_ZEN_XBL = [ "127.0.0.4", "127.0.0.5", "127.0.0.6", "127.0.0.7" ];
                  #SPAMHAUS_ZEN_PBL = [ "127.0.0.10", "127.0.0.11" ];
                  SPAMHAUS_ZEN = [ "127.0.0.2", "127.0.0.3", "127.0.0.4", "127.0.0.5", "127.0.0.6", "127.0.0.7", "127.0.0.9", "127.0.0.10", "127.0.0.11" ];
              }
          }
          spamhaus_authbl_received {
              # Check if the sender client is listed in AuthBL (AuthBL is *not* part of ZEN)
              rbl = "${SPAMHAUS_DQS_KEY}.authbl.dq.spamhaus.net";
              from = false;
              received = true;
              ipv6 = true;
              returncodes {
                  SH_AUTHBL_RECEIVED = "127.0.0.20"
              }
          }
          spamhaus_dbl {
              # Add checks on the HELO string
              rbl = "${SPAMHAUS_DQS_KEY}.dbl.dq.spamhaus.net";
              helo = true;
              rdns = true;
              dkim = true;
              disable_monitoring = true;
              returncodes {
                  RBL_DBL_SPAM = "127.0.1.2";
                  RBL_DBL_PHISH = "127.0.1.4";
                  RBL_DBL_MALWARE = "127.0.1.5";
                  RBL_DBL_BOTNET = "127.0.1.6";
                  RBL_DBL_ABUSED_SPAM = "127.0.1.102";
                  RBL_DBL_ABUSED_PHISH = "127.0.1.104";
                  RBL_DBL_ABUSED_MALWARE = "127.0.1.105";
                  RBL_DBL_ABUSED_BOTNET = "127.0.1.106";
                  RBL_DBL_DONT_QUERY_IPS = "127.0.1.255";
              }
          }
          spamhaus_dbl_fullurls {
              ignore_defaults = true;
              no_ip = true;
              rbl = "${SPAMHAUS_DQS_KEY}.dbl.dq.spamhaus.net";
              selector = 'urls:get_host'
              disable_monitoring = true;
              returncodes {
                  DBLABUSED_SPAM_FULLURLS = "127.0.1.102";
                  DBLABUSED_PHISH_FULLURLS = "127.0.1.104";
                  DBLABUSED_MALWARE_FULLURLS = "127.0.1.105";
                  DBLABUSED_BOTNET_FULLURLS = "127.0.1.106";
              }
          }
          spamhaus_zrd {
              # Add checks on the HELO string also for DQS
              rbl = "${SPAMHAUS_DQS_KEY}.zrd.dq.spamhaus.net";
              helo = true;
              rdns = true;
              dkim = true;
              disable_monitoring = true;
              returncodes {
                  RBL_ZRD_VERY_FRESH_DOMAIN = ["127.0.2.2", "127.0.2.3", "127.0.2.4"];
                  RBL_ZRD_FRESH_DOMAIN = [
                      "127.0.2.5", "127.0.2.6", "127.0.2.7", "127.0.2.8", "127.0.2.9", "127.0.2.10", "127.0.2.11", "127.0.2.12", "127.0.2.13", "127.0.2.14", "127.0.2.15", "127.0.2.16", "127.0.2.17", "127.0.2.18", "127.0.2.19", "127.0.2.20", "127.0.2.21", "127.0.2.22", "127.0.2.23", "127.0.2.24"
                  ];
                  RBL_ZRD_DONT_QUERY_IPS = "127.0.2.255";
              }
          }
          "SPAMHAUS_ZEN_URIBL" {
              enabled = true;
              rbl = "${SPAMHAUS_DQS_KEY}.zen.dq.spamhaus.net";
              resolve_ip = true;
              checks = ['urls'];
              replyto = true;
              emails = true;
              ipv4 = true;
              ipv6 = true;
              emails_domainonly = true;
              returncodes {
                  URIBL_SBL = "127.0.0.2";
                  URIBL_SBL_CSS = "127.0.0.3";
                  URIBL_XBL = ["127.0.0.4", "127.0.0.5", "127.0.0.6", "127.0.0.7"];
                  URIBL_PBL = ["127.0.0.10", "127.0.0.11"];
                  URIBL_DROP = "127.0.0.9";
              }
          }
          SH_EMAIL_DBL {
              ignore_defaults = true;
              replyto = true;
              emails_domainonly = true;
              disable_monitoring = true;
              rbl = "${SPAMHAUS_DQS_KEY}.dbl.dq.spamhaus.net"
              returncodes = {
                  SH_EMAIL_DBL = [
                      "127.0.1.2",
                      "127.0.1.4",
                      "127.0.1.5",
                      "127.0.1.6"
                  ];
                  SH_EMAIL_DBL_ABUSED = [
                      "127.0.1.102",
                      "127.0.1.104",
                      "127.0.1.105",
                      "127.0.1.106"
                  ];
                  SH_EMAIL_DBL_DONT_QUERY_IPS = [ "127.0.1.255" ];
              }
          }
          SH_EMAIL_ZRD {
              ignore_defaults = true;
              replyto = true;
              emails_domainonly = true;
              disable_monitoring = true;
              rbl = "${SPAMHAUS_DQS_KEY}.zrd.dq.spamhaus.net"
              returncodes = {
                  SH_EMAIL_ZRD_VERY_FRESH_DOMAIN = ["127.0.2.2", "127.0.2.3", "127.0.2.4"];
                  SH_EMAIL_ZRD_FRESH_DOMAIN = [
                      "127.0.2.5", "127.0.2.6", "127.0.2.7", "127.0.2.8", "127.0.2.9", "127.0.2.10", "127.0.2.11", "127.0.2.12", "127.0.2.13", "127.0.2.14", "127.0.2.15", "127.0.2.16", "127.0.2.17", "127.0.2.18", "127.0.2.19", "127.0.2.20", "127.0.2.21", "127.0.2.22", "127.0.2.23", "127.0.2.24"
                  ];
                  SH_EMAIL_ZRD_DONT_QUERY_IPS = [ "127.0.2.255" ];
              }
          }
          "DBL" {
              # Override the defaults for DBL defined in modules.d/rbl.conf
              rbl = "${SPAMHAUS_DQS_KEY}.dbl.dq.spamhaus.net";
              disable_monitoring = true;
          }
          "ZRD" {
              ignore_defaults = true;
              rbl = "${SPAMHAUS_DQS_KEY}.zrd.dq.spamhaus.net";
              no_ip = true;
              dkim = true;
              emails = true;
              emails_domainonly = true;
              urls = true;
              returncodes = {
                  ZRD_VERY_FRESH_DOMAIN = ["127.0.2.2", "127.0.2.3", "127.0.2.4"];
                  ZRD_FRESH_DOMAIN = ["127.0.2.5", "127.0.2.6", "127.0.2.7", "127.0.2.8", "127.0.2.9", "127.0.2.10", "127.0.2.11", "127.0.2.12", "127.0.2.13", "127.0.2.14", "127.0.2.15", "127.0.2.16", "127.0.2.17", "127.0.2.18", "127.0.2.19", "127.0.2.20", "127.0.2.21", "127.0.2.22", "127.0.2.23", "127.0.2.24"];
              }
          }
          spamhaus_sbl_url {
              ignore_defaults = true
              rbl = "${SPAMHAUS_DQS_KEY}.sbl.dq.spamhaus.net";
              checks = ['urls'];
              disable_monitoring = true;
              returncodes {
                  SPAMHAUS_SBL_URL = "127.0.0.2";
              }
          }
      # Disabled: public RBLs that need their own registration (blocked & noisy).
      dnswl { enabled = false; }
      dnswl_dwl { enabled = false; }
      SURBL_MULTI { enabled = false; }
      SURBL_HASHBL { enabled = false; }
      URIBL_MULTI { enabled = false; }
      }
  rspamd_rbl_group:
    content: |
      symbols = {
          "SPAMHAUS_ZEN" {
              weight = 7.0;
              one_shot = true;
          }
          "SH_AUTHBL_RECEIVED" {
              weight = 4.0;
          }
          "RBL_DBL_SPAM" {
              weight = 7.0;
          }
          "RBL_DBL_PHISH" {
              weight = 7.0;
          }
          "RBL_DBL_MALWARE" {
              weight = 7.0;
          }
          "RBL_DBL_BOTNET" {
              weight = 7.0;
          }
          "RBL_DBL_ABUSED_SPAM" {
              weight = 3.0;
          }
          "RBL_DBL_ABUSED_PHISH" {
              weight = 3.0;
          }
          "RBL_DBL_ABUSED_MALWARE" {
              weight = 3.0;
          }
          "RBL_DBL_ABUSED_BOTNET" {
              weight = 3.0;
          }
          "RBL_ZRD_VERY_FRESH_DOMAIN" {
              weight = 7.0;
          }
          "RBL_ZRD_FRESH_DOMAIN" {
              weight = 4.0;
          }
          "ZRD_VERY_FRESH_DOMAIN" {
              weight = 7.0;
          }
          "ZRD_FRESH_DOMAIN" {
              weight = 4.0;
          }
          "SH_EMAIL_DBL" {
              weight = 7.0;
          }
          "SH_EMAIL_DBL_ABUSED" {
              weight = 7.0;
          }
          "SH_EMAIL_ZRD_VERY_FRESH_DOMAIN" {
              weight = 7.0;
          }
          "SH_EMAIL_ZRD_FRESH_DOMAIN" {
              weight = 4.0;
          }
          "RBL_DBL_DONT_QUERY_IPS" {
              weight = 0.0;
          }
          "RBL_ZRD_DONT_QUERY_IPS" {
              weight = 0.0;
          }
          "SH_EMAIL_ZRD_DONT_QUERY_IPS" {
              weight = 0.0;
          }
          "SH_EMAIL_DBL_DONT_QUERY_IPS" {
              weight = 0.0;
          }
          "DBL" {
              weight = 0.0;
              description = "DBL unknown result";
              groups = ["spamhaus"];
          }
          "DBL_SPAM" {
              weight = 7;
              description = "DBL uribl spam";
              groups = ["spamhaus"];
          }
          "DBL_PHISH" {
              weight = 7;
              description = "DBL uribl phishing";
              groups = ["spamhaus"];
          }
          "DBL_MALWARE" {
              weight = 7;
              description = "DBL uribl malware";
              groups = ["spamhaus"];
          }
          "DBL_BOTNET" {
              weight = 7;
              description = "DBL uribl botnet C&C domain";
              groups = ["spamhaus"];
          }

          "DBLABUSED_SPAM_FULLURLS" {
              weight = 5.5;
              description = "DBL uribl abused legit spam";
              groups = ["spamhaus"];
          }
          "DBLABUSED_PHISH_FULLURLS" {
              weight = 5.5;
              description = "DBL uribl abused legit phish";
              groups = ["spamhaus"];
          }
          "DBLABUSED_MALWARE_FULLURLS" {
              weight = 5.5;
              description = "DBL uribl abused legit malware";
              groups = ["spamhaus"];
          }
          "DBLABUSED_BOTNET_FULLURLS" {
              weight = 5.5;
              description = "DBL uribl abused legit botnet";
              groups = ["spamhaus"];
          }

          "DBL_ABUSE" {
              weight = 5.5;
              description = "DBL uribl abused legit spam";
              groups = ["spamhaus"];
          }
          "DBL_ABUSE_REDIR" {
              weight = 1.5;
              description = "DBL uribl abused spammed redirector domain";
              groups = ["spamhaus"];
          }
          "DBL_ABUSE_PHISH" {
              weight = 5.5;
              description = "DBL uribl abused legit phish";
              groups = ["spamhaus"];
          }
          "DBL_ABUSE_MALWARE" {
              weight = 5.5;
              description = "DBL uribl abused legit malware";
              groups = ["spamhaus"];
          }
          "DBL_ABUSE_BOTNET" {
              weight = 5.5;
              description = "DBL uribl abused legit botnet C&C";
              groups = ["spamhaus"];
          }
          "DBL_PROHIBIT" {
              weight = 0.0;
              description = "DBL uribl IP queries prohibited!";
              groups = ["spamhaus"];
          }
          "DBL_BLOCKED_OPENRESOLVER" {
              weight = 0.0;
              description = "You are querying Spamhaus from an open resolver, please see https://www.spamhaus.org/returnc/pub/";
              groups = ["spamhaus"];
          }
          "DBL_BLOCKED" {
              weight = 0.0;
              description = "You are exceeding the query limit, please see https://www.spamhaus.org/returnc/vol/";
              groups = ["spamhaus"];
          }
          "SPAMHAUS_ZEN_URIBL" {
              weight = 0.0;
              description = "Spamhaus ZEN URIBL: Filtered result";
              groups = ["spamhaus"];
          }
          "URIBL_SBL" {
              weight = 6.5;
              description = "A domain in the message body resolves to an IP listed in Spamhaus SBL";
              one_shot = true;
              groups = ["spamhaus"];
          }
          "URIBL_SBL_CSS" {
              weight = 6.5;
              description = "A domain in the message body resolves to an IP listed in Spamhaus SBL CSS";
              one_shot = true;
              groups = ["spamhaus"];
          }
          "URIBL_PBL" {
              weight = 0.01;
              description = "A domain in the message body resolves to an IP listed in Spamhaus PBL";
              one_shot = true;
              groups = ["spamhaus"];
          }
          "URIBL_DROP" {
              weight = 6.5;
              description = "A domain in the message body resolves to an IP listed in Spamhaus DROP";
              one_shot = true;
              groups = ["spamhaus"];
          }
          "URIBL_XBL" {
              weight = 5.0;
              description = "A domain in the message body resolves to an IP listed in Spamhaus XBL";
              one_shot = true;
              groups = ["spamhaus"];
          }
          "SPAMHAUS_SBL_URL" {
              weight = 6.5;
              description = "A numeric URL in the message body is listed in Spamhaus SBL";
              one_shot = true;
              groups = ["spamhaus"];
          }
      }

The clacks side of it:

VariableDefault
CLACKS_RSPAMD_ENABLEDfalseRspamd replaces the built-in checks
CLACKS_RSPAMD_HOSTrspamduse 127.0.0.1 with host networking
CLACKS_RSPAMD_SCAN_PORT11333
CLACKS_RSPAMD_CONTROLLER_PORT11334learn endpoints
CLACKS_RSPAMD_PASSWORDRspamd controller password (required for learning)
CLACKS_RSPAMD_GREYLISTfalsesee below
CLACKS_RSPAMD_REDIS_HOSTset to include Rspamd's learning in backups
CLACKS_RSPAMD_REDIS_PORT6379

Greylisting

With CLACKS_RSPAMD_GREYLIST=true, a suspicious first-time sender gets a 451 temporary defer. Real mail servers retry within minutes and get through; most spam cannons never retry. No mail is lost — but first-contact mail from borderline senders is delayed, so it's off by default.

Expect a training period

Bayesian filtering starts empty. For the first while, rely on the rule engine and blocklists, and file misclassified mail into/out of Junk — it sharpens quickly. Seed it by dragging a batch of known spam into Junk on day one.

Seeing why a message scored

Open any message in the webmail and press </> Source — the raw message includes X-Spam-Score, X-Spam-Action, and X-Spam-Status headers showing exactly what the filter decided.

Licensed under AGPL-3.0 · GNU Terry Pratchett