Most mail admins only care about bounce codes after a big client complains their transactional emails vanished. By then you're digging through weeks of logs. Setting up proper bounce code handling before that happens saves hours and keeps your sender reputation clean.
SMTP bounce codes are three-digit responses that tell your mail server why a message failed. A 550 means permanent failure (bad address), a 451 means temporary (mailbox full), and a 421 means the remote server is overloaded. Your server needs to parse these codes, log them correctly, and decide whether to retry or give up. Without that logic in place, you'll retry permanent failures for days and waste IP reputation on dead addresses.
This walkthrough assumes you're running Postfix on a Linux VPS with root access. If you're on a different MTA, the concepts translate but the config files will differ.
Step 1: Verify your MTA is logging bounce responses
First, confirm that your mail transfer agent is actually capturing SMTP conversations. For Postfix, check /var/log/mail.log or /var/log/maillog depending on your distro.
sudo tail -f /var/log/mail.log | grep "status="
Send a test email to a nonexistent address at a real domain. You should see lines like status=bounced (host mx.example.com said: 550 5.1.1 User unknown). If you don't see detailed SMTP responses, your logging level is too low.
Edit /etc/postfix/main.cf and make sure you have:
smtpd_sasl_loglevel = 1
smtp_sasl_loglevel = 1
Reload Postfix with sudo postfix reload and test again.
Step 2: Install a bounce processor
Postfix logs everything but doesn't automatically parse bounce codes into a database or alert system. You need a layer on top. Two common approaches: write a log parser in Python or Perl, or use an existing tool like pflogsumm for summaries.
For a quick audit, install pflogsumm:
sudo apt install pflogsumm # Debian/Ubuntu
sudo yum install postfix-perl-scripts # RHEL/CentOS
Run it against yesterday's logs:
sudo pflogsumm -d yesterday /var/log/mail.log
You'll see a breakdown of delivery status by code. This won't give you per-message tracking but it surfaces patterns fast.
Step 3: Map permanent vs temporary codes
SMTP codes fall into two buckets. Permanent failures (5xx codes) mean stop trying; temporary failures (4xx codes) mean retry later. Your bounce handler must distinguish them or you'll hammer mailboxes that are offline for maintenance.
Common permanent codes: - 550: User unknown, mailbox not found - 551: User not local, relay denied - 552: Mailbox full (sometimes treated as temporary) - 553: Bad sender address
Common temporary codes: - 421: Service not available, try again - 450: Mailbox unavailable (temporary) - 451: Local error, greylisting - 452: Insufficient storage
Create a reference file at /etc/postfix/bounce_codes.txt with this mapping. You'll script against it in the next step.
Step 4: Write a log parser script
Here's a minimal Python script that tails the mail log, extracts bounce codes, and writes them to a CSV for later analysis. Save it as /usr/local/bin/bounce_parser.py:
#!/usr/bin/env python3
import re
import sys
import csv
from datetime import datetime
pattern = re.compile(r'status=bounced \(host .+ said: (\d{3}) (.+?)\)')
output = '/var/log/bounces.csv'
with open(output, 'a', newline='') as csvfile:
writer = csv.writer(csvfile)
for line in sys.stdin:
match = pattern.search(line)
if match:
code = match.group(1)
reason = match.group(2).strip()
timestamp = datetime.now().isoformat()
writer.writerow([timestamp, code, reason])
csvfile.flush()
Make it executable:
sudo chmod +x /usr/local/bin/bounce_parser.py
Test it by piping a sample log:
grep "status=bounced" /var/log/mail.log | python3 /usr/local/bin/bounce_parser.py
Check /var/log/bounces.csv for output.
Step 5: Set up a systemd service to run the parser
Running the parser manually is tedious. Create a systemd unit that tails the log continuously. Save this as /etc/systemd/system/bounce-parser.service:
[Unit]
Description=SMTP Bounce Code Parser
After=postfix.service
[Service]
Type=simple
ExecStart=/bin/bash -c 'tail -F /var/log/mail.log | /usr/local/bin/bounce_parser.py'
Restart=always
User=root
[Install]
WantedBy=multi-user.target
Enable and start it:
sudo systemctl daemon-reload
sudo systemctl enable bounce-parser
sudo systemctl start bounce-parser
Now every bounce gets logged to the CSV in real time.
Step 6: Configure Postfix retry intervals
By default Postfix retries failed messages on an exponential backoff schedule for up to five days. That's fine for temporary failures but wasteful for permanent ones. You want to give up fast on 5xx codes and be patient with 4xx codes.
Edit /etc/postfix/main.cf and adjust:
maximal_queue_lifetime = 2d
bounce_queue_lifetime = 1d
This tells Postfix to keep retrying for two days max, and to hold bounce notifications for one day before generating a final failure notice. For production transactional mail, I've seen teams go as low as six hours on bounce_queue_lifetime to catch problems faster.
Reload:
sudo postfix reload
Step 7: Customize bounce message templates
When Postfix sends a delivery status notification (DSN) back to the sender, it uses a default template that's not always helpful. You can override it to include more context.
Copy the default templates:
sudo cp /etc/postfix/bounce.cf.default /etc/postfix/bounce.cf
Edit /etc/postfix/bounce.cf and look for the failure_template section. Add your own boilerplate explaining what the user should check (SPF, DMARC, recipient address). Keep it under 10 lines or mail clients will truncate it.
Tell Postfix to use your custom template:
bounce_template_file = /etc/postfix/bounce.cf
Reload again.
What if your bounce parser crashes?
Systemd will restart it automatically because we set Restart=always in the unit file. But if the CSV grows unbounded, you'll fill the disk. Add a logrotate rule at /etc/logrotate.d/bounces:
/var/log/bounces.csv {
daily
rotate 30
compress
delaycompress
missingok
notifempty
create 0640 root root
}
Test it:
sudo logrotate -f /etc/logrotate.d/bounces
You'll see bounces.csv.1.gz appear after the first rotation.
Step 8: Integrate with monitoring or alerting
A CSV file is better than nothing but you want alerts when bounce rates spike. If you run Prometheus, export a metric from the CSV. If you use cron, write a daily script that counts 5xx codes and emails you if they exceed a threshold.
Here's a one-liner for a cron alert:
BOUNCES=$(grep -c "^5[0-9][0-9]" /var/log/bounces.csv)
if [ "$BOUNCES" -gt 100 ]; then echo "High bounce rate: $BOUNCES" | mail -s "Bounce alert" [email protected]; fi
Add it to root's crontab to run at 9 AM daily:
0 9 * * * /usr/local/bin/bounce_alert.sh
Adjust the threshold based on your volume. For a small WordPress site sending 50 emails a day, even 10 bounces is a red flag.
Step 9: Test with real scenarios
Send test emails to addresses you control and verify the codes are logged correctly. Try:
- A nonexistent user at Gmail (should return 550)
- A full mailbox at Outlook (often 552 or 450)
- A greylisted server (421 or 451)
Check /var/log/bounces.csv and /var/log/mail.log after each test. If the codes don't match what you expect, the remote server might be misconfigured or your parser regex is too narrow.
Step 10: Document runbook steps for your team
Write down where the logs live, how to restart the parser service, and how to interpret common codes. In support tickets I handled, the usual culprit was a junior admin who didn't know that a 421 means "wait and retry," so they panicked and disabled the queue.
Keep a one-pager in your internal wiki:
- Bounce logs:
/var/log/bounces.csv, rotated daily, kept 30 days - Service name:
bounce-parser.service - Quick restart:
sudo systemctl restart bounce-parser - 5xx = permanent, stop sending; 4xx = temporary, retry later
- Alerting threshold: 100+ bounces per day triggers email
Anyone on call should be able to find that page in under 30 seconds.
What to check first when bounce handling breaks
If your parser stops writing to the CSV, check three things in order:
- Is the
bounce-parser.servicerunning?sudo systemctl status bounce-parser - Does
/var/log/mail.logstill exist and is it being written to? Some log rotations move it and break the tail. - Did someone change the log format in Postfix? Run your test regex against a recent log line to confirm it still matches.
Nine times out of ten it's a rotated log path or a stopped service. Fix that and you're back online in under a minute.
