by Michael Hampicke
Quarantine Sentinel: a second opinion for Proxmox Mail Gateway
Anyone running Proxmox Mail Gateway (PMG) knows how this goes. You turn the spam filter up far enough to keep junk out of inboxes, and sooner or later an invoice or a registration confirmation is sitting in quarantine instead. Someone has to notice it, and by the time they do the mail has been sitting there for days.
Quarantine Sentinel is a small Python script I wrote to take that checking off my plate. 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, so a human can go and release it. The script only ever reads from PMG: it leaves the spam scoring alone and never releases mail by itself.
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
A run looks like this:
========================================================================
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, under Quarantine > select mail > Deliver, or through its API.
Run it again a minute later and you'll notice it's fast: mails that were already scored sit in quarantine_sentinel.db and get skipped, so a cron job running every 30 minutes only ever pays 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. 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.
One gotcha worth knowing up front: verdicts are cached by mail ID only, so editing your policy doesn't retroactively re-score anything already in the database. After a meaningful policy change, delete quarantine_sentinel.db for a clean slate.
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.
In practice
I have been running this against our own PMG quarantine for a while. A cautious policy leaves genuinely borderline mail below the threshold, so the digest is never the whole picture, but it has caught several invoices that would otherwise have sat in quarantine until retention deleted them.
Comments
Add a comment