How to Implement Bounce Email Handling in Postfix for Newsletter Applications


13 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;