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]

Reply via email to