Hi Remo,
Looks interesting, please keep us posted how things work out
/Finn
Den 28.04.2026 kl. 02.11 skrev Remo Mattei:
I deployed rspamd-driven AI spam scoring across two of my qmail-toaster
mail servers over the weekend and wanted to share the working
configuration in case anyone else wants to try it.
Important version note: this only works on rspamd 4.x. The GPT module in
3.x (what qmt-testing currently ships) does not have the necessary
plugin code. I had to swap qmt-testing's |rspamd-3.2-1| for upstream |
rspamd-4.0.1-1.el9| and pin it via |dnf versionlock| to prevent the QMT
repo from reclaiming it on the next update.
Architecture:
+------------------------------------+
| Ollama LLM backend |
| Ubuntu 24.04, bare metal, CPU |
| qwen2.5:3b-instruct (default) |
| qwen2.5:7b-instruct (fallback) |
+------------------+-----------------+
|
HTTP /v1/chat/completions (LAN only)
|
+------------------------+------------------------+
| |
v v
+----------------------------+
+----------------------------+
| Mail server 1 | | Mail server 2
|
| Rocky 9 VM on Proxmox | | Rocky 9 VM on Proxmox
|
| | |
|
| qmail toaster | | qmail toaster
|
| simscan + ClamAV + SA | | simscan + ClamAV + SA
|
| rspamd 4.0.1 (locked) | | rspamd 4.0.1 (locked)
|
| named (recursive) | | named (recursive)
|
| Redis (Bayes + cache) | | Redis (Bayes + cache)
|
| systemd-timer healthcheck | | systemd-timer
healthcheck |
+----------------------------+
+----------------------------+
Files Changed — AI Spam Scoring Deployment
Complete list of every file created or modified across my mail
infrastructure during the deployment. Use this as a checklist when
replicating to a new mail server or as a reference when something
needs to be restored.
Paths assume Rocky 9 for mail servers and Ubuntu 24.04 for the Ollama box.
Hostnames in this document use generic placeholders:
* |server-ollama| — the bare-metal box running Ollama
* |mail1.qmail1.com| — first mail server (example domain |qmail1.com|)
* |mail2.qmail2.org| — second mail server (example domain |qmail2.org|)
Substitute your own hostnames and IPs when applying.
------------------------------------------------------------------------
server-ollama (Ollama LLM backend)
|/etc/systemd/system/ollama.service.d/override.conf| — CREATED
Tunes Ollama to listen on the LAN, keep models warm, and accept
multiple concurrent requests from mail servers fanning in.
ini
|[Service] Environment="OLLAMA_HOST=0.0.0.0:11434"
Environment="OLLAMA_KEEP_ALIVE=24h"
Environment="OLLAMA_MAX_LOADED_MODELS=2"
Environment="OLLAMA_NUM_PARALLEL=4" Environment="OLLAMA_MODELS=/usr/
share/ollama/.ollama/models"|
Apply with:
bash
|systemctl daemon-reload systemctl restart ollama|
Ollama models pulled
Not files per se, but worth listing:
bash
|ollama pull qwen2.5:3b-instruct-q4_K_M # default, ~2 GB ollama pull
qwen2.5:7b-instruct-q4_K_M # fallback, ~4.7 GB|
Stored under |/usr/share/ollama/.ollama/models/|.
------------------------------------------------------------------------
Mail servers (mail1.qmail1.com AND mail2.qmail2.org)
Both servers got identical changes. Only difference: |
mail2.qmail2.org| had some extra cloud-init drop-ins because it was
provisioned via cloud-init.
|/etc/resolv.conf| — REPLACED
Strip public resolvers, point at local recursive |named|. Required so
DNSBL queries (Spamhaus, URIBL, etc.) actually return real answers
instead of sinkhole responses from blocked public resolvers.
|search qmail1.com nameserver 127.0.0.1|
(For the second server: |search qmail2.org| instead.)
Backup of original was saved as |/etc/resolv.conf.bak.YYYY-MM-DD|.
|/etc/hosts| — APPENDED
Added entry for the Ollama host so rspamd can resolve it.
|192.168.1.14 server-ollama|
|/etc/yum.repos.d/rspamd.repo| — CREATED
Upstream rspamd repo. The QMT-testing repo only ships rspamd 3.x which
lacks the GPT module entirely. We pulled 4.0.1 from upstream.
ini
|[rspamd] name=Rspamd stable repository baseurl=https://rspamd.com/
rpm-stable/centos-9/$basearch/ enabled=1 gpgcheck=0 repo_gpgcheck=0|
|gpgcheck=0| is intentional — Rocky 9's default crypto policy rejects
the SHA-1 GPG key Rspamd ships. The repo is HTTPS so it's TLS-
protected in transit.
|/etc/dnf/protected.d/rspamd.conf| — CREATED
Prevents |dnf| from removing rspamd accidentally during a future upgrade.
|rspamd|
dnf versionlock — APPLIED
bash
|dnf install -y python3-dnf-plugin-versionlock dnf versionlock add
'rspamd-4.0.1-1.el9'|
Pins rspamd to 4.0.1 so the QMT-testing repo can't reclaim it on the
next |dnf update|. Stored in |/etc/dnf/plugins/versionlock.list|.
|/etc/rspamd/local.d/options.inc| — CREATED
Bumps rspamd's per-task timeout from 8 s (default) to 30 s. Required
because real-world inbound (URI-heavy newsletters, marketing mail)
consumes DNS budget on URIBL/SURBL lookups before the GPT module runs;
the AI then runs out of time on the default budget.
|task_timeout = 30s;|
|/etc/rspamd/local.d/gpt.conf| — CREATED
The main AI scoring config. Identical contents on both mail servers.
Substitute your Ollama host where |server-ollama|appears.
|enabled = true; type = "ollama"; url = "http://server-ollama:11434/
v1/chat/completions"; model = "qwen2.5:3b-instruct-q4_K_M"; timeout =
15s; min_words = 0; allow_passthrough = true; allow_ham = true;
autolearn = true; json = true; include_response_format = true;
reason_header = "X-GPT-Reason"; prompt = "You are an email spam
classifier. Analyze the email and respond with ONLY a single JSON
object, no other text. Schema: {\"probability\": <number 0.0-1.0>,
\"reason\": \"<short reason in one sentence>\"}. probability is your
estimate that the email is spam: 0.0 means definitely ham/legitimate,
1.0 means definitely spam. Examples: {\"probability\": 0.05,
\"reason\": \"personal correspondence with no spam indicators\"} or
{\"probability\": 0.92, \"reason\": \"mass marketing with urgent call-
to-action\"}. Respond with ONLY the JSON object."; model_parameters
{ "qwen2.5:3b-instruct-q4_K_M" { max_tokens = 200; temperature = 0.0; } }|
Key gotchas baked into this config:
* |min_words = 0| — overrides the default of 10 which rejects short
messages.
* |json = true| + |include_response_format = true| — forces strict
JSON output.
* The custom |prompt| is critical. The default rspamd prompt asks
the LLM for a "Numeric score" but the parser expects a JSON object
with a |probability| field. Without this override the AI will
respond but rspamd can't parse it.
* No custom |condition| function — the plugin default works fine. If
you add one, it must return |(true, content, sel_part)|, not just
|true|.
|/etc/rspamd/local.d/rbl.conf| — EDITED (mail1 only)
The QMT-shipped |rbl.conf| had a malformed SPAMHAUS_PBL block (|map =|
instead of |rbl =|, plus extra fields rspamd 4.x rejects). Fixed to:
hocon
|rbls { interserver_ip { symbol = "RBL_INTERSERVER_IP"; rbl =
"rbl.interserver.net"; from = true; ipv6 = false; ipv4 = true;
returncodes { RBL_INTERSERVER_BAD_IP = "127.0.0.2"; } }
interserver_uri { symbol = "RBL_INTERSERVER_URI"; rbl =
"rbluri.interserver.net"; ignore_defaults = true; no_ip = true; dkim =
true; emails = true; urls = true; returncodes =
{ RBL_INTERSERVER_BAD_URI = "127.0.0.2"; action = "reject"; } }
spamhaus_pbl { symbol = "SPAMHAUS_PBL_BLACKLIST"; rbl =
"pbl.spamhaus.org"; ipv4 = true; ipv6 = false; from = true; } }|
(|mail2.qmail2.org| did not have this issue — its rbl.conf was already
valid.)
|/usr/local/bin/rspamd-gpt-healthcheck| — CREATED
Auto-failover script. Runs every 2 minutes via systemd timer. Disables
the GPT module if Ollama is unreachable; re-enables when it returns.
bash
|#!/bin/bash GPT_CONF=/etc/rspamd/local.d/gpt.conf OLLAMA_URL="http://
server-ollama:11434/api/tags" LOCK=/run/rspamd-gpt-state mkdir -p
"$(dirname"$LOCK")" if curl -sf --max-time 3 "$OLLAMA_URL" > /dev/null
2>&1; then if [[ -f "$LOCK" && "$(cat"$LOCK")" == "disabled" ]]; then
sed -i 's/^enabled = false;/enabled = true;/' "$GPT_CONF" systemctl
reload rspamd echo enabled > "$LOCK" logger -t rspamd-gpt-health
"Ollama back, GPT re-enabled" fi [[ ! -f "$LOCK" ]] && echo enabled >
"$LOCK" else if [[ ! -f "$LOCK" || "$(cat"$LOCK")" == "enabled" ]];
then sed -i 's/^enabled = true;/enabled = false;/' "$GPT_CONF"
systemctl reload rspamd echo disabled > "$LOCK" logger -t rspamd-gpt-
health "Ollama unreachable, GPT disabled" fi fi|
|chmod +x| after creating.
|/etc/systemd/system/rspamd-gpt-healthcheck.service| — CREATED
ini
|[Unit] Description=rspamd GPT module health check
After=rspamd.service network-online.target [Service] Type=oneshot
ExecStart=/usr/local/bin/rspamd-gpt-healthcheck|
|/etc/systemd/system/rspamd-gpt-healthcheck.timer| — CREATED
ini
|[Unit] Description=Run rspamd GPT health check every 2 minutes
[Timer] OnBootSec=2min OnUnitActiveSec=2min Persistent=true [Install]
WantedBy=timers.target|
Enable with:
bash
|systemctl daemon-reload systemctl enable --now rspamd-gpt-
healthcheck.timer|
|/etc/cron.weekly/gpt-baseline| — CREATED
Weekly snapshot of GPT verdict counts to |/var/log/gpt-baseline.txt|.
Useful for trend tracking.
bash
|#!/bin/bash { date grep -oE 'GPT_(SPAM|HAM|MARKETING|PHISHING|
UNCERTAIN)' /var/log/rspamd/rspamd.log | \ sort | uniq -c | sort -rn
echo "---" } >> /var/log/gpt-baseline.txt|
|chmod +x| after creating.
------------------------------------------------------------------------
mail2.qmail2.org only — cloud-init overrides
|mail2.qmail2.org| was provisioned via cloud-init. Without these
overrides cloud-init regenerates |/etc/hosts| and networking on boot,
wiping our changes.
|/etc/cloud/cloud.cfg.d/99-disable-network-config.cfg| — CREATED
yaml
|network: {config: disabled}|
|/etc/cloud/cloud.cfg.d/99-disable-hosts-mgmt.cfg| — CREATED
yaml
|manage_etc_hosts: false|
NetworkManager DNS settings — MODIFIED
bash
|nmcli con mod "System eth0" ipv4.dns "127.0.0.1" nmcli con mod
"System eth0" ipv4.ignore-auto-dns yes nmcli con mod ens19 ipv4.dns ""
nmcli con mod ens19 ipv4.ignore-auto-dns yes|
These updates persist in |/etc/NetworkManager/system-connections/
*.nmconnection|.
|/etc/sysconfig/network-scripts/ifcfg-eth0| — EDITED
Removed legacy |DNS1=|, |DNS2=|, |DNS3=| lines that conflicted with
NetworkManager's own settings.
|/etc/NetworkManager/conf.d/99-cloud-init.conf| — RENAMED
Renamed to |99-cloud-init.conf.disabled| so cloud-init's |dns = none|
directive no longer prevents NetworkManager from regenerating |
resolv.conf|.
------------------------------------------------------------------------
mail1.qmail1.com only — DNS via NetworkManager
mail1 was not provisioned via cloud-init, so its DNS handling is simpler.
NetworkManager DNS settings — MODIFIED
bash
|nmcli con mod ens18 ipv4.dns "127.0.0.1" nmcli con mod ens18
ipv4.ignore-auto-dns yes nmcli con mod ens19 ipv4.dns "" nmcli con mod
ens19 ipv4.ignore-auto-dns yes|
(IPv6 was disabled on |ens18|, so |ipv6.dns| was not set.)
------------------------------------------------------------------------
Files that were RENAMED/RETIRED
These files exist on disk as |.bak| or |.unused| but are no longer
active. Safe to delete after a few weeks of confirmed stable operation.
mail1.qmail1.com
Path Note
|/etc/rspamd/local.d/gpt.api_key.unused| Anthropic API key from the
abandoned cloud-API approach. The Anthropic key itself was revoked at
the vendor console.
|/etc/rspamd/local.d/gpt.conf.bak.YYYY-MM-DD.*| Successive iterations
of gpt.conf during development.
|/etc/rspamd/local.d/rbl.conf.bak.YYYY-MM-DD*| Pre-fix backups of the
broken rbl.conf.
mail2.qmail2.org
Path Note
|/etc/resolv.conf.bak.YYYY-MM-DD| Pre-fix resolv.conf with public
resolvers.
|/etc/sysconfig/network-scripts/ifcfg-eth0.bak| Pre-fix ifcfg with
legacy DNS settings.
|/etc/NetworkManager/conf.d/99-cloud-init.conf.disabled| Disabled
cloud-init drop-in.
------------------------------------------------------------------------
Existing files NOT changed but worth knowing
Path Purpose
|/etc/named.conf| BIND config — already had |recursion yes| and |
listen-on { 127.0.0.1; }|, no edits needed.
|/etc/redis/redis.conf| Redis config — already listening on
127.0.0.1:6379, no edits needed.
|/var/log/rspamd/rspamd.log| Main log, rotated daily by logrotate.
|/etc/logrotate.d/rspamd| Logrotate config — not modified, default 10-
day retention.
------------------------------------------------------------------------
Replication checklist for adding a new mail server
When deploying this stack to a new mail server:
1. DNS
* Verify |named| is running on 127.0.0.1
* Replace |/etc/resolv.conf| with |nameserver 127.0.0.1| only
* If cloud-init managed: add the two |99-disable-*.cfg| files
2. Hosts
* Add |192.168.1.xxx server-ollama| to |/etc/hosts|
* If cloud-init manages |/etc/hosts|: ensure |manage_etc_hosts:
false| drop-in is in place
3. rspamd 4.x
* Add |/etc/yum.repos.d/rspamd.repo|
* |dnf clean all && dnf makecache|
* |systemctl stop rspamd|
* |dnf swap rspamd rspamd-4.0.1 -y --allowerasing|
* |dnf versionlock add 'rspamd-4.0.1-1.el9'|
* |echo rspamd > /etc/dnf/protected.d/rspamd.conf|
* |rspamadm configtest| — fix any warnings (most likely an
rbl.conf stub)
4. Redis — should already be installed on a QMT host. If not: |dnf
install -y redis && systemctl enable --now redis|
5. Configs
* Drop in |/etc/rspamd/local.d/options.inc| (task_timeout)
* Drop in |/etc/rspamd/local.d/gpt.conf| (the AI module)
6. Health check
* Drop in |/usr/local/bin/rspamd-gpt-healthcheck| and |chmod +x|
* Drop in the |.service| and |.timer| units
* |systemctl daemon-reload && systemctl enable --now rspamd-gpt-
healthcheck.timer|
7. Optional
* Drop in |/etc/cron.weekly/gpt-baseline| and |chmod +x|
8. Validate
* |systemctl restart rspamd|
* |rspamadm configdump | grep -A 5 '^gpt {'| — confirm module loaded
* Send a known-spam test message and verify |GPT_SPAM| appears
in the symbol list and |X-GPT-Reason| in the delivered headers.
Remo
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]