Shore Up
a letter trapped going round and round a circular conveyor belt, passing the same sorting slot over and over while a clerk marks a tally sheet
LinuxMail

Detect Mail Loops by Parsing Postfix Logs

Ketan Aagja8 min read
No ratings yet

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.log is readable by root and by members of the adm group. If your user is in adm you can run this unprivileged; otherwise prefix it with sudo. 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 rotated mail.log.1 the 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.

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.

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.

9 min read

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.

10 min read

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.

9 min read

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.

9 min read