Shore Up
three separate stacks of paper log sheets being merged by a clerk with a magnifying glass into a single summary page, with a few suspicious lines circled in red.
LinuxSecurityMail

Correlate Fail2ban, Postfix and Dovecot Logs Into One Report

Ketan Aagja10 min read
No ratings yet

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 rsyslog writing 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.

Written by
Ketan Aagja

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.

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.

7 min read

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.

9 min read

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.

9 min read

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.

7 min read