#!/usr/bin/env python3 """ Email rules processing (Auto-Reply/OOO and Forwarding) """ import smtplib from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from email.utils import parseaddr, formatdate, make_msgid from botocore.exceptions import ClientError from logger import log from config import config, is_internal_address from aws.dynamodb_handler import DynamoDBHandler from aws.ses_handler import SESHandler from email_processing.parser import EmailParser class RulesProcessor: """Processes email rules (OOO, Forwarding)""" def __init__(self, dynamodb: DynamoDBHandler, ses: SESHandler): self.dynamodb = dynamodb self.ses = ses def process_rules_for_recipient( self, recipient: str, parsed, domain: str, worker_name: str, metrics_callback=None ): """ Process OOO and Forward rules for a recipient Args: recipient: Recipient email address parsed: Parsed email message object domain: Email domain worker_name: Worker name for logging metrics_callback: Optional callback to increment metrics """ rule = self.dynamodb.get_email_rules(recipient) if not rule: return False # NEU: Return-Wert original_from = parsed.get('From', '') sender_name, sender_addr = parseaddr(original_from) if not sender_addr: sender_addr = original_from # ============================================ # OOO / Auto-Reply handling # ============================================ if rule.get('ooo_active', False): self._handle_ooo( recipient, parsed, sender_addr, rule, domain, worker_name, metrics_callback ) # ============================================ # Forward handling # ============================================ forwards = rule.get('forwards', []) has_legacy_forward = False # NEU if forwards: if rule.get('forward_smtp_override'): has_legacy_forward = True # NEU self._handle_forwards( recipient, parsed, original_from, forwards, domain, worker_name, metrics_callback, rule=rule ) return has_legacy_forward # NEU: statt kein Return def _handle_ooo( self, recipient: str, parsed, sender_addr: str, rule: dict, domain: str, worker_name: str, metrics_callback=None ): """Handle Out-of-Office auto-reply""" # Don't reply to automatic messages auto_submitted = parsed.get('Auto-Submitted', '') precedence = (parsed.get('Precedence') or '').lower() if auto_submitted and auto_submitted != 'no': log(f" ⏭ Skipping OOO for auto-submitted message", 'INFO', worker_name) return if precedence in ['bulk', 'junk', 'list']: log(f" ⏭ Skipping OOO for {precedence} message", 'INFO', worker_name) return if any(x in sender_addr.lower() for x in ['noreply', 'no-reply', 'mailer-daemon']): log(f" ⏭ Skipping OOO for noreply address", 'INFO', worker_name) return try: ooo_msg = rule.get('ooo_message', 'I am out of office.') content_type = rule.get('ooo_content_type', 'text') ooo_reply = self._create_ooo_reply(parsed, recipient, ooo_msg, content_type) ooo_bytes = ooo_reply.as_bytes() # Distinguish: Internal (Port 2525) vs External (SES) if is_internal_address(sender_addr): # Internal address → direct via Port 2525 success = self._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: # External address → via SES success = self.ses.send_raw_email(recipient, sender_addr, ooo_bytes, worker_name) if success: log(f"✓ Sent OOO reply externally to {sender_addr} via SES", 'SUCCESS', worker_name) if metrics_callback: metrics_callback('autoreply', domain) except Exception as e: log(f"⚠ OOO reply failed to {sender_addr}: {e}", 'ERROR', worker_name) def _handle_forwards( self, recipient: str, parsed, original_from: str, forwards: list, domain: str, worker_name: str, metrics_callback=None, rule: dict = None ): """Handle email forwarding""" smtp_override = None if rule: smtp_override = rule.get('forward_smtp_override') for forward_to in forwards: try: if smtp_override: # Migration: Original-Mail unverändert weiterleiten raw_bytes = parsed.as_bytes() success = self._send_via_legacy_smtp( recipient, forward_to, raw_bytes, smtp_override, worker_name ) if success: log(f"✓ Forwarded via legacy SMTP to {forward_to} " f"({smtp_override.get('host', '?')})", 'SUCCESS', worker_name) else: log(f"⚠ Legacy SMTP forward failed to {forward_to}", 'WARNING', worker_name) else: # Normaler Forward (neue FWD-Message) fwd_msg = self._create_forward_message( parsed, recipient, forward_to, original_from ) fwd_bytes = fwd_msg.as_bytes() if is_internal_address(forward_to): success = self._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: success = self.ses.send_raw_email( recipient, forward_to, fwd_bytes, worker_name ) if success: log(f"✓ Forwarded externally to {forward_to} via SES", 'SUCCESS', worker_name) if metrics_callback: metrics_callback('forward', domain) except Exception as e: log(f"⚠ Forward failed to {forward_to}: {e}", 'ERROR', worker_name) @staticmethod def _send_via_legacy_smtp( from_addr: str, to_addr: str, raw_message: bytes, smtp_config: dict, worker_name: str ) -> bool: """ Send email directly to a legacy SMTP server (for migration). Bypasses SES completely to avoid mail loops. """ try: host = smtp_config.get('host', '') # DynamoDB speichert Zahlen als Decimal, daher int() port = int(smtp_config.get('port', 25)) use_tls = smtp_config.get('tls', False) username = smtp_config.get('username') password = smtp_config.get('password') if not host: log(f" ✗ Legacy SMTP: no host configured", 'ERROR', worker_name) return False with smtplib.SMTP(host, port, timeout=30) as conn: conn.ehlo() if use_tls: conn.starttls() conn.ehlo() if username and password: conn.login(username, password) conn.sendmail(from_addr, [to_addr], raw_message) return True except Exception as e: log( f" ✗ Legacy SMTP failed ({smtp_config.get('host', '?')}:" f"{smtp_config.get('port', '?')}): {e}", 'ERROR', worker_name ) return False @staticmethod def _send_internal_email(from_addr: str, to_addr: str, raw_message: bytes, worker_name: str) -> bool: """ Send email via internal SMTP port (bypasses transport_maps) Args: from_addr: From address to_addr: To address raw_message: Raw MIME message bytes worker_name: Worker name for logging Returns: True on success, False on failure """ try: with smtplib.SMTP(config.smtp_host, config.internal_smtp_port, timeout=30) as conn: conn.ehlo() conn.sendmail(from_addr, [to_addr], raw_message) return True except Exception as e: log(f" ✗ Internal delivery failed to {to_addr}: {e}", 'ERROR', worker_name) return False @staticmethod def _create_ooo_reply(original_parsed, recipient: str, ooo_msg: str, content_type: str = 'text'): """Create Out-of-Office reply as complete MIME message""" text_body, html_body = EmailParser.extract_body_parts(original_parsed) original_subject = original_parsed.get('Subject', '(no subject)') original_from = original_parsed.get('From', 'unknown') msg = MIMEMultipart('mixed') msg['From'] = recipient msg['To'] = original_from msg['Subject'] = f"Out of Office: {original_subject}" msg['Date'] = formatdate(localtime=True) msg['Message-ID'] = make_msgid(domain=recipient.split('@')[1]) msg['In-Reply-To'] = original_parsed.get('Message-ID', '') msg['References'] = original_parsed.get('Message-ID', '') msg['Auto-Submitted'] = 'auto-replied' msg['X-SES-Worker-Processed'] = 'ooo-reply' body_part = MIMEMultipart('alternative') # Text version text_content = f"{ooo_msg}\n\n--- Original Message ---\n" text_content += f"From: {original_from}\n" text_content += f"Subject: {original_subject}\n\n" text_content += text_body body_part.attach(MIMEText(text_content, 'plain', 'utf-8')) # HTML version (if desired and original available) if content_type == 'html' or html_body: html_content = f"