Detect abnormal outbound SMTP bursts on a Mail-in-a-Box server and freeze the offending SASL account before the spam actually goes out.
Built after a real incident: a single mailbox password leaked, was used from ~30 rotating AWS EC2 IPs to blast ~4,000 messages in ~40 hours through authenticated submission. Nothing in the standard MiaB stack (fail2ban, Spamhaus RBL, greylisting, DKIM, DMARC) catches this pattern — those defenses protect against incoming mail and against unauthenticated abuse. Once the attacker has a valid SASL credential, they're inside the trust boundary.
mailguard closes that gap.
Runs every 60 seconds via a systemd timer. Two independent detection signals:
| Signal | Trigger |
|---|---|
| Submission burst | ≥ N SASL auths from a single user within ~60s (default: 20) |
| Queue concentration | ≥ M messages queued from a single sender at any moment (default: 50) |
When either fires, mailguard atomically:
- Freezes the mailbox by prefixing the Dovecot password hash with
!FROZEN:, invalidating the scheme header. The original hash is preserved inside the field so unfreeze is a trivial string strip. A password reset from the MiaB admin panel naturally overwrites the frozen field with a fresh hash. - Holds all queued messages from the sender via
postsuper -h. They move to Postfix'sholdqueue and will never be delivered until you explicitly release or purge them. - Emails an alert to a configured admin address with the reason, sample recipients, and the exact commands to release or purge.
Nothing that was already in the queue gets delivered. Nothing new can be submitted by the frozen user. Alert lands within a minute of the burst.
Rate limiting still lets some spam through — enough to blacklist your IP. Freeze-on-burst delivers zero spam once a threshold is crossed. Legitimate bursts (a newsletter, a mass transactional run) become an alert you glance at and release with one command:
mailguard release info@example.com
Tested on: Mail-in-a-Box (Ubuntu 22.04, Postfix, Dovecot, systemd).
Assumes:
- Postfix log format at
/var/log/mail.log(rsyslog default) - Dovecot passdb backed by MiaB's SQLite schema at
/home/user-data/mail/users.sqlite(userstable,passwordcolumn storing{SHA512-CRYPT}...hashes) - systemd timer support
- Python 3.10+ (standard library only, no dependencies)
Not tested on but likely works with light path changes: any Postfix + Dovecot setup where the passdb is queryable-writable and stores scheme-prefixed hashes.
Won't work on Exim, Sendmail, OpenSMTPD, or non-Dovecot SASL providers without a driver refactor. Contributions welcome.
git clone https://github.com/USER/mailguard.git
cd mailguard
sudo ./install.shBy default the installer starts mailguard in dry-run mode — it will detect and email alerts but will not freeze anything. Watch it for a few days, tune thresholds, then flip to enforcement:
sudo sed -i 's/"dry_run": true/"dry_run": false/' /etc/mailguard/config.jsonEdit /etc/mailguard/config.json:
| Field | Default | Meaning |
|---|---|---|
dry_run |
true |
If true, alert only — never actually freeze or hold. |
submission_burst_threshold |
20 |
SASL auths per user per window that trip a freeze. |
submission_burst_window_seconds |
60 |
Sliding window matches the timer interval. |
queue_concentration_threshold |
50 |
Queue messages from one sender that trip a freeze. |
alert_email |
— | Where alerts go. Strongly recommend a local mailbox (external addresses may bounce during an incident or be delayed by rDNS/DMARC issues). |
alert_from |
— | From: header for alerts. |
mail_log_path |
/var/log/mail.log |
Path to Postfix log. |
users_sqlite |
/home/user-data/mail/users.sqlite |
MiaB user database. |
whitelist_users |
[] |
SASL usernames exempt from freeze (bulk senders you trust). |
hostname |
— | Used in alert subject/body only. |
mailguard status # current state, top queue senders, frozen list
mailguard freeze USER REASON # manual freeze (for one-off incidents)
mailguard unfreeze USER # restore original password hash
mailguard purge USER # delete all queue messages from sender (spam case)
mailguard release USER # move held messages back to active queue (false-positive case)
mailguard watch # single detection pass (called by systemd timer)
Typical incident response:
# 1. Alert arrives. Look at what was caught:
mailguard status
# 2. Confirm it was actually spam (sample the queued messages):
mailq | head -50
# 3a. Real spam — nuke it and rotate password via MiaB admin panel:
mailguard purge compromised@example.com
# (then reset the mailbox password in the MiaB admin panel;
# the password reset naturally clears the frozen state)
# 3b. False positive — release and whitelist the sender:
mailguard release newsletter@yourdomain.com
# (add to whitelist_users in /etc/mailguard/config.json to prevent recurrence)Dovecot's SQL passdb (default_pass_scheme = SHA512-CRYPT) stores password
hashes as {SHA512-CRYPT}$6$.... Dovecot parses the {SCHEME} prefix to
decide how to verify the plaintext at auth time.
mailguard freezes an account by prepending !FROZEN: to that stored value:
before: {SHA512-CRYPT}$6$rounds=5000$...
after: !FROZEN:{SHA512-CRYPT}$6$rounds=5000$...
Dovecot no longer recognizes the scheme header, so authentication fails
regardless of what password is presented. The original hash is preserved
verbatim inside the field, so mailguard unfreeze is a one-line string strip
that restores auth exactly as before.
This mechanism has two nice properties:
- MiaB-compatible. No Postfix config edits, no
main.cformaster.cftouched, so MiaB updates andsudo mailinaboxruns don't overwrite anything. - Naturally cleaned up by password reset. When the admin resets the
mailbox password from the MiaB admin panel, the entire
passwordfield is overwritten with a fresh hash — the!FROZEN:prefix disappears automatically. No coordination needed between mailguard and the admin workflow.
| Path | Purpose |
|---|---|
/opt/mailguard/mailguard.py |
Main script |
/usr/local/bin/mailguard |
CLI symlink |
/etc/mailguard/config.json |
Configuration |
/var/lib/mailguard/state.json |
Log offset, tracked freezes |
/var/log/mailguard.log |
Activity log |
/etc/systemd/system/mailguard.service |
Systemd unit |
/etc/systemd/system/mailguard.timer |
Systemd timer (every 60s) |
sudo ./uninstall.sh # remove code, keep config and state
sudo ./uninstall.sh --purge # remove everything- This is not a substitute for good mailbox password hygiene. It's a containment layer for when a password does leak.
- Alert delivery matters. If your alert address bounces or is delayed, you'll miss the notification. Use a local MiaB mailbox for alerts, or an external address on a domain that reliably accepts mail from your server.
- If the compromised mailbox is the one receiving alerts, local LMTP delivery to its INBOX still works (freeze only breaks SASL auth, not incoming mail).
- Thresholds need tuning per-site. The defaults were picked for a small
MiaB deployment. A larger site with many bulk senders may want higher
thresholds and populated
whitelist_users. - First-run behavior: mailguard starts reading the log from now, not from history. Any ongoing burst at install time will be detected on the next tick (within 60s), not retroactively.
- The queue concentration signal catches slow-drip attacks that stay under the submission burst threshold but accumulate in the queue over time. Both signals are needed.
Issues and PRs welcome. Especially useful:
- Exim / OpenSMTPD driver
- Non-MiaB Dovecot passdb backends (LDAP, plain files)
- Log rotation edge cases on other rsyslog configurations
- Prometheus/OpenTelemetry metrics export
MIT — see LICENSE.