Skip to content
Back to Blog
Linux & Server11 min read

How to Set Up SMTP Bounce Codes in 10 Steps (2026)

A literal step-by-step walkthrough to configure SMTP bounce code handling on your mail server, from installing tools to testing delivery failures.

Written by Abdul AbrorTechnical Hosting Support Engineer
How to Set Up SMTP Bounce Codes in 10 Steps (2026)
On this page

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:

  1. Is the bounce-parser.service running? sudo systemctl status bounce-parser
  2. Does /var/log/mail.log still exist and is it being written to? Some log rotations move it and break the tail.
  3. 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.

FAQ

Q: Do I need a separate bounce handler for each domain on my server?

No. Postfix handles all domains in one queue and the parser reads the unified log. You can filter by recipient domain in your CSV analysis if you need per-domain metrics.

Q: What if I'm using Exim or Sendmail instead of Postfix?

The concepts are identical but the config syntax differs. Exim uses retry rules in exim.conf, and Sendmail uses sendmail.cf. The log format will also vary, so adjust the regex in your parser.

Q: How do I handle soft bounces that turn into hard bounces after repeated failures?

Track the recipient address and count of 4xx responses. After three or four retries over 24 hours with no success, treat it as a hard bounce and suppress future sends. Postfix doesn't do this automatically; you'll need custom logic in your application layer or a wrapper script.

Q: Should I retry 552 (mailbox full) codes?

Yes, but with a longer backoff. A full mailbox is often temporary, but if it stays full for days the user has likely abandoned it. Retry for 48 hours, then give up.

Q: Can I use a third-party service like SendGrid to handle bounces instead?

Absolutely. Transactional email providers parse bounces for you and expose them via webhook or API. If you're sending high volume, that's easier than rolling your own. But if you're already running your own MTA for cost or control reasons, the setup above costs nothing.