#!/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 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', []) if forwards: self._handle_forwards( recipient, parsed, original_from, forwards, domain, worker_name, metrics_callback, rule=rule ) 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 # NEU ): """Handle email forwarding""" # NEU: SMTP Override aus Rule lesen smtp_override = None if rule: smtp_override = rule.get('forward_smtp_override') for forward_to in forwards: try: fwd_msg = self._create_forward_message( parsed, recipient, forward_to, original_from ) fwd_bytes = fwd_msg.as_bytes() # NEU: Legacy SMTP Override (Migration) if smtp_override: success = self._send_via_legacy_smtp( recipient, forward_to, fwd_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) elif 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"
{ooo_msg}



" html_content += "Original Message
" html_content += f"From: {original_from}
" html_content += f"Subject: {original_subject}

" html_content += (html_body if html_body else text_body.replace('\n', '
')) body_part.attach(MIMEText(html_content, 'html', 'utf-8')) msg.attach(body_part) return msg @staticmethod def _create_forward_message(original_parsed, recipient: str, forward_to: str, original_from: str): """Create Forward message as complete MIME message""" original_subject = original_parsed.get('Subject', '(no subject)') original_date = original_parsed.get('Date', 'unknown') msg = MIMEMultipart('mixed') msg['From'] = recipient msg['To'] = forward_to msg['Subject'] = f"FWD: {original_subject}" msg['Date'] = formatdate(localtime=True) msg['Message-ID'] = make_msgid(domain=recipient.split('@')[1]) msg['Reply-To'] = original_from msg['X-SES-Worker-Processed'] = 'forwarded' text_body, html_body = EmailParser.extract_body_parts(original_parsed) body_part = MIMEMultipart('alternative') # Text version fwd_text = "---------- Forwarded message ---------\n" fwd_text += f"From: {original_from}\n" fwd_text += f"Date: {original_date}\n" fwd_text += f"Subject: {original_subject}\n" fwd_text += f"To: {recipient}\n\n" fwd_text += text_body body_part.attach(MIMEText(fwd_text, 'plain', 'utf-8')) # HTML version if html_body: fwd_html = "
" fwd_html += "---------- Forwarded message ---------
" fwd_html += f"From: {original_from}
" fwd_html += f"Date: {original_date}
" fwd_html += f"Subject: {original_subject}
" fwd_html += f"To: {recipient}

" fwd_html += html_body fwd_html += "
" body_part.attach(MIMEText(fwd_html, 'html', 'utf-8')) msg.attach(body_part) # Copy attachments - FIX FILENAMES if original_parsed.is_multipart(): for part in original_parsed.walk(): if part.get_content_maintype() == 'multipart': continue if part.get_content_type() in ['text/plain', 'text/html']: continue # Fix malformed filename in Content-Disposition content_disp = part.get('Content-Disposition', '') if 'filename=' in content_disp and '"' not in content_disp: # Add quotes around filename with spaces import re fixed_disp = re.sub(r'filename=([^;"\s]+(?:\s+[^;"\s]+)*)', r'filename="\1"', content_disp) part.replace_header('Content-Disposition', fixed_disp) msg.attach(part) return msg