
Per-Domain Mail Volume Trends from Postfix Logs
Before you run this
This guide gives you a small Python 3 script that reads Postfix's delivery log
lines, extracts the recipient domain and the delivery status (sent, bounced,
deferred, etc.) from each line, and prints a per-domain, per-day count as CSV so
you can spot volume trends. It is read-only: it opens log files, counts lines,
and writes nothing back to the system. It does not touch Postfix, the queue, or the
logs themselves.
You need read access to the mail log. On a default Debian/Ubuntu install that
means running it with sudo, or as a user in the adm group, because
/var/log/mail.log is not world-readable. The script itself needs no root
privileges beyond being able to open the files you point it at.
Test it first. Copy a log file somewhere and run the script against the copy before you wire it into cron or point it at your live logs:
cp /var/log/mail.log /tmp/mail.log.sample
python3 maildomains.py --year 2024 /tmp/mail.log.sample
Read the script before you run it. Nothing here is destructive, but you should never paste code you haven't read onto a mail server. If you decide to save the report to a file, the only "undo" is deleting that file — the script changes nothing else.
Assumptions
I'm writing for Debian 12 / Ubuntu 22.04 with Postfix logging through rsyslog to
/var/log/mail.log in the traditional syslog format (Nov 10 06:25:01 host postfix/smtp[123]: ...). Python 3.9 or newer is assumed; both ship in the base
repos.
Two things to note about your environment:
- If your host is journald-only (no rsyslog), you won't have
/var/log/mail.log. Feed the script from the journal instead — see the last section. - The traditional syslog timestamp has no year. The script assumes the current
year unless you pass
--year, which matters when you parse rotated logs that cross a January boundary.
An established tool already exists for Postfix log summaries — pflogsumm — and it's worth knowing about. This script exists because pflogsumm doesn't give you a tidy per-domain, per-day table you can chart or diff over time.
The script
Save this as maildomains.py. It handles both plain and gzip-compressed rotated
logs (mail.log, mail.log.1, mail.log.2.gz, …) so you can report over a longer
window.
#!/usr/bin/env python3
"""Report per-domain, per-day Postfix delivery counts as CSV (read-only)."""
import argparse
import csv
import gzip
import re
import sys
from collections import defaultdict
from datetime import datetime
# Matches Postfix delivery lines carrying a recipient and a status.
# Example line:
# Nov 10 06:25:01 host postfix/smtp[123]: ABC12: to=<a@example.com>, relay=..., status=sent (250 OK)
LINE_RE = re.compile(
r'^(?P<mon>\w{3})\s+(?P<day>\d{1,2})\s+\d{2}:\d{2}:\d{2}\s+'
r'\S+\s+postfix/\w+\[\d+\]:\s+\w+:\s+'
r'.*?to=<[^@>]*@(?P<domain>[^>]+)>' # recipient domain
r'.*?status=(?P<status>\w+)' # sent / bounced / deferred / ...
)
def open_log(path):
# Transparently read .gz rotated logs; otherwise open as text.
if path.endswith('.gz'):
return gzip.open(path, 'rt', errors='replace')
return open(path, 'rt', errors='replace')
def parse(paths, year):
# counts[(date, domain)][status] -> int
counts = defaultdict(lambda: defaultdict(int))
statuses = set()
for path in paths:
with open_log(path) as fh:
for line in fh:
m = LINE_RE.match(line)
if not m:
continue
# Rebuild a date from the syslog "Mon DD" plus the given year.
stamp = f"{m['mon']} {int(m['day']):02d} {year}"
try:
d = datetime.strptime(stamp, "%b %d %Y").date()
except ValueError:
continue # unparseable month/day; skip
domain = m['domain'].lower()
status = m['status'].lower()
counts[(d.isoformat(), domain)][status] += 1
statuses.add(status)
return counts, sorted(statuses)
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument('logs', nargs='+', help='log file(s), plain or .gz')
ap.add_argument('--year', type=int, default=datetime.now().year,
help='year to assume for syslog timestamps (default: current)')
args = ap.parse_args()
counts, statuses = parse(args.logs, args.year)
writer = csv.writer(sys.stdout)
writer.writerow(['date', 'domain', *statuses, 'total'])
for (date, domain) in sorted(counts):
row = counts[(date, domain)]
per_status = [row.get(s, 0) for s in statuses]
writer.writerow([date, domain, *per_status, sum(per_status)])
if __name__ == '__main__':
main()
Run it against your current log:
sudo python3 maildomains.py /var/log/mail.log
Or over a week of rotated logs, telling it which year those timestamps belong to:
sudo python3 maildomains.py --year 2024 \
/var/log/mail.log /var/log/mail.log.1 /var/log/mail.log.*.gz
Output looks like this:
date,domain,bounced,deferred,sent,total
2024-11-09,example.com,3,1,412,416
2024-11-09,example.net,0,0,88,88
2024-11-10,example.com,5,2,455,462
The status columns are whatever statuses actually appeared in your logs, sorted
alphabetically, so you don't have to guess ahead of time.
What the regex matches (and what it deliberately doesn't)
The script counts recipient-side delivery events — lines from Postfix delivery
agents (postfix/smtp, postfix/lmtp, postfix/local, postfix/pipe, postfix/virtual)
that carry both a to=<...@domain> address and a status= field. That is the
standard shape of a Postfix delivery log line and it's stable across recent
FortiOS-era Postfix releases.
It ignores lines with no @ in the recipient (double-bounce and null recipients),
lines from smtpd/cleanup/qmgr that have no delivery status, and anything it
can't parse. That's intentional: it counts deliveries, not connections.
One honest limitation: a single message to three recipients on the same domain counts as three events, because Postfix logs one delivery line per recipient. If you want messages rather than deliveries, that's a different, harder job (you'd have to correlate queue IDs), and this script does not attempt it.
Charting the trend
The CSV is deliberately plain so downstream tools can consume it. To watch one domain over time, save the output and filter:
sudo python3 maildomains.py --year 2024 /var/log/mail.log* > /tmp/mailvol.csv
grep -E '^[0-9-]+,example\.com,' /tmp/mailvol.csv
From there, LibreOffice Calc, gnuplot, or a pandas one-liner will plot the
date against sent or total. I'm not going to prescribe a charting stack — the
CSV is the interface.
Running it on a schedule
A daily cron job that reports on yesterday's rotated log is the usual pattern. As
root (so it can read the logs), edit root's crontab with sudo crontab -e and add:
# 05:10 daily: append yesterday's per-domain counts to a running CSV
10 5 * * * /usr/bin/python3 /opt/scripts/maildomains.py /var/log/mail.log.1 >> /var/log/mailvol-history.csv
Note that this appends a fresh header row each run — if you want a single clean
header, initialise the file once by hand and strip the header line in the cron
command, or post-process with tail -n +2.
Verify it worked
Cross-check the script's numbers against a raw grep for one domain and status.
The counts should match:
# What the script reported as "sent" for example.com on a given day:
grep 'status=sent' /var/log/mail.log | grep -c 'to=<[^@>]*@example.com>'
If the two disagree, the usual culprit is the year assumption (rotated logs
from last year counted under this year) or a subdomain you didn't expect
(mail.example.com is a different domain string than example.com). Both are
visible in the CSV, so you can see exactly what was counted.
journald-only hosts
If you have no /var/log/mail.log, export the journal to a file the script can
read, then point the script at that file:
sudo journalctl -u postfix@- --no-pager -o short > /tmp/mail.journal
python3 maildomains.py --year 2024 /tmp/mail.journal
short output uses the same Mon DD HH:MM:SS host process[pid]: prefix the regex
expects. Confirm your unit name with systemctl list-units 'postfix*' first — the
templated postfix@- covers the default instance, but yours may differ. Check
man journalctl for the output-format options before relying on this in a job.
Undo
There's nothing to reverse. The script reads logs and prints text. The only
artefacts are files you choose to create (the redirected CSV or the cron history
file); delete those if you no longer want them, and remove the crontab line with
sudo crontab -e to stop the scheduled run.
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
Detect Spam Relay Abuse from Postfix Mail Logs
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.
Automate Postfix Transport Map Updates Without Downtime
This guide gives you a small bash script that rebuilds a Postfix transport map from its flat-file source, validates the configuration, and reloads Postfix so the new routing takes effect. postfix reload re-reads configuration and recycles daemons gracefully — it does not drop in-flight SMTP connections or stop the queue, which is why we use it instead of restart .
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.
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.




