365 lines
14 KiB
Python
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
|