diff --git a/DMS/Dockerfile.custom b/DMS/Dockerfile.custom new file mode 100644 index 0000000..c8abd5e --- /dev/null +++ b/DMS/Dockerfile.custom @@ -0,0 +1,21 @@ +FROM docker.io/mailserver/docker-mailserver:latest + +LABEL maintainer="andreas@knuth.dev" +LABEL description="Custom DMS with content filter support" + +# Install Python and boto3 +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + python3 \ + python3-pip \ + && pip3 install --no-cache-dir boto3 \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Copy content filter script +COPY content_filter.py /usr/local/bin/content_filter.py +RUN chmod +x /usr/local/bin/content_filter.py + +# Create log file with correct permissions +RUN touch /var/log/mail/content_filter.log && \ + chown mail:mail /var/log/mail/content_filter.log \ No newline at end of file diff --git a/DMS/content_filter.py b/DMS/content_filter.py new file mode 100644 index 0000000..a81d5a8 --- /dev/null +++ b/DMS/content_filter.py @@ -0,0 +1,217 @@ +#!/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 + +# 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 + +# 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""" + if not DYNAMODB_AVAILABLE: + return {} + + 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)}") + return item + except Exception as e: + logging.error(f"DynamoDB error for {email_address}: {e}") + return {} + +def should_send_autoreply(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 + + 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 " format + sender_name, sender_addr = parseaddr(sender) + + if not should_send_autoreply(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 + from io import BytesIO + 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 [recipient2] ...") + sys.exit(1) + + sender = sys.argv[1] + recipients = sys.argv[2:] + + logging.info(f"Processing email from {sender} to {', '.join(recipients)}") + + # Read email from stdin + try: + msg_bytes = sys.stdin.buffer.read() + msg = message_from_binary_file(sys.stdin.buffer) + + # Parse again from bytes for processing + from io import BytesIO + 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: + 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(recipients)}") + 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) \ No newline at end of file diff --git a/DMS/docker-data/dms/config/postfix/main.cf.append b/DMS/docker-data/dms/config/postfix/main.cf.append new file mode 100644 index 0000000..14c7b86 --- /dev/null +++ b/DMS/docker-data/dms/config/postfix/main.cf.append @@ -0,0 +1,3 @@ +# Content Filter Configuration +# Routes all local deliveries through content filter on port 10025 +content_filter = smtp:[localhost]:10025 \ No newline at end of file diff --git a/DMS/docker-data/dms/config/postfix/master.cf.append b/DMS/docker-data/dms/config/postfix/master.cf.append new file mode 100644 index 0000000..5036d89 --- /dev/null +++ b/DMS/docker-data/dms/config/postfix/master.cf.append @@ -0,0 +1,34 @@ +# +# Content Filter Setup +# Two additional SMTP services for content filtering +# + +# Port 10025: Content filter input +# Receives mail from main Postfix, passes to content_filter.py +localhost:10025 inet n - n - - smtpd + -o content_filter= + -o local_recipient_maps= + -o relay_recipient_maps= + -o smtpd_restriction_classes= + -o smtpd_client_restrictions= + -o smtpd_helo_restrictions= + -o smtpd_sender_restrictions= + -o smtpd_recipient_restrictions=permit_mynetworks,reject + -o mynetworks=127.0.0.0/8 + -o smtpd_authorized_xforward_hosts=127.0.0.0/8 + -o receive_override_options=no_unknown_recipient_checks + +# Port 10026: Content filter output (re-injection) +# Receives processed mail from content_filter.py for final delivery +localhost:10026 inet n - n - - smtpd + -o content_filter= + -o local_recipient_maps= + -o relay_recipient_maps= + -o smtpd_restriction_classes= + -o smtpd_client_restrictions= + -o smtpd_helo_restrictions= + -o smtpd_sender_restrictions= + -o smtpd_recipient_restrictions=permit_mynetworks,reject + -o mynetworks=127.0.0.0/8 + -o smtpd_authorized_xforward_hosts=127.0.0.0/8 + -o receive_override_options=no_header_body_checks,no_unknown_recipient_checks \ No newline at end of file diff --git a/DMS/docker-data/dms/config/user-patches.sh b/DMS/docker-data/dms/config/user-patches.sh index 02a7ee8..f3089f8 100644 --- a/DMS/docker-data/dms/config/user-patches.sh +++ b/DMS/docker-data/dms/config/user-patches.sh @@ -5,20 +5,38 @@ CFG_ROOT="/tmp/docker-mailserver" SRC_DIR="$CFG_ROOT/postfix" DST_DIR="/etc/postfix" -# Dateien nach /etc/postfix kopieren (oder aktualisieren) -# install -D -m 0644 "$SRC_DIR/transport" "$DST_DIR/transport" -# install -D -m 0600 "$SRC_DIR/sasl_passwd" "$DST_DIR/sasl_passwd" -install -D -m 0644 "$SRC_DIR/header_checks" "$DST_DIR/header_checks" +echo "[user-patches.sh] Starting Postfix customizations..." + +# Existing patches (header_checks, etc.) +install -D -m 0644 "$SRC_DIR/header_checks" "$DST_DIR/header_checks" install -D -m 0644 "$SRC_DIR/smtp_header_checks" "$DST_DIR/maps/sender_header_filter.pcre" -# Maps bauen -# postmap "$DST_DIR/transport" -# postmap "$DST_DIR/sasl_passwd" +# NEW: Append content filter configuration to main.cf +if [ -f "$SRC_DIR/main.cf.append" ]; then + echo "[user-patches.sh] Appending content filter config to main.cf..." + cat "$SRC_DIR/main.cf.append" >> "$DST_DIR/main.cf" + echo "[user-patches.sh] ✓ main.cf updated" +else + echo "[user-patches.sh] ⚠ main.cf.append not found, skipping" +fi -# Rechte auf die .db-Helferdatei -# chmod 600 "$DST_DIR/sasl_passwd.db" || true +# NEW: Append content filter services to master.cf +if [ -f "$SRC_DIR/master.cf.append" ]; then + echo "[user-patches.sh] Appending content filter services to master.cf..." + cat "$SRC_DIR/master.cf.append" >> "$DST_DIR/master.cf" + echo "[user-patches.sh] ✓ master.cf updated" +else + echo "[user-patches.sh] ⚠ master.cf.append not found, skipping" +fi -# rm -f /etc/dovecot/conf.d/95-sieve-redirect.conf +# Verify content filter script exists and is executable +if [ -x "/usr/local/bin/content_filter.py" ]; then + echo "[user-patches.sh] ✓ Content filter script found" +else + echo "[user-patches.sh] ⚠ WARNING: content_filter.py not found or not executable!" +fi + +echo "[user-patches.sh] Postfix customizations complete" # Postfix neu laden (nachdem docker-mailserver seine eigene Konfig geladen hat) postfix reload || true \ No newline at end of file diff --git a/unified-worker/unified_worker.py b/unified-worker/unified_worker.py index 921a333..adbc832 100644 --- a/unified-worker/unified_worker.py +++ b/unified-worker/unified_worker.py @@ -86,7 +86,7 @@ class Config: max_messages: int = int(os.environ.get('MAX_MESSAGES', '10')) visibility_timeout: int = int(os.environ.get('VISIBILITY_TIMEOUT', '300')) - # SMTP + # SMTP for delivery (should use LMTP port 24 to bypass transport_maps) 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' @@ -94,6 +94,12 @@ class Config: smtp_pass: str = os.environ.get('SMTP_PASS', '') smtp_pool_size: int = int(os.environ.get('SMTP_POOL_SIZE', '5')) + # LMTP for local delivery (bypasses Postfix transport_maps completely) + # Set LMTP_ENABLED=true and LMTP_PORT=24 to use Dovecot LMTP + lmtp_enabled: bool = os.environ.get('LMTP_ENABLED', 'false').lower() == 'true' + lmtp_host: str = os.environ.get('LMTP_HOST', 'localhost') + lmtp_port: int = int(os.environ.get('LMTP_PORT', '24')) + # 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') @@ -261,6 +267,9 @@ class SMTPPool: smtp_pool = SMTPPool(config.smtp_host, config.smtp_port, config.smtp_pool_size) +# Global set of domains we manage (populated at startup) +MANAGED_DOMAINS: set = set() + # ============================================ # HELPER FUNCTIONS # ============================================ @@ -271,6 +280,30 @@ def domain_to_queue_name(domain: str) -> str: def domain_to_bucket_name(domain: str) -> str: return domain.replace('.', '-') + '-emails' +def is_internal_address(email_address: str) -> bool: + """Check if email address belongs to one of our managed domains""" + if '@' not in email_address: + return False + domain = email_address.split('@')[1].lower() + return domain in MANAGED_DOMAINS + +def send_internal_email(from_addr: str, to_addr: str, raw_message: bytes, worker_name: str) -> bool: + """ + Send email via local SMTP port 2525 (bypasses transport_maps). + Used for internal forwards to avoid SES loop. + Returns: True on success, False on failure + """ + try: + # Direkte SMTP Verbindung auf Port 2525 (ohne transport_maps) + with smtplib.SMTP(config.smtp_host, 2525, timeout=30) as conn: + conn.ehlo() + conn.sendmail(from_addr, [to_addr], raw_message) + log(f" ✓ Internal delivery to {to_addr} (Port 2525)", 'SUCCESS', worker_name) + return True + except Exception as e: + log(f" ✗ Internal delivery failed to {to_addr}: {e}", 'ERROR', worker_name) + return False + def get_queue_url(domain: str) -> Optional[str]: queue_name = domain_to_queue_name(domain) try: @@ -284,7 +317,8 @@ def get_queue_url(domain: str) -> Optional[str]: return None def load_domains() -> List[str]: - """Load domains from config""" + """Load domains from config and populate MANAGED_DOMAINS global""" + global MANAGED_DOMAINS domains = [] if config.domains_list: @@ -298,6 +332,10 @@ def load_domains() -> List[str]: domains.append(domain) domains = list(set(domains)) + + # Populate global set for is_internal_address() checks + MANAGED_DOMAINS = set(d.lower() for d in domains) + log(f"Loaded {len(domains)} domains: {', '.join(domains)}") return domains @@ -609,13 +647,24 @@ def process_rules_for_recipient(recipient: str, parsed, domain: str, worker_name else: try: ooo_reply = create_ooo_reply(parsed, recipient, ooo_msg, content_type) + ooo_bytes = ooo_reply.as_bytes() - ses.send_raw_email( - Source=recipient, - Destinations=[sender_addr], - RawMessage={'Data': ooo_reply.as_bytes()} - ) - log(f"✓ Sent OOO reply to {sender_addr} from {recipient}", 'SUCCESS', worker_name) + # Unterscheiden: Intern (Port 2525) vs Extern (SES) + if is_internal_address(sender_addr): + # Interne Adresse → direkt via Port 2525 + success = send_internal_email(recipient, sender_addr, ooo_bytes, worker_name) + if success: + log(f"✓ Sent OOO reply internally to {sender_addr}", 'SUCCESS', worker_name) + else: + log(f"⚠ Internal OOO reply failed to {sender_addr}", 'WARNING', worker_name) + else: + # Externe Adresse → via SES + ses.send_raw_email( + Source=recipient, + Destinations=[sender_addr], + RawMessage={'Data': ooo_bytes} + ) + log(f"✓ Sent OOO reply externally to {sender_addr} via SES", 'SUCCESS', worker_name) if PROMETHEUS_ENABLED: autoreplies_sent.labels(domain=domain).inc() @@ -623,6 +672,8 @@ def process_rules_for_recipient(recipient: str, parsed, domain: str, worker_name except ClientError as e: error_code = e.response['Error']['Code'] log(f"⚠ SES OOO send failed ({error_code}): {e}", 'ERROR', worker_name) + except Exception as e: + log(f"⚠ OOO reply failed to {sender_addr}: {e}", 'ERROR', worker_name) # ============================================ # Forward handling @@ -632,13 +683,24 @@ def process_rules_for_recipient(recipient: str, parsed, domain: str, worker_name for forward_to in forwards: try: fwd_msg = create_forward_message(parsed, recipient, forward_to, original_from) + fwd_bytes = fwd_msg.as_bytes() - ses.send_raw_email( - Source=recipient, - Destinations=[forward_to], - RawMessage={'Data': fwd_msg.as_bytes()} - ) - log(f"✓ Forwarded to {forward_to} from {recipient}", 'SUCCESS', worker_name) + # Unterscheiden: Intern (Port 2525) vs Extern (SES) + if is_internal_address(forward_to): + # Interne Adresse → direkt via Port 2525 (keine Loop!) + success = send_internal_email(recipient, forward_to, fwd_bytes, worker_name) + if success: + log(f"✓ Forwarded internally to {forward_to}", 'SUCCESS', worker_name) + else: + log(f"⚠ Internal forward failed to {forward_to}", 'WARNING', worker_name) + else: + # Externe Adresse → via SES + ses.send_raw_email( + Source=recipient, + Destinations=[forward_to], + RawMessage={'Data': fwd_bytes} + ) + log(f"✓ Forwarded externally to {forward_to} via SES", 'SUCCESS', worker_name) if PROMETHEUS_ENABLED: forwards_sent.labels(domain=domain).inc() @@ -646,6 +708,8 @@ def process_rules_for_recipient(recipient: str, parsed, domain: str, worker_name except ClientError as e: error_code = e.response['Error']['Code'] log(f"⚠ SES forward failed to {forward_to} ({error_code}): {e}", 'ERROR', worker_name) + except Exception as e: + log(f"⚠ Forward failed to {forward_to}: {e}", 'ERROR', worker_name) except ClientError as e: error_code = e.response['Error']['Code'] @@ -683,26 +747,41 @@ def is_permanent_recipient_error(error_msg: str) -> bool: def send_email_to_recipient(from_addr: str, recipient: str, raw_message: bytes, worker_name: str, max_retries: int = 2) -> Tuple[bool, Optional[str], bool]: """ - Sendet E-Mail via SMTP an EINEN Empfänger - Mit Retry-Logik bei Connection-Fehlern + Sendet E-Mail via SMTP/LMTP an EINEN Empfänger. + Wenn LMTP aktiviert ist, wird direkt an Dovecot geliefert (umgeht transport_maps). + Mit Retry-Logik bei Connection-Fehlern. Returns: (success: bool, error: str or None, is_permanent: bool) """ last_error = None + # Entscheide ob LMTP oder SMTP + use_lmtp = config.lmtp_enabled + for attempt in range(max_retries + 1): - smtp_conn = smtp_pool.get_connection() - - if not smtp_conn: - last_error = "Could not get SMTP connection" - log(f" ⚠ {recipient}: No SMTP connection (attempt {attempt + 1}/{max_retries + 1})", 'WARNING', worker_name) - time.sleep(0.5) - continue + conn = None try: - result = smtp_conn.sendmail(from_addr, [recipient], raw_message) + if use_lmtp: + # LMTP Verbindung direkt zu Dovecot (umgeht Postfix/transport_maps) + conn = smtplib.LMTP(config.lmtp_host, config.lmtp_port, timeout=30) + # LMTP braucht kein EHLO, aber schadet nicht + conn.ehlo() + else: + # Normale SMTP Verbindung aus dem Pool + conn = smtp_pool.get_connection() + if not conn: + last_error = "Could not get SMTP connection" + log(f" ⚠ {recipient}: No SMTP connection (attempt {attempt + 1}/{max_retries + 1})", 'WARNING', worker_name) + time.sleep(0.5) + continue - # Connection war erfolgreich, zurück in Pool - smtp_pool.return_connection(smtp_conn) + result = conn.sendmail(from_addr, [recipient], raw_message) + + # Erfolg + if use_lmtp: + conn.quit() + else: + smtp_pool.return_connection(conn) if isinstance(result, dict) and result: error = str(result.get(recipient, 'Unknown refusal')) @@ -710,23 +789,30 @@ def send_email_to_recipient(from_addr: str, recipient: str, raw_message: bytes, log(f" ✗ {recipient}: {error} ({'permanent' if is_permanent else 'temporary'})", 'ERROR', worker_name) return False, error, is_permanent else: - log(f" ✓ {recipient}: Delivered", 'SUCCESS', worker_name) + delivery_method = "LMTP" if use_lmtp else "SMTP" + log(f" ✓ {recipient}: Delivered ({delivery_method})", 'SUCCESS', worker_name) return True, None, False except smtplib.SMTPServerDisconnected as e: # Connection wurde geschlossen - Retry mit neuer Connection log(f" ⚠ {recipient}: Connection lost, retrying... (attempt {attempt + 1}/{max_retries + 1})", 'WARNING', worker_name) last_error = str(e) - # Connection nicht zurückgeben (ist kaputt) - try: - smtp_conn.quit() - except: - pass + if conn: + try: + conn.quit() + except: + pass time.sleep(0.3) continue except smtplib.SMTPRecipientsRefused as e: - smtp_pool.return_connection(smtp_conn) + if conn and not use_lmtp: + smtp_pool.return_connection(conn) + elif conn: + try: + conn.quit() + except: + pass error_msg = str(e) is_permanent = is_permanent_recipient_error(error_msg) log(f" ✗ {recipient}: Recipients refused - {error_msg}", 'ERROR', worker_name) @@ -736,26 +822,34 @@ def send_email_to_recipient(from_addr: str, recipient: str, raw_message: bytes, error_msg = str(e) # Bei Connection-Fehlern: Retry if 'disconnect' in error_msg.lower() or 'closed' in error_msg.lower() or 'connection' in error_msg.lower(): - log(f" ⚠ {recipient}: SMTP connection error, retrying... (attempt {attempt + 1}/{max_retries + 1})", 'WARNING', worker_name) + log(f" ⚠ {recipient}: Connection error, retrying... (attempt {attempt + 1}/{max_retries + 1})", 'WARNING', worker_name) last_error = error_msg - try: - smtp_conn.quit() - except: - pass + if conn: + try: + conn.quit() + except: + pass time.sleep(0.3) continue - smtp_pool.return_connection(smtp_conn) + if conn and not use_lmtp: + smtp_pool.return_connection(conn) + elif conn: + try: + conn.quit() + except: + pass is_permanent = is_permanent_recipient_error(error_msg) - log(f" ✗ {recipient}: SMTP error - {error_msg}", 'ERROR', worker_name) + log(f" ✗ {recipient}: Error - {error_msg}", 'ERROR', worker_name) return False, error_msg, is_permanent except Exception as e: - # Unbekannter Fehler - Connection verwerfen, aber nicht permanent - try: - smtp_conn.quit() - except: - pass + # Unbekannter Fehler + if conn: + try: + conn.quit() + except: + pass log(f" ✗ {recipient}: Unexpected error - {e}", 'ERROR', worker_name) return False, str(e), False @@ -1286,7 +1380,10 @@ def main(): log(f"{'='*70}") log(f" Domains: {len(worker.queue_urls)}") log(f" DynamoDB: {'Connected' if DYNAMODB_AVAILABLE else 'Not Available'}") - log(f" SMTP Pool: {config.smtp_pool_size} connections -> {config.smtp_host}:{config.smtp_port}") + if config.lmtp_enabled: + log(f" Delivery: LMTP -> {config.lmtp_host}:{config.lmtp_port} (bypasses transport_maps)") + else: + log(f" Delivery: SMTP -> {config.smtp_host}:{config.smtp_port}") log(f" Poll Interval: {config.poll_interval}s") log(f" Visibility: {config.visibility_timeout}s") log(f"") @@ -1295,6 +1392,7 @@ def main(): log(f" {'✓' if DYNAMODB_AVAILABLE else '✗'} Auto-Reply / Out-of-Office") log(f" {'✓' if DYNAMODB_AVAILABLE else '✗'} Email Forwarding") log(f" {'✓' if PROMETHEUS_ENABLED else '✗'} Prometheus Metrics") + log(f" {'✓' if config.lmtp_enabled else '✗'} LMTP Direct Delivery") log(f"") log(f" Active Domains:") for domain in sorted(worker.queue_urls.keys()): @@ -1304,4 +1402,4 @@ def main(): worker.start() if __name__ == '__main__': - main() + main() \ No newline at end of file