email-amazon/email-worker/email_processing/rules_processor.py

365 lines
14 KiB
Python

#!/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"<div>{ooo_msg}</div><br><hr><br>"
html_content += "<strong>Original Message</strong><br>"
html_content += f"<strong>From:</strong> {original_from}<br>"
html_content += f"<strong>Subject:</strong> {original_subject}<br><br>"
html_content += (html_body if html_body else text_body.replace('\n', '<br>'))
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 = "<div style='border-left:3px solid #ccc;padding-left:10px;'>"
fwd_html += "<strong>---------- Forwarded message ---------</strong><br>"
fwd_html += f"<strong>From:</strong> {original_from}<br>"
fwd_html += f"<strong>Date:</strong> {original_date}<br>"
fwd_html += f"<strong>Subject:</strong> {original_subject}<br>"
fwd_html += f"<strong>To:</strong> {recipient}<br><br>"
fwd_html += html_body
fwd_html += "</div>"
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