
Detect Mail Loops by Parsing Postfix Logs
A mail loop is a message that never lands: a misconfigured relay that points back at itself, two autoresponders bouncing the same notice back and forth, or a forward that feeds an address into its own alias. Postfix leaves clear fingerprints for this in its log, and a small parser turns those fingerprints into a report you can read in the morning.
Before you run this
This script only reads your mail log and prints a report. It does not touch the queue, does not change Postfix configuration, and does not delete or modify anything. Its purpose is to flag two things: Postfix's own "loops back to myself" warnings, and recipients that show an abnormal number of delivery events in a short window (the volumetric signature of an autoresponder or forwarding loop).
- Privileges: on a default Debian/Ubuntu install,
/var/log/mail.logis readable by root and by members of theadmgroup. If your user is inadmyou can run this unprivileged; otherwise prefix it withsudo. It needs nothing more than read access to the log. - Test first: read the script before you run it, and run it against a copy of a log file (
cp /var/log/mail.log /tmp/mail-sample.log) or a rotatedmail.log.1the first time, so you can see the output shape before pointing it at anything live. Nothing here is destructive, but the counts are only as good as the regex matching your log format, so confirm it recognises your lines before you trust its silence. - No rollback needed — it changes nothing. The only persistent change is if you later add the cron job at the end; undoing that is just removing the cron line.
I'm assuming Debian 12 / Ubuntu 22.04 with Postfix logging via rsyslog to /var/log/mail.log, and Python 3.10+. On RHEL/Alma the log is /var/log/maillog, and if your box logs only to the journal you'll read it with journalctl -u postfix@- > /tmp/mail-sample.log first and point the script at that file.
What a loop looks like in the log
Two patterns matter.
The explicit one is a warning Postfix emits when a destination resolves back to this host — a relayhost or transport that points at itself:
status=bounced (mail for example.com loops back to myself)
The implicit one is volume. A vacation/autoreply loop or a forwarding loop produces the same pair of addresses, or the same recipient, appearing in to=<...> lines over and over within seconds. Postfix also enforces a hop-count ceiling (hopcount_limit, default 50) in the cleanup daemon and will reject a message that has accumulated too many Received: headers — but the exact wording of that rejection has shifted between releases, so I don't hard-code it below. If you want to match it too, check your own log for the line cleanup produces on a bounced loop and add that literal string to the pattern list, rather than trusting a string from a guide.
The parser
Save this as mailloop-report.py. It reads a log file, counts to= events per recipient, surfaces the explicit loop warnings, and prints both.
#!/usr/bin/env python3
"""Read a Postfix mail log and report likely delivery loops.
Read-only: parses and prints, changes nothing."""
import re
import sys
from collections import Counter
# Threshold: how many delivery events to one recipient before we flag it.
# Tune to your environment -- a busy list member is normal; 40 attempts
# to one address in one log window is not.
THRESHOLD = 40
# Match the queue-id and recipient on Postfix delivery lines, e.g.
# postfix/smtp[123]: ABCD1234: to=<user@example.com>, ... status=bounced (...)
TO_RE = re.compile(r'postfix/\w+\[\d+\]:\s+(\w+):\s+to=<([^>]*)>')
# The explicit self-loop warning Postfix writes on a bounced loop.
LOOP_RE = re.compile(r'loops back to myself')
def main(path):
recipients = Counter()
explicit = []
with open(path, "r", errors="replace") as fh:
for line in fh:
if LOOP_RE.search(line):
explicit.append(line.rstrip())
m = TO_RE.search(line)
if m:
recipients[m.group(2).lower()] += 1
print("=== Explicit 'loops back to myself' warnings ===")
if explicit:
for l in explicit:
print(l)
else:
print("(none found)")
print("\n=== Recipients over threshold ({} events) ===".format(THRESHOLD))
flagged = [(a, c) for a, c in recipients.most_common() if c >= THRESHOLD]
if flagged:
for addr, count in flagged:
print(f"{count:6d} {addr}")
else:
print("(none over threshold)")
if __name__ == "__main__":
if len(sys.argv) != 2:
sys.exit(f"usage: {sys.argv[0]} /path/to/mail.log")
main(sys.argv[1])
Run it against your sample first:
python3 mailloop-report.py /tmp/mail-sample.log
Then, once you trust the output, against the live log:
sudo python3 mailloop-report.py /var/log/mail.log
A clean system prints (none found) and (none over threshold). A looping one shows the offending address with a high count, and — if Postfix caught it as a self-loop — the raw warning lines so you can see which domain and transport are involved.
Reading the result
A high count against one recipient is a lead, not a verdict. Pull that address's full trail to confirm it's a loop and not just legitimate volume:
# Replace user@example.com with the address the report flagged.
grep 'user@example.com' /var/log/mail.log | grep -E 'from=|to=' | tail -n 50
If you see the same message alternating between two addresses, or the same queue activity repeating on a tight interval, that's a loop. The explicit "loops back to myself" lines point at a transport/relay misconfiguration — usually a relayhost, transport_maps, or MX entry that resolves back to this host. The fix lives in your Postfix config, not in this script; confirm the destination's mydestination/relay_domains/transport_maps handling with postconf -n and the Postfix documentation for the parameter in question (postfix.org/postconf.5.html).
Running it on a schedule
If you want a daily digest rather than ad-hoc runs, point it at the rotated log and mail yourself the output. This assumes logrotate has produced /var/log/mail.log.1 overnight and that mail (from bsd-mailx or similar) is installed and configured to send locally.
# /etc/cron.d/mailloop-report -- runs 06:00 daily
0 6 * * * root /usr/bin/python3 /usr/local/sbin/mailloop-report.py /var/log/mail.log.1 | /usr/bin/mail -s "Mail loop report: $(hostname)" root
Put the script at /usr/local/sbin/mailloop-report.py and chmod 744 it. To undo the schedule later, delete /etc/cron.d/mailloop-report — there's nothing else to reverse.
Verify it works
The honest test is a known-positive input. Append a couple of synthetic lines that match the patterns to your sample file and confirm the script catches them:
cp /var/log/mail.log /tmp/mail-sample.log
echo 'Jan 01 00:00:00 mail postfix/smtp[1]: TEST01: to=<loop@example.com>, status=bounced (mail for example.com loops back to myself)' >> /tmp/mail-sample.log
python3 mailloop-report.py /tmp/mail-sample.log
You should see the injected loops back to myself line in the first section. If the recipient counter also behaves — flag the test address by lowering THRESHOLD to 1 temporarily — you know the to= regex matches your log's exact format. If it doesn't appear, your log lines differ from the assumed format; paste one real line and adjust TO_RE to match before relying on the report. Delete /tmp/mail-sample.log when you're done.
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
Bulk-Export Postfix Virtual Maps to an Encrypted Backup
This guide builds a small bash script that reads your Postfix virtual-table configuration, copies the source files behind each file-based map ( hash: , lmdb: , btree: , cdb: , texthash: ) plus main.cf and master.cf , bundles them into a tarball, and encrypts that tarball with GnuPG so you can store it off-box safely. The purpose is a routine, restorable snapshot of your mail routing and alias data.
One Report Correlating Mail Service Errors and Delivery Failures
This is a read-only reporting script . It queries the systemd journal and prints a single, time-ordered report that interleaves two things: systemd service-level errors for your mail stack (Postfix and Dovecot logging at priority err or worse) and Postfix delivery failures (bounces, deferrals, and NOQUEUE rejects). The point is to see, in one place, whether a run of deferred deliveries at 02:15 lines up with Dovecot restarting at 02:14.
Automate Cleanup of Stalled Messages in the Postfix Queue
Postfix is good at retrying mail on its own, but over time a busy server collects two kinds of junk in its spool: messages that have been deferred for days and will never deliver, and partial or misplaced files left behind by a crash or an abrupt restart. This guide sets up a safe, scheduled cleanup for both — using Postfix's own tools, not manual file surgery.
Audit Postfix for Open Relay Vulnerabilities with Bash
This script reads your Postfix relay-control settings with postconf and flags configurations that could let outsiders relay mail through your server — the classic "open relay" that gets you onto blocklists and turns your box into a spam cannon. It is read-only. It changes nothing, writes nothing, and reloads nothing , so there is no config to back up and nothing to roll back. It simply reports.




