by Michael Hampicke
Quarantine Sentinel: a second opinion for Proxmox Mail Gateway
Our Proxmox Mail Gateway (PMG) puts a small but steady number of legitimate mails into quarantine. In the run I am using as an example below, 3 of 856 quarantined messages were mail somebody actually wanted. The way we dealt with that until now: every colleague checks their own spam quarantine by hand. Which in practice means irregularly, or not at all, and with a seven day retention a mail can be gone before anyone looks.
Quarantine Sentinel is a small Python script that does that sweep instead. It logs into PMG read-only, pulls every quarantined message across all users for a configurable lookback window and asks an LLM to judge each one against PMG's own headers, the SpamAssassin rule hits and a text excerpt of the body. Anything the model is confident is legitimate mail shows up in a digest. The script only ever reads from PMG: it leaves the spam scoring alone and never releases mail by itself, so somebody still has to look at the digest and click Deliver.
The code lives on GitHub: github.com/mgeha/quarantine-sentinel
What it does
- Authenticates to a PMG node via ticket-based auth (PMG has no API tokens)
- Fetches quarantined messages for all users within a configurable lookback window
- Scores each new message against an LLM (Ollama, OpenAI or Anthropic) to flag likely false positives
- Caches every verdict in a local SQLite database, so a mail is never re-scored on the next run
- Prints a digest of probable false positives, with the model's reasoning and PMG's own spam score for context
Prerequisites
- Python 3.11+ and uv
- A PMG node (tested against 9.x)
- An LLM backend: a local Ollama instance, or an API key for OpenAI or Anthropic
Before anything else, create a dedicated PMG account for the poller and give it the Auditor role, which is the read-only access level it needs. Don't reuse root. PMG's account types and roles are documented under User Management in the admin guide.
Installation
The script ships as a single file with PEP 723 inline dependency metadata, so there is no virtualenv to set up. uv reads the dependency block at the top of the file and installs what it needs on the fly.
git clone https://github.com/mgeha/quarantine-sentinel.git quarantine-sentinel
cd quarantine-sentinel
# 1. Copy the example config and fill in your PMG credentials
cp config.toml.example config.toml
# 2. Set your LLM API key as an environment variable or in config.toml
export ANTHROPIC_API_KEY=sk-ant-...
# or: export OPENAI_API_KEY=sk-...
# 3. Run it. uv installs the inline dependency (`anthropic`) automatically
uv run quarantine_sentinel.py
Using Ollama instead? Nothing extra to install. Set backend = "ollama" in config.toml and run the same command. For OpenAI, run with uv run --with openai quarantine_sentinel.py, since openai isn't in the inline dependency block by default.
Configuring it
Everything lives in config.toml, which is deliberately git-ignored: it holds credentials and real mail metadata and should never end up in a repo. config.toml.example is a fully commented starting point. These are the parts worth understanding before your first run:
[pmg]
url = "https://pmg.example.com:8006"
username = "sentinel@pam"
password = "changeme"
verify_ssl = true
[llm]
backend = "claude" # "ollama" | "openai" | "claude"
model = "claude-haiku-4-5" # cost-efficient for bulk scoring
A few top-level settings control how aggressively it works:
- lookback_days: How far back into quarantine to look (PMG's default retention is 7 days)
- confidence_threshold: Minimum ham-confidence (0.0 to 1.0) for a mail to make it into the digest
- max_mails_per_run: Hard cap on LLM calls per run, a safety valve against runaway API cost
- max_workers: How many mails to score in parallel (lower it for Ollama on modest hardware)
- rcpt_ignore: Recipients (shared mailboxes, mailing lists, monitoring addresses) skipped entirely, before any mail is even fetched
- policy_file: Your own scoring rules, replacing the shipped default_policy.md
For rcpt_ignore there is a small interactive helper, so you don't have to hand-type addresses:
uv run update_rcpt_ignore.py
It lists the recipients currently sitting in quarantine and lets you add any of them to the ignore list on the spot.
Your first run
Here is an actual run against our own gateway. Sender names, domains, hostnames and mail IDs are replaced, everything else is verbatim:
========================================================================
PMG Quarantine False-Positive Report — 2026-08-28 13:23:52 UTC
========================================================================
Mails in scope : 856 (59 scored this run, 797 from cache)
LLM : claude / claude-haiku-4-5
Threshold : ham confidence >= 70%
LIKELY FALSE POSITIVES (3 mail(s) above threshold):
* 92% HAM id=C0R51838T39905985
From: Kerstin Falkenberg <falkenberg@nordpr.example>
Subject: SPAM: [Spam]it-sa Nürnberg: Adlerbach und Vosskamp
Consulting präsentieren exklusive Partnerschaft zu DocVault
Score: 8.0
Reason: This is a legitimate press release / media outreach from a
named PR professional (Kerstin Falkenberg at Nordpr Public
Relations) sent from the plausible domain nordpr.example.
The connecting host (mail.nordpr.example) matches the
sender's domain, DKIM validation passes, SPF passes, and
the content is professional B2B partnership announcement
targeted at media partners — exactly the kind of
unsolicited-but-legitimate outreach a PR agency sends. The
high spam score is driven almost entirely by the
KAM_MARKSPAM rul…
* 85% HAM id=C0R51838T39905984
From: Kerstin Falkenberg <falkenberg@nordpr.example>
Subject: SPAM: [Spam]it-sa Nürnberg: Adlerbach und Vosskamp
Consulting präsentieren exklusive Partnerschaft zu DocVault
Score: 8.0
Reason: Press release from a legitimate PR professional (Kerstin
Falkenberg at Nordpr Public Relations) announcing a
business partnership. The connecting domain
(mail.nordpr.example) matches the sender's own domain; DKIM
and SPF both pass; BAYES_00 indicates low spam probability.
The single strong signal (KAM_MARKSPAM=10.0) is the
gateway's own quarantine decision being audited—circular
evidence that must be disregarded. Body content is
professional, on-topic, and consistent with
unsolicited-but-legitima…
* 75% HAM id=C0R52051T86054324
From: <info@tierfutter-nord.example>
Subject: SPAM: WG: Zusammenarbeit
Score: 5.4129000000000005
Reason: This is a forwarded business correspondence about a
legitimate commercial arrangement (magazine advertising and
product placement). The sender domain
tierfutter-nord.example matches the From address, the body
contains specific business details (pricing, print run,
contact names), and the connecting host
(mo4-p00-ob.smtp.relay.example) is a legitimate German mail
relay. BAYES_00 and positive mailspike reputation support
legitimacy. The high score is driven by KAM_DMARC_REJECT
(7.0 points) — a DMARC policy …
Confirmed spam (not listed): 853 mail(s)
To release a mail (human decision required):
# Via PMG web UI: Quarantine -> select mail -> Deliver
# Via API (POST to https://pmg.example.com:8006):
# Path: /api2/extjs/quarantine/content
# Body: {"action": "deliver", "id": "<mail_id>"}
========================================================================
Everything above the threshold is worth a look in the PMG web UI. Everything below it stays in quarantine unless you go and dig manually. Releasing happens in PMG itself, and the report prints both paths at the bottom so nobody has to look them up.
That output also shows one of the rough edges. The press release appears twice, once per recipient, and the two copies came back at 92% and 85%. Same mail, same headers, different number. It doesn't matter much when both are above the threshold, but the confidence value is not something to read too precisely.
Of the 856 mails in scope, 797 came out of quarantine_sentinel.db and only 59 were sent to the API. That ratio is what makes a cron job every 30 minutes affordable: after the first run you only ever pay for genuinely new mail.
Tuning what counts as "ham"
default_policy.md holds the actual spam/ham heuristics: which SpamAssassin rules count as strong evidence (RBL hits, BAYES_99), which are explicitly not enough on their own (a bare SPF/DKIM failure, HTML_MESSAGE), and how brand impersonation on unrelated infrastructure gets caught.
One rule needed spelling out. KAM_MARKSPAM contributes 10 points and it fires because PMG has already classified the mail as spam. Hand that to the model as evidence and it will agree with the gateway every single time, which makes the whole exercise pointless. The policy tells it to disregard the rule, and you can watch that land in the output above: "the gateway's own quarantine decision being audited, circular evidence that must be disregarded".
If your mail has quirks the default policy doesn't account for, such as internal domains or a vendor with a permanently broken SPF record, copy the file, edit your copy and point at it with policy_file = "local/my_policy.md".
(local/ is git-ignored, which makes it a convenient place for a policy that names your internal domains.) A configured policy replaces the default instead of merging with it, and relative paths resolve against the config file's directory, so a cron or systemd run still finds it.
Verdicts are cached by mail ID only, so editing the policy doesn't re-score anything already in the database. After a real policy change, delete quarantine_sentinel.db.
Before you point this at real mail
A cloud LLM backend (OpenAI, Anthropic) receives message metadata, authentication results, spam-rule hits and a body excerpt bounded by body_max_chars. Attachments are never sent. If that conflicts with your data protection requirements, run an on-premises Ollama model instead and nothing leaves your infrastructure.
With a cloud backend you want a data processing agreement with the provider in place before this touches real mailboxes. quarantine_sentinel.db holds quarantined-mail metadata and the model's verdicts, so it belongs on storage with restricted access.
Three hits out of 856 is not a dramatic rate, and a cautious policy leaves borderline mail sitting below the threshold. But those three entries are two real mails, a press release and a forwarded enquiry about an advertising deal, and neither of them would have been found before retention deleted it.
Comments
Add a comment