Appearance
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.confAdd 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:
| Variable | Default | |
|---|---|---|
CLACKS_RSPAMD_ENABLED | false | Rspamd replaces the built-in checks |
CLACKS_RSPAMD_HOST | rspamd | use 127.0.0.1 with host networking |
CLACKS_RSPAMD_SCAN_PORT | 11333 | |
CLACKS_RSPAMD_CONTROLLER_PORT | 11334 | learn endpoints |
CLACKS_RSPAMD_PASSWORD | — | Rspamd controller password (required for learning) |
CLACKS_RSPAMD_GREYLIST | false | see below |
CLACKS_RSPAMD_REDIS_HOST | — | set to include Rspamd's learning in backups |
CLACKS_RSPAMD_REDIS_PORT | 6379 |
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.