How to Implement Bounce Email Handling in Postfix for Newsletter Applications


2 views

When managing email newsletters, handling bounce messages is crucial for maintaining list hygiene. The standard approach involves using unique return-path addresses like bounce-123456789@example.com to track delivery failures. In Postfix, we can implement this through several configuration steps.

To capture all bounce messages in a single mailbox, we'll use Postfix's virtual aliases. Here's the complete setup:

# /etc/postfix/virtual
bounce-*@example.com   bouncebox

Then update the main Postfix configuration:

# /etc/postfix/main.cf
virtual_alias_maps = hash:/etc/postfix/virtual

After making these changes, run:

postmap /etc/postfix/virtual
postfix reload

When sending newsletters, you should set the Return-Path header to your bounce tracking address. Here's an example in PHP:

$headers = [
    'Return-Path: bounce-'.time().'@example.com',
    'From: newsletter@example.com',
    'Content-Type: text/html; charset=UTF-8'
];
mail($to, $subject, $message, implode("\r\n", $headers));

There are two main types of bounces:

  • Hard bounces: Permanent delivery failures (invalid addresses, domain doesn't exist)
  • Soft bounces: Temporary issues (mailbox full, server down)

Here's a Python script example to parse bounce messages:

import re
import email

def parse_bounce(message):
    msg = email.message_from_string(message)
    subject = msg['subject']
    body = msg.get_payload()
    
    # Check for common bounce patterns
    if '550' in subject or 'permanent failure' in body.lower():
        return 'hard'
    elif '421' in subject or 'temporary failure' in body.lower():
        return 'soft'
    return None

Follow these rules for bounce handling:

  • Remove addresses after 1 hard bounce
  • Remove addresses after 3 soft bounces within 30 days
  • Implement a quarantine period for soft bounces

Variable Envelope Return Path (VERP) provides more granular tracking. Here's how to implement it in Postfix:

# /etc/postfix/main.cf
recipient_delimiter = +

Then use addresses like newsletter+user=domain.com@example.com where the original recipient is encoded in the local part.


When sending newsletters through Postfix, bounce management is critical for maintaining list hygiene. The common pattern involves:

Return-Path: bounce-123456789@example.com
From: newsletter@yourdomain.com
To: subscriber@gmail.com

To capture all bounce-* prefixed emails into a single mailbox, add these configurations to main.cf:

# Enable recipient address rewriting
recipient_canonical_maps = regexp:/etc/postfix/recipient_canonical

# Virtual mailbox configuration
virtual_alias_maps = regexp:/etc/postfix/virtual_aliases
virtual_mailbox_base = /var/mail/vhosts
virtual_mailbox_domains = example.com
virtual_mailbox_maps = hash:/etc/postfix/virtual_mailboxes

Create /etc/postfix/recipient_canonical with:

/^bounce-.*@example\.com$/ bounce-catchall@example.com

Here's a Python script to parse Postfix bounce messages:

import email
import re
from mailbox import mbox

def parse_bounces(mbox_path):
    pattern = re.compile(r'bounce-(\d+)@example\.com')
    bounces = {'hard': set(), 'soft': set()}

    for message in mbox(mbox_path):
        if message['X-Failed-Recipients']:
            original_id = pattern.search(message['Return-Path']).group(1)
            
            if '5.1.1' in message.get_payload():  # Hard bounce code
                bounces['hard'].add(original_id)
            else:  # Soft bounce
                bounces['soft'].add(original_id)
    
    return bounces

Key SMTP codes to identify:

  • Hard bounces (permanent failures): 5.1.1, 5.1.2, 5.4.1
  • Soft bounces (temporary issues): 4.2.2, 4.3.1, 4.4.7

Recommended handling strategy:

def manage_subscription(bounce_data):
    for sub_id in bounce_data['hard']:
        db.execute("UPDATE subscribers SET status='bounced' WHERE id=?", (sub_id,))
    
    for sub_id in bounce_data['soft']:
        soft_count = db.execute("SELECT soft_bounces FROM subscribers WHERE id=?", (sub_id,))
        if soft_count >= 3:
            db.execute("UPDATE subscribers SET status='bounced' WHERE id=?", (sub_id,))

For better bounce classification with SpamAssassin:

content_filter = smtp-amavis:[127.0.0.1]:10024

# In amavisd.conf:
$bounce_killer_score = 6.3;
$sa_tag_level_deflt = -999;
$sa_tag2_level_deflt = 4.0;