How to Programmatically Parse Bounced Email Headers to Detect Soft/Hard Bounces in Postfix with VERP Implementation


4 views

When implementing email delivery systems, bounce processing remains one of the most complex tasks due to inconsistent header formats across mail servers. Major providers like Gmail, Outlook, and Yahoo each have their own way of reporting delivery failures.

The most important headers to examine in bounced emails:


Received: 
X-Failed-Recipients: 
Diagnostic-Code: 
Action: 
Status: 
Final-Recipient: 

A typical Postfix bounce might look like:


Content-Type: message/delivery-status

Action: failed
Status: 5.1.1
Final-Recipient: rfc822;user@example.com
Diagnostic-Code: smtp; 550 5.1.1 User unknown

Here's a practical solution using Python's email library:


import email
import re

def analyze_bounce(raw_email):
    msg = email.message_from_string(raw_email)
    
    # Extract VERP address from Return-Path
    return_path = msg['Return-Path']
    original_recipient = extract_verp(return_path) if return_path else None
    
    # Check for delivery status notification
    if msg.is_multipart():
        for part in msg.walk():
            if part.get_content_type() == 'message/delivery-status':
                status_part = part
                break
    else:
        status_part = msg
        
    # Parse status codes
    status_code = None
    diagnostic_code = ''
    
    if status_part:
        status_lines = status_part.get_payload().split('\n')
        for line in status_lines:
            if line.startswith('Status:'):
                status_code = line.split(' ', 1)[1].strip()
            elif line.startswith('Diagnostic-Code:'):
                diagnostic_code = line.split(':', 1)[1].strip()
    
    # Classify bounce type
    bounce_type = classify_bounce(status_code, diagnostic_code)
    
    return {
        'original_recipient': original_recipient,
        'status_code': status_code,
        'diagnostic_code': diagnostic_code,
        'bounce_type': bounce_type
    }

def extract_verp(return_path):
    # Example: bounce+user=example.com@verp.example.com
    match = re.search(r'bounce\+(.*?)@', return_path)
    return match.group(1).replace('=', '@') if match else None

def classify_bounce(status, diagnostic):
    if not status:
        return 'unknown'
    
    # Check SMTP status codes (RFC 3463)
    if status.startswith('5.'):
        return 'hard'
    elif status.startswith('4.'):
        return 'soft'
    
    # Fallback to diagnostic pattern matching
    hard_bounce_phrases = [
        'user unknown',
        'mailbox unavailable',
        'no such user',
        'invalid address',
        'account disabled'
    ]
    
    if any(phrase in diagnostic.lower() for phrase in hard_bounce_phrases):
        return 'hard'
    
    return 'soft'

Here's how to process different types of bounces:


# Example 1: Gmail hard bounce
{
  "status_code": "5.1.1",
  "diagnostic_code": "smtp; 550-5.1.1 The email account does not exist",
  "bounce_type": "hard"
}

# Example 2: Temporary mailbox full
{
  "status_code": "4.2.2",
  "diagnostic_code": "smtp; 452 4.2.2 mailbox full",
  "bounce_type": "soft"
}

To automate the process, create a transport map in Postfix:


# /etc/postfix/transport
bounces@yourdomain.com bounce_processor:

Then define the pipe transport in master.cf:


bounce_processor unix - n n - - pipe
  flags=R user=youruser argv=/path/to/bounce_processor.py

Best practices for bounce handling:

  • Immediately suppress hard bounces
  • Track soft bounce counts (typically 3 strikes)
  • Implement automated list hygiene procedures
  • Monitor bounce rates (should stay below 2%)

When dealing with bounced emails, it's crucial to understand the difference between soft and hard bounces:

  • Soft bounce: Temporary delivery failure (e.g., mailbox full, server timeout)
  • Hard bounce: Permanent failure (e.g., invalid address, domain doesn't exist)

Here's a basic Postfix configuration for VERP:

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

# /etc/postfix/virtual
bounce@example.com bounce

Create a processing script (/usr/local/bin/bounce_handler.py):

#!/usr/bin/env python3
import sys
import email
import re

def parse_bounce(message):
    msg = email.message_from_string(message)
    for part in msg.walk():
        if part.get_content_type() == 'message/delivery-status':
            status = part.get_payload()
            return analyze_status(status)
    return None

def analyze_status(status_text):
    # Common patterns
    patterns = {
        'hard': [
            r'5\d{2}\s',
            r'permanent\sfailure',
            r'user\sunknown',
            r'no\ssuch\suser',
            r'account\sdisabled'
        ],
        'soft': [
            r'4\d{2}\s',
            r'temporary\sfailure',
            r'mailbox\sfull',
            r'exceeded\squota'
        ]
    }
    
    for category, regex_list in patterns.items():
        for regex in regex_list:
            if re.search(regex, status_text, re.IGNORECASE):
                return category
    return 'unknown'

if __name__ == '__main__':
    message = sys.stdin.read()
    result = parse_bounce(message)
    if result:
        print(f"Bounce type: {result}")

Here are typical headers you'll encounter:

# Hard bounce example
X-Postfix; 550 5.1.1 user unknown
Final-Recipient: rfc822; baduser@example.com
Action: failed
Status: 5.1.1

# Soft bounce example
X-Postfix; 452 4.2.2 mailbox full
Final-Recipient: rfc822; validuser@example.com
Action: delayed
Status: 4.2.2

Add this to your Postfix master.cf to pipe bounces to your script:

bounce unix - n n - - pipe
  flags=R user=nobody argv=/usr/local/bin/bounce_handler.py
  • Implement exponential backoff for soft bounces
  • Consider using third-party libraries like Flanker for more sophisticated parsing
  • Log bounce data for analytics and reputation monitoring

Key metrics to track:

# Sample monitoring query
SELECT 
    COUNT(CASE WHEN bounce_type = 'hard' THEN 1 END) as hard_bounces,
    COUNT(CASE WHEN bounce_type = 'soft' THEN 1 END) as soft_bounces,
    COUNT(*) as total_bounces,
    (COUNT(CASE WHEN bounce_type = 'hard' THEN 1 END)*100.0/COUNT(*)) as hard_bounce_rate
FROM bounces
WHERE timestamp > NOW() - INTERVAL '7 days';