
Detect Spam Relay Abuse from Postfix Mail Logs
Before you run this
This guide gives you a read-only Python script that parses a Postfix mail log and reports two things: authenticated senders (SASL users) who sent an unusually large number of messages or recipients — the classic signature of a compromised mailbox being used to blast spam — and source IPs that keep tripping "Relay access denied", which is relay probing. The script does not change anything: it reads the log, counts, and prints a report. It never touches Postfix config, never disables an account, never blocks an IP.
Because it only reads, it is low-risk — but it needs to read /var/log/mail.log, which on Debian/Ubuntu is owned root:adm and not world-readable. Run it with sudo, or as a user in the adm group. It requires no other privileges and installs nothing beyond a stock Python 3.
Test it first. Copy a chunk of your log to a scratch file and run the script against that copy before you point it at the live log — that also lets you eyeball whether the regexes match your log's exact format, which can differ slightly between Postfix versions and distributions. Read the script before you run it.
One more caution, because this is the dangerous part: the script tells you who looks suspicious, not who is guilty. A newsletter run or a legitimate bulk mailbox will also show a high count. Do not disable an account or block an IP purely on this output. Confirm with the raw log lines (I show you how at the end) and your knowledge of the mailbox before you act. Disabling the wrong account or blackholing the wrong IP is the irreversible mistake here, not running the parser.
Assumptions
I'm writing for Postfix on Debian 12 / Ubuntu 22.04, logging through rsyslog to /var/log/mail.log, with Python 3.9 or newer.
- On RHEL/Alma/Rocky, the log is
/var/log/maillog— change the path. - If your system logs only to the systemd journal (no rsyslog), there is no flat file. Feed the script from the journal instead:
Thesudo journalctl -u postfix@- --since "24 hours ago" | python3 relay_report.py --tells the script to read standard input. (Confirm your Postfix unit name withsystemctl list-units 'postfix*'— on some setups it's justpostfix.)
What the log actually shows
Three Postfix log lines carry the evidence we need. A message from an authenticated client looks like this:
postfix/smtpd[2011]: A1B2C3: client=mail.example.net[203.0.113.9], sasl_method=PLAIN, sasl_username=alice@example.com
postfix/qmgr[1990]: A1B2C3: from=<alice@example.com>, size=41233, nrcpt=87 (queue active)
The smtpd line ties a queue ID (A1B2C3) to the SASL username. The qmgr line ties the same queue ID to nrcpt= — the recipient count. Correlating the two lets us total messages and recipients per authenticated user. A relay probe, by contrast, never gets a queue ID:
postfix/smtpd[2011]: NOQUEUE: reject: RCPT from unknown[198.51.100.4]: 554 5.7.1 <spam@victim.org>: Relay access denied;
That's your server correctly refusing to relay. A handful is background noise; hundreds from one IP is someone testing you.
The script
#!/usr/bin/env python3
"""Read-only Postfix log parser: reports high-volume SASL senders and relay probes.
Usage: relay_report.py /var/log/mail.log
journalctl -u postfix@- | relay_report.py -
"""
import sys, re
from collections import defaultdict
# --- tune these to your environment ---
MSG_THRESHOLD = 200 # flag a SASL user above this many messages in the log window
RCPT_THRESHOLD = 500 # ...or above this many total recipients
PROBE_THRESHOLD = 50 # flag a source IP above this many "Relay access denied"
# queue-id -> sasl_username, captured from the smtpd client line
re_client = re.compile(r'postfix/smtpd\[\d+\]: (\w+): client=.*sasl_username=([^,\s]+)')
# queue-id -> recipient count, from the qmgr line
re_qmgr = re.compile(r'postfix/qmgr\[\d+\]: (\w+): from=<[^>]*>, size=\d+, nrcpt=(\d+)')
# relay probes: source IP from a "Relay access denied" reject
re_probe = re.compile(r'postfix/smtpd\[\d+\]: NOQUEUE: reject: RCPT from \S+\[([\d.]+)\].*Relay access denied')
qid_user = {} # queue id -> username
user_msgs = defaultdict(int) # username -> message count
user_rcpt = defaultdict(int) # username -> total recipients
probes = defaultdict(int) # source IP -> denied attempts
def source():
if len(sys.argv) < 2:
sys.exit(f"usage: {sys.argv[0]} <logfile|->")
return sys.stdin if sys.argv[1] == '-' else open(sys.argv[1], encoding='utf-8', errors='replace')
for line in source():
m = re_client.search(line)
if m:
qid, user = m.group(1), m.group(2).lower()
qid_user[qid] = user
user_msgs[user] += 1
continue
m = re_qmgr.search(line)
if m:
qid, nrcpt = m.group(1), int(m.group(2))
user = qid_user.get(qid) # only count queue IDs we saw authenticate
if user:
user_rcpt[user] += nrcpt
continue
m = re_probe.search(line)
if m:
probes[m.group(1)] += 1
print("=== Authenticated senders (msgs / recipients) ===")
for user in sorted(user_msgs, key=lambda u: user_rcpt[u], reverse=True):
flag = " <-- REVIEW" if (user_msgs[user] >= MSG_THRESHOLD
or user_rcpt[user] >= RCPT_THRESHOLD) else ""
print(f" {user_msgs[user]:6d} msgs {user_rcpt[user]:7d} rcpts {user}{flag}")
print("\n=== Relay-denied source IPs ===")
for ip, n in sorted(probes.items(), key=lambda kv: kv[1], reverse=True):
flag = " <-- REVIEW" if n >= PROBE_THRESHOLD else ""
print(f" {n:6d} denied {ip}{flag}")
Save it as relay_report.py and run it:
sudo python3 relay_report.py /var/log/mail.log
The thresholds at the top are deliberately conservative placeholders — set them to something sane for your own traffic. A single-user vanity domain and a 5,000-mailbox host have very different "normal" volumes. If you rotate logs daily, remember the report only covers what's in that one file; add rotated files explicitly (e.g. zcat mail.log.1.gz | ... -) if you want a longer window.
Reading the output
The first block lists authenticated senders, sorted by recipient total, with a <-- REVIEW marker on anyone over your thresholds. A normal user sits in the low tens. A compromised mailbox typically jumps to thousands of recipients across a burst of messages, often at odd hours. The second block is relay probing — usually harmless because your server is refusing it, but a spike from one IP is worth a firewall block at the perimeter.
Verify it's counting correctly
Before you trust the numbers, cross-check one flagged user against the raw log with standard tools. Count that user's smtpd authentication lines directly:
sudo grep 'sasl_username=alice@example.com' /var/log/mail.log | grep -c 'client='
That count should match the "msgs" column for alice@example.com. If it doesn't, your log format differs from the pattern above (older Postfix, a custom syslog_name, or added fields) — inspect a few lines with less and adjust the regexes rather than trusting a mismatched total.
To confirm a suspected account really is spamming, pull its actual messages and look at the recipients and subjects:
sudo grep 'sasl_username=alice@example.com' /var/log/mail.log | grep 'client=' | awk '{print $6}'
# take those queue IDs and grep each one to see from=, to=, and status
If you confirm abuse
This script stops at reporting — acting is a manual decision. The standard remediation, in order: change the compromised mailbox's password immediately (this alone cuts off the sender, since the spam relies on valid SASL credentials), then inspect the mail queue with postqueue -p and delete the spam with postsuper if needed. For persistent relay probing, block the source at your firewall or with your usual fail2ban/postscreen setup. None of that is reversible in the sense of un-sending mail, so verify with the raw log first, exactly as above, before you pull the trigger.
For the queue tools and log line meanings, see the official Postfix documentation — the postqueue(1), postsuper(1), and postconf(5) manual pages, and the "Postfix Bottleneck Analysis" and SASL howto pages on postfix.org.
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
Correlate Fail2ban, Postfix and Dovecot Logs Into One Report
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.
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.
Detect and Report Failed SSH Login Attempts with a Log-Parsing Script
This guide builds a read-only bash script that parses your SSH log, counts failed password attempts, and prints a summary of the busiest source IP addresses and the usernames they tried. Its purpose is visibility — spotting brute-force patterns — not blocking. It does not change firewall rules, ban anyone, edit config, or delete anything. Running it and re-running it leaves your system exactly as it was.
Alert on a Growing Postfix Mail Queue with a Cron Script
A mail queue that quietly grows is one of those failures you notice late — usually when a user asks why their mail from three hours ago hasn't arrived. A backlog can mean a dead relay host, a DNS problem, a downstream server rejecting everything, or an outbound spam run from a compromised account. This guide sets up a small cron job that counts the queue and emails you when it crosses a threshold, so you hear about it early.




