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