email-amazon/unified-worker/unified_worker.py

998 lines
36 KiB
Python

#!/usr/bin/env python3
"""
unified_worker.py - Multi-Domain Email Worker (Full Featured)
Features:
- Multi-Domain parallel processing via Thread Pool
- Bounce Detection & Rewriting (SES MAILER-DAEMON handling)
- Auto-Reply / Out-of-Office (from DynamoDB email-rules)
- Email Forwarding (from DynamoDB email-rules)
- SMTP Connection Pooling
- Prometheus Metrics
- Graceful Shutdown
DynamoDB Tables:
- ses-outbound-messages: Tracking für Bounce-Korrelation
- email-rules: Forwards und Auto-Reply Regeln
Schema email-rules:
{
"email": "user@domain.com", # Partition Key
"rule_type": "forward|autoreply", # Sort Key
"enabled": true,
"target": "other@example.com", # Für forwards
"subject": "Out of Office", # Für autoreply
"body": "I am currently...", # Für autoreply
"start_date": "2025-01-01", # Optional: Zeitraum
"end_date": "2025-01-15" # Optional: Zeitraum
}
Schema ses-outbound-messages:
{
"MessageId": "abc123...", # Partition Key (SES Message-ID)
"original_source": "sender@domain.com",
"recipients": ["recipient@other.com"],
"timestamp": "2025-01-01T12:00:00Z",
"bounceType": "Permanent", # Nach Bounce-Notification
"bounceSubType": "General",
"bouncedRecipients": ["recipient@other.com"]
}
"""
import os
import sys
import json
import time
import signal
import logging
import threading
import smtplib
import hashlib
import re
from queue import Queue, Empty
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from typing import List, Dict, Optional, Tuple, Any
from datetime import datetime, date
from email.parser import BytesParser
from email.policy import SMTP as SMTPPolicy
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.utils import formataddr, parseaddr, formatdate
import copy
import boto3
from botocore.exceptions import ClientError
from boto3.dynamodb.conditions import Key
# Optional: Prometheus Metrics
try:
from prometheus_client import start_http_server, Counter, Gauge, Histogram
PROMETHEUS_ENABLED = True
except ImportError:
PROMETHEUS_ENABLED = False
logging.warning("prometheus_client not installed - metrics disabled")
# ============================================
# CONFIGURATION
# ============================================
@dataclass
class Config:
"""Worker Configuration"""
# AWS
aws_region: str = os.environ.get('AWS_REGION', 'us-east-2')
# Domains to process
domains_list: str = os.environ.get('DOMAINS', '')
domains_file: str = os.environ.get('DOMAINS_FILE', '/etc/email-worker/domains.txt')
# Worker Settings
worker_threads: int = int(os.environ.get('WORKER_THREADS', '10'))
poll_interval: int = int(os.environ.get('POLL_INTERVAL', '20'))
max_messages: int = int(os.environ.get('MAX_MESSAGES', '10'))
visibility_timeout: int = int(os.environ.get('VISIBILITY_TIMEOUT', '300'))
# SMTP
smtp_host: str = os.environ.get('SMTP_HOST', 'localhost')
smtp_port: int = int(os.environ.get('SMTP_PORT', '25'))
smtp_use_tls: bool = os.environ.get('SMTP_USE_TLS', 'false').lower() == 'true'
smtp_user: str = os.environ.get('SMTP_USER', '')
smtp_pass: str = os.environ.get('SMTP_PASS', '')
smtp_pool_size: int = int(os.environ.get('SMTP_POOL_SIZE', '5'))
# DynamoDB Tables
rules_table: str = os.environ.get('DYNAMODB_RULES_TABLE', 'email-rules')
messages_table: str = os.environ.get('DYNAMODB_MESSAGES_TABLE', 'ses-outbound-messages')
# Bounce Handling
bounce_lookup_retries: int = int(os.environ.get('BOUNCE_LOOKUP_RETRIES', '3'))
bounce_lookup_delay: float = float(os.environ.get('BOUNCE_LOOKUP_DELAY', '1.0'))
# Auto-Reply
autoreply_from_name: str = os.environ.get('AUTOREPLY_FROM_NAME', 'Auto-Reply')
autoreply_cooldown_hours: int = int(os.environ.get('AUTOREPLY_COOLDOWN_HOURS', '24'))
# Monitoring
metrics_port: int = int(os.environ.get('METRICS_PORT', '8000'))
health_port: int = int(os.environ.get('HEALTH_PORT', '8080'))
config = Config()
# ============================================
# LOGGING
# ============================================
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] [%(threadName)s] %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger(__name__)
# ============================================
# METRICS
# ============================================
if PROMETHEUS_ENABLED:
emails_processed = Counter('emails_processed_total', 'Total emails processed', ['domain', 'status'])
emails_in_flight = Gauge('emails_in_flight', 'Emails currently being processed')
processing_time = Histogram('email_processing_seconds', 'Time to process email', ['domain'])
queue_size = Gauge('queue_messages_available', 'Messages in queue', ['domain'])
bounces_processed = Counter('bounces_processed_total', 'Bounce notifications processed', ['domain', 'type'])
autoreplies_sent = Counter('autoreplies_sent_total', 'Auto-replies sent', ['domain'])
forwards_sent = Counter('forwards_sent_total', 'Forwards sent', ['domain'])
# ============================================
# AWS CLIENTS
# ============================================
sqs = boto3.client('sqs', region_name=config.aws_region)
s3 = boto3.client('s3', region_name=config.aws_region)
ses = boto3.client('ses', region_name=config.aws_region)
# DynamoDB
dynamodb = boto3.resource('dynamodb', region_name=config.aws_region)
try:
rules_table = dynamodb.Table(config.rules_table)
messages_table = dynamodb.Table(config.messages_table)
# Test connection
rules_table.table_status
messages_table.table_status
DYNAMODB_AVAILABLE = True
logger.info(f"DynamoDB connected: {config.rules_table}, {config.messages_table}")
except Exception as e:
DYNAMODB_AVAILABLE = False
rules_table = None
messages_table = None
logger.warning(f"DynamoDB not available: {e}")
# Auto-Reply Cooldown Cache (in-memory, thread-safe)
autoreply_cache: Dict[str, datetime] = {}
autoreply_cache_lock = threading.Lock()
# ============================================
# SMTP CONNECTION POOL
# ============================================
class SMTPPool:
"""Thread-safe SMTP Connection Pool"""
def __init__(self, host: str, port: int, pool_size: int = 5):
self.host = host
self.port = port
self.pool_size = pool_size
self._pool: Queue = Queue(maxsize=pool_size)
self._lock = threading.Lock()
self._initialized = False
def _create_connection(self) -> Optional[smtplib.SMTP]:
"""Create new SMTP connection"""
try:
conn = smtplib.SMTP(self.host, self.port, timeout=30)
conn.ehlo()
if config.smtp_use_tls:
conn.starttls()
conn.ehlo()
if config.smtp_user and config.smtp_pass:
conn.login(config.smtp_user, config.smtp_pass)
return conn
except Exception as e:
logger.error(f"Failed to create SMTP connection: {e}")
return None
def initialize(self):
"""Pre-create connections"""
if self._initialized:
return
for _ in range(self.pool_size):
conn = self._create_connection()
if conn:
self._pool.put(conn)
self._initialized = True
logger.info(f"SMTP pool initialized with {self._pool.qsize()} connections")
def get_connection(self, timeout: float = 5.0) -> Optional[smtplib.SMTP]:
"""Get connection from pool"""
try:
conn = self._pool.get(timeout=timeout)
try:
conn.noop()
return conn
except:
conn = self._create_connection()
return conn
except Empty:
return self._create_connection()
def return_connection(self, conn: smtplib.SMTP):
"""Return connection to pool"""
if conn is None:
return
try:
self._pool.put_nowait(conn)
except:
try:
conn.quit()
except:
pass
def close_all(self):
"""Close all connections"""
while not self._pool.empty():
try:
conn = self._pool.get_nowait()
conn.quit()
except:
pass
smtp_pool = SMTPPool(config.smtp_host, config.smtp_port, config.smtp_pool_size)
# ============================================
# HELPER FUNCTIONS
# ============================================
def domain_to_queue_name(domain: str) -> str:
return domain.replace('.', '-') + '-queue'
def domain_to_bucket_name(domain: str) -> str:
return domain.replace('.', '-') + '-emails'
def get_queue_url(domain: str) -> Optional[str]:
queue_name = domain_to_queue_name(domain)
try:
response = sqs.get_queue_url(QueueName=queue_name)
return response['QueueUrl']
except ClientError as e:
if e.response['Error']['Code'] == 'AWS.SimpleQueueService.NonExistentQueue':
logger.warning(f"Queue not found for domain: {domain}")
else:
logger.error(f"Error getting queue URL for {domain}: {e}")
return None
def load_domains() -> List[str]:
"""Load domains from config"""
domains = []
if config.domains_list:
domains.extend([d.strip() for d in config.domains_list.split(',') if d.strip()])
if os.path.exists(config.domains_file):
with open(config.domains_file, 'r') as f:
for line in f:
domain = line.strip()
if domain and not domain.startswith('#'):
domains.append(domain)
domains = list(set(domains))
logger.info(f"Loaded {len(domains)} domains")
return domains
def extract_email_address(header_value: str) -> str:
"""Extract pure email address from header like 'Name <email@domain.com>'"""
if not header_value:
return ''
name, addr = parseaddr(header_value)
return addr.lower() if addr else header_value.lower()
# ============================================
# BOUNCE HANDLING
# ============================================
def is_ses_bounce_notification(parsed_email) -> bool:
"""Check if email is from SES MAILER-DAEMON"""
from_header = (parsed_email.get('From') or '').lower()
return 'mailer-daemon@' in from_header and 'amazonses.com' in from_header
def get_bounce_info_from_dynamodb(message_id: str) -> Optional[Dict]:
"""
Look up original sender info from DynamoDB for bounce correlation.
The message_id here is from the bounce notification, we need to find
the original outbound message.
"""
if not DYNAMODB_AVAILABLE or not messages_table:
return None
for attempt in range(config.bounce_lookup_retries):
try:
# Message-ID könnte verschiedene Formate haben
# Versuche mit und ohne < > Klammern
clean_id = message_id.strip('<>').split('@')[0]
response = messages_table.get_item(Key={'MessageId': clean_id})
item = response.get('Item')
if item:
logger.info(f"Found bounce info for {clean_id}")
return {
'original_source': item.get('original_source', ''),
'recipients': item.get('recipients', []),
'bounceType': item.get('bounceType', 'Unknown'),
'bounceSubType': item.get('bounceSubType', 'Unknown'),
'bouncedRecipients': item.get('bouncedRecipients', []),
'timestamp': item.get('timestamp', '')
}
if attempt < config.bounce_lookup_retries - 1:
logger.debug(f"Bounce record not found, retry {attempt + 1}...")
time.sleep(config.bounce_lookup_delay)
except Exception as e:
logger.error(f"DynamoDB error looking up bounce: {e}")
if attempt < config.bounce_lookup_retries - 1:
time.sleep(config.bounce_lookup_delay)
return None
def rewrite_bounce_headers(parsed_email, bounce_info: Dict) -> Tuple[Any, bool]:
"""
Rewrite bounce email headers so the recipient sees it correctly.
Problem: SES sends bounces FROM mailer-daemon@amazonses.com
but we want the user to see FROM the bounced address
Returns: (modified_email, was_modified)
"""
if not bounce_info:
return parsed_email, False
original_source = bounce_info.get('original_source', '')
bounced_recipients = bounce_info.get('bouncedRecipients', [])
bounce_type = bounce_info.get('bounceType', 'Unknown')
bounce_subtype = bounce_info.get('bounceSubType', 'Unknown')
if not bounced_recipients:
return parsed_email, False
# Use first bounced recipient as the new "From"
new_from = bounced_recipients[0] if isinstance(bounced_recipients[0], str) else bounced_recipients[0].get('emailAddress', '')
if not new_from:
return parsed_email, False
logger.info(f"Rewriting bounce: FROM {parsed_email.get('From')} -> {new_from}")
# Add diagnostic headers
parsed_email['X-Original-SES-From'] = parsed_email.get('From', '')
parsed_email['X-Bounce-Type'] = f"{bounce_type}/{bounce_subtype}"
parsed_email['X-Original-Recipient'] = original_source
# Rewrite From header
if 'From' in parsed_email:
del parsed_email['From']
parsed_email['From'] = new_from
# Add Reply-To if not present
if not parsed_email.get('Reply-To'):
parsed_email['Reply-To'] = new_from
# Improve subject for clarity
subject = parsed_email.get('Subject', '')
if 'delivery status' in subject.lower() or not subject:
if 'Subject' in parsed_email:
del parsed_email['Subject']
parsed_email['Subject'] = f"Undeliverable: Message to {new_from}"
return parsed_email, True
# ============================================
# EMAIL RULES (FORWARDS & AUTO-REPLY)
# ============================================
@dataclass
class EmailRule:
"""Represents a forward or auto-reply rule"""
email: str
rule_type: str # 'forward' or 'autoreply'
enabled: bool = True
target: str = '' # Forward destination
subject: str = '' # Auto-reply subject
body: str = '' # Auto-reply body
start_date: Optional[date] = None
end_date: Optional[date] = None
def is_active(self) -> bool:
"""Check if rule is currently active (considering date range)"""
if not self.enabled:
return False
today = date.today()
if self.start_date and today < self.start_date:
return False
if self.end_date and today > self.end_date:
return False
return True
def get_rules_for_email(email_address: str) -> List[EmailRule]:
"""Fetch all rules for an email address from DynamoDB"""
if not DYNAMODB_AVAILABLE or not rules_table:
return []
email_lower = email_address.lower()
rules = []
try:
response = rules_table.query(
KeyConditionExpression=Key('email').eq(email_lower)
)
for item in response.get('Items', []):
rule = EmailRule(
email=item.get('email', ''),
rule_type=item.get('rule_type', ''),
enabled=item.get('enabled', True),
target=item.get('target', ''),
subject=item.get('subject', ''),
body=item.get('body', ''),
)
# Parse dates if present
if item.get('start_date'):
try:
rule.start_date = datetime.strptime(item['start_date'], '%Y-%m-%d').date()
except:
pass
if item.get('end_date'):
try:
rule.end_date = datetime.strptime(item['end_date'], '%Y-%m-%d').date()
except:
pass
rules.append(rule)
logger.debug(f"Found {len(rules)} rules for {email_address}")
return rules
except Exception as e:
logger.error(f"Error fetching rules for {email_address}: {e}")
return []
def should_send_autoreply(recipient: str, sender: str) -> bool:
"""
Check if we should send an auto-reply.
Implements cooldown to avoid reply loops.
"""
# Don't reply to automated messages
sender_lower = sender.lower()
if any(x in sender_lower for x in ['noreply', 'no-reply', 'mailer-daemon', 'postmaster', 'bounce', 'notification']):
return False
# Check cooldown cache
cache_key = f"{recipient}:{sender}"
with autoreply_cache_lock:
last_reply = autoreply_cache.get(cache_key)
if last_reply:
hours_since = (datetime.now() - last_reply).total_seconds() / 3600
if hours_since < config.autoreply_cooldown_hours:
logger.debug(f"Auto-reply cooldown active for {cache_key}")
return False
# Update cache
autoreply_cache[cache_key] = datetime.now()
return True
def send_autoreply(rule: EmailRule, original_from: str, original_subject: str, domain: str) -> bool:
"""Send an auto-reply email via SES"""
if not should_send_autoreply(rule.email, original_from):
return False
try:
# Build reply message
reply_subject = rule.subject or f"Re: {original_subject}"
reply_body = rule.body or "This is an automated reply. I am currently unavailable."
# Add original subject reference if not in body
if original_subject and original_subject not in reply_body:
reply_body += f"\n\n---\nRegarding: {original_subject}"
msg = MIMEText(reply_body, 'plain', 'utf-8')
msg['Subject'] = reply_subject
msg['From'] = formataddr((config.autoreply_from_name, rule.email))
msg['To'] = original_from
msg['Date'] = formatdate(localtime=True)
msg['Auto-Submitted'] = 'auto-replied'
msg['X-Auto-Response-Suppress'] = 'All'
msg['Precedence'] = 'auto_reply'
# Send via SES
ses.send_raw_email(
Source=rule.email,
Destinations=[original_from],
RawMessage={'Data': msg.as_string()}
)
logger.info(f"✉ Auto-reply sent from {rule.email} to {original_from}")
if PROMETHEUS_ENABLED:
autoreplies_sent.labels(domain=domain).inc()
return True
except Exception as e:
logger.error(f"Failed to send auto-reply: {e}")
return False
def send_forward(rule: EmailRule, raw_email: bytes, original_from: str, original_subject: str, domain: str) -> bool:
"""Forward email to target address"""
if not rule.target:
return False
try:
# Parse original email
parsed = BytesParser(policy=SMTPPolicy).parsebytes(raw_email)
# Create forwarded message
fwd_msg = MIMEMultipart('mixed')
fwd_msg['Subject'] = f"Fwd: {original_subject or parsed.get('Subject', '(no subject)')}"
fwd_msg['From'] = rule.email
fwd_msg['To'] = rule.target
fwd_msg['Date'] = formatdate(localtime=True)
fwd_msg['X-Forwarded-For'] = rule.email
fwd_msg['X-Original-From'] = original_from
# Add forward header text
forward_header = MIMEText(
f"---------- Forwarded message ----------\n"
f"From: {parsed.get('From', 'Unknown')}\n"
f"Date: {parsed.get('Date', 'Unknown')}\n"
f"Subject: {parsed.get('Subject', '(no subject)')}\n"
f"To: {parsed.get('To', rule.email)}\n\n",
'plain', 'utf-8'
)
fwd_msg.attach(forward_header)
# Attach original message
from email.mime.message import MIMEMessage
fwd_msg.attach(MIMEMessage(parsed))
# Send via SES
ses.send_raw_email(
Source=rule.email,
Destinations=[rule.target],
RawMessage={'Data': fwd_msg.as_string()}
)
logger.info(f"➡ Forward sent from {rule.email} to {rule.target}")
if PROMETHEUS_ENABLED:
forwards_sent.labels(domain=domain).inc()
return True
except Exception as e:
logger.error(f"Failed to forward email: {e}")
return False
# ============================================
# MAIN EMAIL PROCESSING
# ============================================
def process_message(domain: str, message: dict) -> bool:
"""Process single SQS message with full feature set"""
receipt_handle = message['ReceiptHandle']
try:
# Parse SQS message body
body = json.loads(message['Body'])
# Handle SNS wrapper (Lambda shim format)
if body.get('Type') == 'Notification':
ses_data = json.loads(body.get('Message', '{}'))
else:
ses_data = body.get('ses', body)
mail = ses_data.get('mail', {})
receipt = ses_data.get('receipt', {})
message_id = mail.get('messageId', 'unknown')
from_addr = mail.get('source', '')
recipients = receipt.get('recipients', []) or mail.get('destination', [])
logger.info(f"Processing: {message_id} for {domain}")
logger.info(f" From: {from_addr}")
logger.info(f" To: {recipients}")
# ----------------------------------------
# 1. Download email from S3
# ----------------------------------------
bucket = domain_to_bucket_name(domain)
object_key = message_id
# Check receipt action for actual S3 location
actions = receipt.get('action', {})
if isinstance(actions, dict):
actions = [actions]
for action in actions:
if isinstance(action, dict) and action.get('type') == 'S3':
bucket = action.get('bucketName', bucket)
object_key = action.get('objectKey', object_key)
break
try:
s3_response = s3.get_object(Bucket=bucket, Key=object_key)
raw_email = s3_response['Body'].read()
except ClientError as e:
logger.error(f"Failed to download from S3: {bucket}/{object_key} - {e}")
return False
# Parse email
parsed = BytesParser(policy=SMTPPolicy).parsebytes(raw_email)
subject = parsed.get('Subject', '')
# ----------------------------------------
# 2. Bounce Detection & Rewriting
# ----------------------------------------
is_bounce = is_ses_bounce_notification(parsed)
bounce_rewritten = False
if is_bounce:
logger.info("🔄 Detected SES bounce notification")
# Extract Message-ID from bounce to correlate with original
bounce_msg_id = parsed.get('Message-ID', '')
# Also check X-Original-Message-Id or parse from body
# SES bounces often contain the original Message-ID in headers or body
original_msg_id = parsed.get('X-Original-Message-Id', '')
if not original_msg_id:
# Try to extract from References header
refs = parsed.get('References', '')
if refs:
original_msg_id = refs.split()[0] if refs else ''
lookup_id = original_msg_id or bounce_msg_id
if lookup_id:
bounce_info = get_bounce_info_from_dynamodb(lookup_id)
if bounce_info:
parsed, bounce_rewritten = rewrite_bounce_headers(parsed, bounce_info)
if PROMETHEUS_ENABLED:
bounces_processed.labels(
domain=domain,
type=bounce_info.get('bounceType', 'Unknown')
).inc()
# ----------------------------------------
# 3. Deliver to local mailboxes
# ----------------------------------------
success_count = 0
failed_recipients = []
delivered_recipients = []
# Get modified raw email if bounce was rewritten
if bounce_rewritten:
raw_email = parsed.as_bytes()
smtp_conn = smtp_pool.get_connection()
if not smtp_conn:
logger.error("Could not get SMTP connection")
return False
try:
for recipient in recipients:
try:
smtp_conn.sendmail(from_addr, [recipient], raw_email)
success_count += 1
delivered_recipients.append(recipient)
logger.info(f" ✓ Delivered to {recipient}")
if PROMETHEUS_ENABLED:
emails_processed.labels(domain=domain, status='success').inc()
except smtplib.SMTPRecipientsRefused as e:
logger.warning(f" ✗ Recipient refused: {recipient}")
failed_recipients.append(recipient)
if PROMETHEUS_ENABLED:
emails_processed.labels(domain=domain, status='recipient_refused').inc()
except smtplib.SMTPException as e:
logger.error(f" ✗ SMTP error for {recipient}: {e}")
failed_recipients.append(recipient)
finally:
smtp_pool.return_connection(smtp_conn)
# ----------------------------------------
# 4. Process Rules (Forwards & Auto-Reply)
# ----------------------------------------
if not is_bounce: # Don't process rules for bounces
for recipient in delivered_recipients:
rules = get_rules_for_email(recipient)
for rule in rules:
if not rule.is_active():
continue
if rule.rule_type == 'autoreply':
send_autoreply(rule, from_addr, subject, domain)
elif rule.rule_type == 'forward':
send_forward(rule, raw_email, from_addr, subject, domain)
# ----------------------------------------
# 5. Update S3 metadata
# ----------------------------------------
if success_count > 0:
try:
metadata = {
'processed': 'true',
'processed_at': str(int(time.time())),
'delivered_to': ','.join(delivered_recipients),
'status': 'delivered'
}
if failed_recipients:
metadata['failed_recipients'] = ','.join(failed_recipients)
if bounce_rewritten:
metadata['bounce_rewritten'] = 'true'
s3.copy_object(
Bucket=bucket,
Key=object_key,
CopySource={'Bucket': bucket, 'Key': object_key},
Metadata=metadata,
MetadataDirective='REPLACE'
)
except Exception as e:
logger.warning(f"Failed to update S3 metadata: {e}")
return success_count > 0
except Exception as e:
logger.error(f"Error processing message: {e}", exc_info=True)
if PROMETHEUS_ENABLED:
emails_processed.labels(domain=domain, status='error').inc()
return False
# ============================================
# DOMAIN POLLER
# ============================================
def poll_domain(domain: str, queue_url: str, stop_event: threading.Event):
"""Poll single domain's queue continuously"""
logger.info(f"Starting poller for {domain}")
while not stop_event.is_set():
try:
response = sqs.receive_message(
QueueUrl=queue_url,
MaxNumberOfMessages=config.max_messages,
WaitTimeSeconds=config.poll_interval,
VisibilityTimeout=config.visibility_timeout
)
messages = response.get('Messages', [])
# Update queue size metric
if PROMETHEUS_ENABLED:
try:
attrs = sqs.get_queue_attributes(
QueueUrl=queue_url,
AttributeNames=['ApproximateNumberOfMessages']
)
queue_size.labels(domain=domain).set(
int(attrs['Attributes'].get('ApproximateNumberOfMessages', 0))
)
except:
pass
for message in messages:
if stop_event.is_set():
break
if PROMETHEUS_ENABLED:
emails_in_flight.inc()
start_time = time.time()
try:
success = process_message(domain, message)
if success:
sqs.delete_message(
QueueUrl=queue_url,
ReceiptHandle=message['ReceiptHandle']
)
# If not successful, message becomes visible again after timeout
finally:
if PROMETHEUS_ENABLED:
emails_in_flight.dec()
processing_time.labels(domain=domain).observe(time.time() - start_time)
except Exception as e:
logger.error(f"Error polling {domain}: {e}")
time.sleep(5)
logger.info(f"Stopped poller for {domain}")
# ============================================
# UNIFIED WORKER
# ============================================
class UnifiedWorker:
"""Main worker coordinating all domain pollers"""
def __init__(self):
self.stop_event = threading.Event()
self.executor: Optional[ThreadPoolExecutor] = None
self.domains: List[str] = []
self.queue_urls: Dict[str, str] = {}
def setup(self):
"""Initialize worker"""
self.domains = load_domains()
if not self.domains:
logger.error("No domains configured!")
sys.exit(1)
# Get queue URLs
for domain in self.domains:
url = get_queue_url(domain)
if url:
self.queue_urls[domain] = url
if not self.queue_urls:
logger.error("No valid queues found!")
sys.exit(1)
# Initialize SMTP pool
smtp_pool.initialize()
logger.info(f"Initialized with {len(self.queue_urls)} domains")
def start(self):
"""Start all domain pollers"""
self.executor = ThreadPoolExecutor(
max_workers=config.worker_threads,
thread_name_prefix='poller'
)
futures = []
for domain, queue_url in self.queue_urls.items():
future = self.executor.submit(poll_domain, domain, queue_url, self.stop_event)
futures.append(future)
logger.info(f"Started {len(futures)} domain pollers")
try:
for future in as_completed(futures):
try:
future.result()
except Exception as e:
logger.error(f"Poller error: {e}")
except KeyboardInterrupt:
pass
def stop(self):
"""Stop gracefully"""
logger.info("Stopping worker...")
self.stop_event.set()
if self.executor:
self.executor.shutdown(wait=True, cancel_futures=False)
smtp_pool.close_all()
logger.info("Worker stopped")
# ============================================
# HEALTH CHECK SERVER
# ============================================
def start_health_server(worker: UnifiedWorker):
"""HTTP health endpoint"""
from http.server import HTTPServer, BaseHTTPRequestHandler
class HealthHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == '/health' or self.path == '/':
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
status = {
'status': 'healthy',
'domains': len(worker.queue_urls),
'dynamodb': DYNAMODB_AVAILABLE,
'features': {
'bounce_rewriting': True,
'auto_reply': DYNAMODB_AVAILABLE,
'forwarding': DYNAMODB_AVAILABLE
},
'timestamp': datetime.utcnow().isoformat()
}
self.wfile.write(json.dumps(status).encode())
elif self.path == '/domains':
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps(list(worker.queue_urls.keys())).encode())
elif self.path == '/metrics-status':
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps({
'prometheus_enabled': PROMETHEUS_ENABLED,
'metrics_port': config.metrics_port if PROMETHEUS_ENABLED else None
}).encode())
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
pass
server = HTTPServer(('0.0.0.0', config.health_port), HealthHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
logger.info(f"Health server on port {config.health_port}")
# ============================================
# ENTRY POINT
# ============================================
def main():
worker = UnifiedWorker()
def signal_handler(signum, frame):
logger.info(f"Received signal {signum}")
worker.stop()
sys.exit(0)
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
worker.setup()
if PROMETHEUS_ENABLED:
start_http_server(config.metrics_port)
logger.info(f"Prometheus metrics on port {config.metrics_port}")
start_health_server(worker)
logger.info("=" * 60)
logger.info(" UNIFIED EMAIL WORKER (Full Featured)")
logger.info("=" * 60)
logger.info(f" Domains: {len(worker.queue_urls)}")
logger.info(f" Threads: {config.worker_threads}")
logger.info(f" DynamoDB: {'Connected' if DYNAMODB_AVAILABLE else 'Not Available'}")
logger.info(f" SMTP Pool: {config.smtp_pool_size} connections")
logger.info("")
logger.info(" Features:")
logger.info(" ✓ Bounce Detection & Header Rewriting")
logger.info(f" {'' if DYNAMODB_AVAILABLE else ''} Auto-Reply / Out-of-Office")
logger.info(f" {'' if DYNAMODB_AVAILABLE else ''} Email Forwarding")
logger.info(f" {'' if PROMETHEUS_ENABLED else ''} Prometheus Metrics")
logger.info("=" * 60)
worker.start()
if __name__ == '__main__':
main()