
Correlate Fail2ban, Postfix and Dovecot Logs Into One Report
Before you run this
This is a read-only reporting script. It reads your Postfix/Dovecot mail log and your Fail2ban log, extracts the source IPs behind SMTP SASL failures, Postfix rejects and Dovecot auth failures, cross-references them against the IPs Fail2ban actually banned, and prints one ranked summary. It does not touch your firewall, your jails, your mail queue, or any config. There is nothing to undo except deleting the report file and removing the cron entry you add at the end.
It needs read access to the log files. On a stock Debian/Ubuntu box /var/log/mail.log and /var/log/fail2ban.log are readable by root and the adm group, so run it with sudo or as a user in the adm group. It does not need to modify anything, so do not run it as root out of habit — plain read access is enough.
Even though it is non-destructive, read it before you run it, and run it first on a test box or against a copy of your logs (cp /var/log/mail.log /tmp/mail.log and point the script there). The regexes here match the common Postfix/Dovecot log lines, but log formats drift between versions and packages — confirm the counts against your own logs before you trust the report or wire it into cron.
What I'm assuming
- Debian 12 or Ubuntu 22.04, with
rsyslogwriting plain-text logs to/var/log/mail.log(Postfix and Dovecot both log there by default on these distros). - Fail2ban installed from the distro package, logging to
/var/log/fail2ban.log. - Python 3.9+, which both distros ship. No third-party modules — standard library only.
If your box uses journald only and has no /var/log/mail.log, the parsing logic is the same but you'd feed it journalctl -u postfix -u dovecot output instead of the file; I'm writing for the rsyslog file case here. If Postfix/Dovecot log somewhere else on your system, check /etc/rsyslog.d/ for the mail.* target.
The parsing logic, before the code
Four line shapes carry what we need. These are the standard forms on the assumed stack — but verify them against your own grep output, because a different Dovecot version or a postfix/submission service can word things slightly differently:
- Fail2ban ban:
fail2ban.actions … NOTICE [jail] Ban 1.2.3.4 - Postfix SASL auth failure:
postfix/smtpd… warning: host[1.2.3.4]: SASL LOGIN authentication failed - Postfix reject:
postfix/smtpd… NOQUEUE: reject: … [1.2.3.4] - Dovecot auth failure:
dovecot: … auth failed … rip=1.2.3.4
The IP is the join key. We tally each category per IP, then flag which IPs Fail2ban has already banned and which are still hammering unbanned.
The script
Save this as mailauth-report.py. Every non-obvious line has a comment.
#!/usr/bin/env python3
"""Correlate Fail2ban bans with Postfix/Dovecot auth failures into one report.
Read-only. Reads mail + fail2ban logs (and their rotations) and prints a summary."""
import re
import gzip
import sys
from collections import defaultdict
from pathlib import Path
# --- adjust these two paths if your logs live elsewhere ---
MAIL_LOG = "/var/log/mail.log"
FAIL2BAN_LOG = "/var/log/fail2ban.log"
IP = r'(\d{1,3}(?:\.\d{1,3}){3})' # IPv4 only; see note below on IPv6
BAN_RE = re.compile(r'fail2ban\.actions.*NOTICE\s+\[(?P<jail>[^\]]+)\]\s+Ban\s+' + IP)
SASL_RE = re.compile(r'postfix/\S+: warning: \S*?\[' + IP + r'\]: SASL \S+ authentication failed')
REJECT_RE = re.compile(r'postfix/\S+: NOQUEUE: reject:.*?\[' + IP + r'\]')
DOVECOT_RE = re.compile(r'dovecot:.*auth failed.*rip=' + IP)
def read_lines(base):
"""Yield lines from a log file plus its rotations (.1, .2.gz, ...)."""
p = Path(base)
files = sorted(p.parent.glob(p.name + "*")) # e.g. mail.log, mail.log.1, mail.log.2.gz
if not files:
print(f"WARNING: no files matched {base}", file=sys.stderr)
for f in files:
try:
opener = gzip.open if f.suffix == ".gz" else open
with opener(f, "rt", errors="replace") as fh:
yield from fh
except PermissionError:
print(f"Cannot read {f} — run with sudo or as a member of 'adm'.", file=sys.stderr)
def main():
# per-IP tally; bans is a set of jail names that banned this IP
stats = defaultdict(lambda: {"sasl": 0, "reject": 0, "dovecot": 0, "bans": set()})
for line in read_lines(FAIL2BAN_LOG):
m = BAN_RE.search(line)
if m:
stats[m.group(2)]["bans"].add(m.group("jail")) # group(2) is the IP here
for line in read_lines(MAIL_LOG):
for regex, key in ((SASL_RE, "sasl"), (REJECT_RE, "reject"), (DOVECOT_RE, "dovecot")):
m = regex.search(line)
if m:
stats[m.group(1)][key] += 1
# rank by total auth-failure noise, worst first
ranked = sorted(stats.items(),
key=lambda kv: kv[1]["sasl"] + kv[1]["reject"] + kv[1]["dovecot"],
reverse=True)
print(f"{'IP':<18}{'SASL':>6}{'REJECT':>8}{'DOVECOT':>9} BANNED BY")
print("-" * 60)
for ip, s in ranked:
total = s["sasl"] + s["reject"] + s["dovecot"]
if total == 0 and not s["bans"]:
continue
banned = ",".join(sorted(s["bans"])) if s["bans"] else "-- NOT BANNED --"
print(f"{ip:<18}{s['sasl']:>6}{s['reject']:>8}{s['dovecot']:>9} {banned}")
if __name__ == "__main__":
main()
A word on the regexes: named-group vs positional. BAN_RE has a named jail group and the shared IP capture, so the IP lands in m.group(2); the other three patterns have exactly one capture, so the IP is m.group(1). That asymmetry is deliberate — don't "tidy" it without re-checking the group numbers.
IPv4 only. The IP pattern here does not match IPv6. If your Dovecot/Postfix logs show IPv6 sources (they will, on a dual-stack box), those lines are silently skipped. Extend the pattern to cover IPv6 only after you've confirmed how your logs render those addresses — don't paste in an IPv6 regex you haven't tested against real lines.
Run it:
chmod +x mailauth-report.py
sudo ./mailauth-report.py
You'll get a table of offending IPs ranked by noise, with the Fail2ban jails that banned each one — and, importantly, -- NOT BANNED -- next to IPs that are generating failures but slipping under your jail thresholds. That last column is the whole point of correlating.
Schedule it
For a daily report, write to a dated file from cron. Edit root's crontab (so it can read the logs) with sudo crontab -e and add:
# 06:00 daily — replace /path/to with the real script location
0 6 * * * /path/to/mailauth-report.py > /var/log/mailauth-report-$(date +\%K).txt 2>&1
Note the escaped % — cron treats an unescaped % as a newline. \%K gives the two-digit day-of-month via date, so you keep a rolling month of reports without them stacking forever. Point /path/to at wherever you saved the script.
If you want the report emailed, the simplest standard path is to let cron mail root's job output by piping to your MTA's sendmail interface, or by installing bsd-mailx and piping the output to the mail command. I'm not going to spell out a mail invocation here because the exact flags differ between mailutils and bsd-mailx — check man mail on your box for the -s subject flag before you rely on it.
A systemd timer is the modern alternative to cron and does the same job; pick one, don't run both.
Verify it worked
Cross-check the script's counts against a manual grep on the same log so you know the regexes are catching your actual lines:
# Postfix SASL failures — should be close to the SASL column total
sudo grep -c "SASL .* authentication failed" /var/log/mail.log
# Dovecot auth failures
sudo grep -c "dovecot:.*auth failed" /var/log/mail.log
# What Fail2ban actually has banned right now, per jail
sudo fail2ban-client status
sudo fail2ban-client status sshd # or postfix-sasl, dovecot, etc.
If a grep -c count is wildly higher than the matching column, your log wording differs from the patterns above — copy a real failing line and adjust the regex to match it. If the ban column is empty but fail2ban-client status <jail> shows bans, your /var/log/fail2ban.log may have rotated away the Ban lines; the script reads rotations, but only as far back as logrotate keeps them.
Undo
There is nothing to reverse in the system — the script only reads. To back it out completely: sudo crontab -e and delete the line you added (or systemctl disable --now the timer if you went that route), and remove the report files with sudo rm /var/log/mailauth-report-*.txt. That's the full extent of what this guide changed.
Runs enterprise networks and security for a living, and writes Shore Up to turn two decades of hands-on Linux, Windows and mail-server work into guides you can actually use.
More about the author →Was this article helpful?
Tap a star — no sign-in needed.
Be the first to rate this article.
Related guides
Automatically Ban Abusive IPs in Postfix with Fail2ban
The standard, boring way to block IPs that hammer your mail server is Fail2ban. It watches the mail log, counts matching failures per source IP inside a time window, and when a source crosses a threshold it inserts a firewall rule to drop that IP for a while. You could write a bash script that greps the log and pipes IPs into nft , and I'll say where that fits at the end — but reinventing Fail2ban is more error-prone than configuring it, so that's what this guide does.
Automate DKIM Key Checks and Rotation on OpenDKIM
This guide has two parts. The check part is a read-only script that queries DNS for your published DKIM record and confirms it still matches the private key OpenDKIM signs with — safe to run any time, and safe to put on cron. The renewal part generates a new key under a new selector, has you publish a DNS record, and then switches signing over to it.
Automate Dovecot Mailbox Quota Reports with a Shell Script
This guide builds a small shell script that runs doveadm quota get for every mailbox on a Dovecot server, writes a plain-text usage report to a file, and emails you a summary that flags anyone at or above a threshold (90% by default). It is a read-only reporting script. It queries quota figures that Dovecot already tracks; it does not create, resize, recalculate, or delete anything in a mailbox.
Automate fail2ban Jail Reports to Slack with Bash
This script reads fail2ban's current state — the list of active jails and how many IPs each one has banned — and posts a short text summary to a Slack channel through an incoming webhook. Its purpose is visibility: you get a scheduled report of what fail2ban is blocking without SSHing in to run fail2ban-client status by hand.




