236 lines
8.0 KiB
Python
236 lines
8.0 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Postfix Content Filter for Internal Email Processing
|
|
Handles forwarding and auto-reply for local deliveries
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import smtplib
|
|
import logging
|
|
from email import message_from_binary_file
|
|
from email.mime.text import MIMEText
|
|
from email.utils import parseaddr, formatdate, make_msgid
|
|
from datetime import datetime, timedelta
|
|
from io import BytesIO
|
|
|
|
# Setup logging
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(levelname)s - %(message)s',
|
|
handlers=[
|
|
logging.FileHandler('/var/log/mail/content_filter.log'),
|
|
logging.StreamHandler(sys.stderr)
|
|
]
|
|
)
|
|
|
|
# AWS Configuration
|
|
AWS_REGION = 'us-east-2'
|
|
DYNAMODB_RULES_TABLE = 'email-rules'
|
|
|
|
# SMTP Configuration
|
|
REINJECT_HOST = 'localhost'
|
|
REINJECT_PORT = 10026
|
|
|
|
# Cache for DynamoDB rules (TTL 5 minutes)
|
|
RULES_CACHE = {}
|
|
CACHE_TTL = timedelta(minutes=5)
|
|
|
|
# Initialize boto3 (lazy import to catch errors)
|
|
try:
|
|
import boto3
|
|
dynamodb = boto3.resource('dynamodb', region_name=AWS_REGION)
|
|
rules_table = dynamodb.Table(DYNAMODB_RULES_TABLE)
|
|
DYNAMODB_AVAILABLE = True
|
|
logging.info("DynamoDB connection initialized")
|
|
except Exception as e:
|
|
DYNAMODB_AVAILABLE = False
|
|
logging.error(f"DynamoDB initialization failed: {e}")
|
|
|
|
def get_email_rules(email_address):
|
|
"""Fetch forwarding and auto-reply rules from DynamoDB with caching"""
|
|
if not DYNAMODB_AVAILABLE:
|
|
return {}
|
|
|
|
now = datetime.now()
|
|
cache_key = email_address.lower()
|
|
if cache_key in RULES_CACHE and now - RULES_CACHE[cache_key]['time'] < CACHE_TTL:
|
|
logging.debug(f"Cache hit for {email_address}")
|
|
return RULES_CACHE[cache_key]['rules']
|
|
|
|
try:
|
|
response = rules_table.get_item(Key={'email_address': email_address})
|
|
item = response.get('Item', {})
|
|
if item:
|
|
logging.info(f"Rules found for {email_address}: forwards={len(item.get('forwards', []))}, ooo={item.get('ooo_active', False)}")
|
|
RULES_CACHE[cache_key] = {'rules': item, 'time': now}
|
|
return item
|
|
except Exception as e:
|
|
logging.error(f"DynamoDB error for {email_address}: {e}")
|
|
return {}
|
|
|
|
def should_send_autoreply(original_msg, sender_addr):
|
|
"""Check if we should send auto-reply to this sender"""
|
|
sender_lower = sender_addr.lower()
|
|
|
|
# Don't reply to automated senders
|
|
blocked_patterns = [
|
|
'mailer-daemon',
|
|
'postmaster',
|
|
'noreply',
|
|
'no-reply',
|
|
'donotreply',
|
|
'bounce',
|
|
'amazonses.com'
|
|
]
|
|
|
|
for pattern in blocked_patterns:
|
|
if pattern in sender_lower:
|
|
logging.info(f"Skipping auto-reply to automated sender: {sender_addr}")
|
|
return False
|
|
|
|
# Check for auto-submitted header to prevent loops (RFC 3834)
|
|
if original_msg.get('Auto-Submitted', '').startswith('auto-'):
|
|
logging.info(f"Skipping auto-reply due to Auto-Submitted header: {sender_addr}")
|
|
return False
|
|
|
|
return True
|
|
|
|
def send_autoreply(original_msg, recipient_rules, recipient_addr):
|
|
"""Send auto-reply if enabled"""
|
|
if not recipient_rules.get('ooo_active'):
|
|
return
|
|
|
|
sender = original_msg.get('From')
|
|
if not sender:
|
|
logging.warning("No sender address, skipping auto-reply")
|
|
return
|
|
|
|
# Extract email from "Name <email>" format
|
|
sender_name, sender_addr = parseaddr(sender)
|
|
|
|
if not should_send_autoreply(original_msg, sender_addr):
|
|
return
|
|
|
|
subject = original_msg.get('Subject', 'No Subject')
|
|
message_id = original_msg.get('Message-ID')
|
|
|
|
# Get auto-reply message
|
|
ooo_message = recipient_rules.get('ooo_message', 'I am currently unavailable.')
|
|
content_type = recipient_rules.get('ooo_content_type', 'text')
|
|
|
|
# Create auto-reply
|
|
if content_type == 'html':
|
|
from email.mime.multipart import MIMEMultipart
|
|
from email.mime.text import MIMEText as MIMETextPart
|
|
reply = MIMEMultipart('alternative')
|
|
reply.attach(MIMETextPart(ooo_message, 'plain'))
|
|
reply.attach(MIMETextPart(ooo_message, 'html'))
|
|
else:
|
|
reply = MIMEText(ooo_message, 'plain', 'utf-8')
|
|
|
|
reply['From'] = recipient_addr
|
|
reply['To'] = sender_addr
|
|
reply['Subject'] = f"Automatic Reply: {subject}"
|
|
reply['Date'] = formatdate(localtime=True)
|
|
reply['Message-ID'] = make_msgid()
|
|
reply['Auto-Submitted'] = 'auto-replied' # RFC 3834
|
|
reply['Precedence'] = 'bulk'
|
|
|
|
if message_id:
|
|
reply['In-Reply-To'] = message_id
|
|
reply['References'] = message_id
|
|
|
|
# Send via local SMTP
|
|
try:
|
|
with smtplib.SMTP(REINJECT_HOST, REINJECT_PORT, timeout=30) as smtp:
|
|
smtp.send_message(reply)
|
|
logging.info(f"✓ Sent auto-reply from {recipient_addr} to {sender_addr}")
|
|
except Exception as e:
|
|
logging.error(f"✗ Auto-reply failed: {e}")
|
|
|
|
def send_forwards(original_msg_bytes, recipient_rules, recipient_addr):
|
|
"""Forward email to configured addresses"""
|
|
forwards = recipient_rules.get('forwards', [])
|
|
if not forwards:
|
|
return
|
|
|
|
for forward_addr in forwards:
|
|
try:
|
|
# Parse message again for clean forwarding
|
|
msg = message_from_binary_file(BytesIO(original_msg_bytes))
|
|
|
|
# Add forwarding headers
|
|
msg['X-Forwarded-For'] = recipient_addr
|
|
msg['X-Original-To'] = recipient_addr
|
|
|
|
# Send via local SMTP
|
|
with smtplib.SMTP(REINJECT_HOST, REINJECT_PORT, timeout=30) as smtp:
|
|
smtp.sendmail(
|
|
from_addr=recipient_addr,
|
|
to_addrs=[forward_addr],
|
|
msg=msg.as_bytes()
|
|
)
|
|
logging.info(f"✓ Forwarded from {recipient_addr} to {forward_addr}")
|
|
except Exception as e:
|
|
logging.error(f"✗ Forward to {forward_addr} failed: {e}")
|
|
|
|
def main():
|
|
"""Main content filter logic"""
|
|
if len(sys.argv) < 3:
|
|
logging.error("Usage: content_filter.py <sender> <recipient1> [recipient2] ...")
|
|
sys.exit(1)
|
|
|
|
sender = sys.argv[1]
|
|
recipients = sys.argv[2:]
|
|
|
|
logging.info(f"Processing email from {sender} to {', '.join(recipient)}")
|
|
|
|
# Read email from stdin
|
|
try:
|
|
msg_bytes = sys.stdin.buffer.read()
|
|
msg = message_from_binary_file(BytesIO(msg_bytes))
|
|
except Exception as e:
|
|
logging.error(f"Failed to read email: {e}")
|
|
sys.exit(75) # EX_TEMPFAIL
|
|
|
|
# Process each recipient
|
|
for recipient in recipients:
|
|
try:
|
|
# Check if mail is internal (same domain) - skip if external
|
|
_, recipient_domain = parseaddr(recipient)[1].rsplit('@', 1) if '@' in recipient else ('', '')
|
|
_, sender_domain = parseaddr(sender)[1].rsplit('@', 1) if '@' in sender else ('', '')
|
|
if recipient_domain != sender_domain:
|
|
logging.info(f"Skipping external mail to {recipient} (sender domain: {sender_domain})")
|
|
continue
|
|
|
|
rules = get_email_rules(recipient)
|
|
|
|
if rules:
|
|
# Send auto-reply if configured
|
|
send_autoreply(msg, rules, recipient)
|
|
|
|
# Send forwards if configured
|
|
send_forwards(msg_bytes, rules, recipient)
|
|
else:
|
|
logging.debug(f"No rules for {recipient}")
|
|
|
|
except Exception as e:
|
|
logging.error(f"Error processing rules for {recipient}: {e}")
|
|
|
|
# Re-inject original email for normal delivery
|
|
try:
|
|
with smtplib.SMTP(REINJECT_HOST, REINJECT_PORT, timeout=30) as smtp:
|
|
smtp.sendmail(sender, recipients, msg_bytes)
|
|
logging.info(f"✓ Delivered to {', '.join(recipient)}")
|
|
sys.exit(0)
|
|
except Exception as e:
|
|
logging.error(f"✗ Delivery failed: {e}")
|
|
sys.exit(75) # EX_TEMPFAIL - Postfix will retry
|
|
|
|
if __name__ == '__main__':
|
|
try:
|
|
main()
|
|
except Exception as e:
|
|
logging.error(f"Fatal error: {e}")
|
|
sys.exit(75) |