forward/reply solution for internal mails
This commit is contained in:
parent
7f9ac1c9e6
commit
deed33c0cf
|
|
@ -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
|
||||||
|
|
@ -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 <email>" 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 <sender> <recipient1> [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)
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Content Filter Configuration
|
||||||
|
# Routes all local deliveries through content filter on port 10025
|
||||||
|
content_filter = smtp:[localhost]:10025
|
||||||
|
|
@ -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
|
||||||
|
|
@ -5,20 +5,38 @@ CFG_ROOT="/tmp/docker-mailserver"
|
||||||
SRC_DIR="$CFG_ROOT/postfix"
|
SRC_DIR="$CFG_ROOT/postfix"
|
||||||
DST_DIR="/etc/postfix"
|
DST_DIR="/etc/postfix"
|
||||||
|
|
||||||
# Dateien nach /etc/postfix kopieren (oder aktualisieren)
|
echo "[user-patches.sh] Starting Postfix customizations..."
|
||||||
# install -D -m 0644 "$SRC_DIR/transport" "$DST_DIR/transport"
|
|
||||||
# install -D -m 0600 "$SRC_DIR/sasl_passwd" "$DST_DIR/sasl_passwd"
|
# Existing patches (header_checks, etc.)
|
||||||
install -D -m 0644 "$SRC_DIR/header_checks" "$DST_DIR/header_checks"
|
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"
|
install -D -m 0644 "$SRC_DIR/smtp_header_checks" "$DST_DIR/maps/sender_header_filter.pcre"
|
||||||
|
|
||||||
# Maps bauen
|
# NEW: Append content filter configuration to main.cf
|
||||||
# postmap "$DST_DIR/transport"
|
if [ -f "$SRC_DIR/main.cf.append" ]; then
|
||||||
# postmap "$DST_DIR/sasl_passwd"
|
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
|
# NEW: Append content filter services to master.cf
|
||||||
# chmod 600 "$DST_DIR/sasl_passwd.db" || true
|
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 neu laden (nachdem docker-mailserver seine eigene Konfig geladen hat)
|
||||||
postfix reload || true
|
postfix reload || true
|
||||||
|
|
@ -86,7 +86,7 @@ class Config:
|
||||||
max_messages: int = int(os.environ.get('MAX_MESSAGES', '10'))
|
max_messages: int = int(os.environ.get('MAX_MESSAGES', '10'))
|
||||||
visibility_timeout: int = int(os.environ.get('VISIBILITY_TIMEOUT', '300'))
|
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_host: str = os.environ.get('SMTP_HOST', 'localhost')
|
||||||
smtp_port: int = int(os.environ.get('SMTP_PORT', '25'))
|
smtp_port: int = int(os.environ.get('SMTP_PORT', '25'))
|
||||||
smtp_use_tls: bool = os.environ.get('SMTP_USE_TLS', 'false').lower() == 'true'
|
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_pass: str = os.environ.get('SMTP_PASS', '')
|
||||||
smtp_pool_size: int = int(os.environ.get('SMTP_POOL_SIZE', '5'))
|
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
|
# DynamoDB Tables
|
||||||
rules_table: str = os.environ.get('DYNAMODB_RULES_TABLE', 'email-rules')
|
rules_table: str = os.environ.get('DYNAMODB_RULES_TABLE', 'email-rules')
|
||||||
messages_table: str = os.environ.get('DYNAMODB_MESSAGES_TABLE', 'ses-outbound-messages')
|
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)
|
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
|
# HELPER FUNCTIONS
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
@ -271,6 +280,30 @@ def domain_to_queue_name(domain: str) -> str:
|
||||||
def domain_to_bucket_name(domain: str) -> str:
|
def domain_to_bucket_name(domain: str) -> str:
|
||||||
return domain.replace('.', '-') + '-emails'
|
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]:
|
def get_queue_url(domain: str) -> Optional[str]:
|
||||||
queue_name = domain_to_queue_name(domain)
|
queue_name = domain_to_queue_name(domain)
|
||||||
try:
|
try:
|
||||||
|
|
@ -284,7 +317,8 @@ def get_queue_url(domain: str) -> Optional[str]:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def load_domains() -> List[str]:
|
def load_domains() -> List[str]:
|
||||||
"""Load domains from config"""
|
"""Load domains from config and populate MANAGED_DOMAINS global"""
|
||||||
|
global MANAGED_DOMAINS
|
||||||
domains = []
|
domains = []
|
||||||
|
|
||||||
if config.domains_list:
|
if config.domains_list:
|
||||||
|
|
@ -298,6 +332,10 @@ def load_domains() -> List[str]:
|
||||||
domains.append(domain)
|
domains.append(domain)
|
||||||
|
|
||||||
domains = list(set(domains))
|
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)}")
|
log(f"Loaded {len(domains)} domains: {', '.join(domains)}")
|
||||||
return domains
|
return domains
|
||||||
|
|
||||||
|
|
@ -609,13 +647,24 @@ def process_rules_for_recipient(recipient: str, parsed, domain: str, worker_name
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
ooo_reply = create_ooo_reply(parsed, recipient, ooo_msg, content_type)
|
ooo_reply = create_ooo_reply(parsed, recipient, ooo_msg, content_type)
|
||||||
|
ooo_bytes = ooo_reply.as_bytes()
|
||||||
|
|
||||||
ses.send_raw_email(
|
# Unterscheiden: Intern (Port 2525) vs Extern (SES)
|
||||||
Source=recipient,
|
if is_internal_address(sender_addr):
|
||||||
Destinations=[sender_addr],
|
# Interne Adresse → direkt via Port 2525
|
||||||
RawMessage={'Data': ooo_reply.as_bytes()}
|
success = send_internal_email(recipient, sender_addr, ooo_bytes, worker_name)
|
||||||
)
|
if success:
|
||||||
log(f"✓ Sent OOO reply to {sender_addr} from {recipient}", 'SUCCESS', worker_name)
|
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:
|
if PROMETHEUS_ENABLED:
|
||||||
autoreplies_sent.labels(domain=domain).inc()
|
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:
|
except ClientError as e:
|
||||||
error_code = e.response['Error']['Code']
|
error_code = e.response['Error']['Code']
|
||||||
log(f"⚠ SES OOO send failed ({error_code}): {e}", 'ERROR', worker_name)
|
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
|
# Forward handling
|
||||||
|
|
@ -632,13 +683,24 @@ def process_rules_for_recipient(recipient: str, parsed, domain: str, worker_name
|
||||||
for forward_to in forwards:
|
for forward_to in forwards:
|
||||||
try:
|
try:
|
||||||
fwd_msg = create_forward_message(parsed, recipient, forward_to, original_from)
|
fwd_msg = create_forward_message(parsed, recipient, forward_to, original_from)
|
||||||
|
fwd_bytes = fwd_msg.as_bytes()
|
||||||
|
|
||||||
ses.send_raw_email(
|
# Unterscheiden: Intern (Port 2525) vs Extern (SES)
|
||||||
Source=recipient,
|
if is_internal_address(forward_to):
|
||||||
Destinations=[forward_to],
|
# Interne Adresse → direkt via Port 2525 (keine Loop!)
|
||||||
RawMessage={'Data': fwd_msg.as_bytes()}
|
success = send_internal_email(recipient, forward_to, fwd_bytes, worker_name)
|
||||||
)
|
if success:
|
||||||
log(f"✓ Forwarded to {forward_to} from {recipient}", 'SUCCESS', worker_name)
|
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:
|
if PROMETHEUS_ENABLED:
|
||||||
forwards_sent.labels(domain=domain).inc()
|
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:
|
except ClientError as e:
|
||||||
error_code = e.response['Error']['Code']
|
error_code = e.response['Error']['Code']
|
||||||
log(f"⚠ SES forward failed to {forward_to} ({error_code}): {e}", 'ERROR', worker_name)
|
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:
|
except ClientError as e:
|
||||||
error_code = e.response['Error']['Code']
|
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]:
|
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
|
Sendet E-Mail via SMTP/LMTP an EINEN Empfänger.
|
||||||
Mit Retry-Logik bei Connection-Fehlern
|
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)
|
Returns: (success: bool, error: str or None, is_permanent: bool)
|
||||||
"""
|
"""
|
||||||
last_error = None
|
last_error = None
|
||||||
|
|
||||||
|
# Entscheide ob LMTP oder SMTP
|
||||||
|
use_lmtp = config.lmtp_enabled
|
||||||
|
|
||||||
for attempt in range(max_retries + 1):
|
for attempt in range(max_retries + 1):
|
||||||
smtp_conn = smtp_pool.get_connection()
|
conn = None
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
try:
|
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
|
result = conn.sendmail(from_addr, [recipient], raw_message)
|
||||||
smtp_pool.return_connection(smtp_conn)
|
|
||||||
|
# Erfolg
|
||||||
|
if use_lmtp:
|
||||||
|
conn.quit()
|
||||||
|
else:
|
||||||
|
smtp_pool.return_connection(conn)
|
||||||
|
|
||||||
if isinstance(result, dict) and result:
|
if isinstance(result, dict) and result:
|
||||||
error = str(result.get(recipient, 'Unknown refusal'))
|
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)
|
log(f" ✗ {recipient}: {error} ({'permanent' if is_permanent else 'temporary'})", 'ERROR', worker_name)
|
||||||
return False, error, is_permanent
|
return False, error, is_permanent
|
||||||
else:
|
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
|
return True, None, False
|
||||||
|
|
||||||
except smtplib.SMTPServerDisconnected as e:
|
except smtplib.SMTPServerDisconnected as e:
|
||||||
# Connection wurde geschlossen - Retry mit neuer Connection
|
# Connection wurde geschlossen - Retry mit neuer Connection
|
||||||
log(f" ⚠ {recipient}: Connection lost, retrying... (attempt {attempt + 1}/{max_retries + 1})", 'WARNING', worker_name)
|
log(f" ⚠ {recipient}: Connection lost, retrying... (attempt {attempt + 1}/{max_retries + 1})", 'WARNING', worker_name)
|
||||||
last_error = str(e)
|
last_error = str(e)
|
||||||
# Connection nicht zurückgeben (ist kaputt)
|
if conn:
|
||||||
try:
|
try:
|
||||||
smtp_conn.quit()
|
conn.quit()
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
except smtplib.SMTPRecipientsRefused as e:
|
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)
|
error_msg = str(e)
|
||||||
is_permanent = is_permanent_recipient_error(error_msg)
|
is_permanent = is_permanent_recipient_error(error_msg)
|
||||||
log(f" ✗ {recipient}: Recipients refused - {error_msg}", 'ERROR', worker_name)
|
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)
|
error_msg = str(e)
|
||||||
# Bei Connection-Fehlern: Retry
|
# Bei Connection-Fehlern: Retry
|
||||||
if 'disconnect' in error_msg.lower() or 'closed' in error_msg.lower() or 'connection' in error_msg.lower():
|
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
|
last_error = error_msg
|
||||||
try:
|
if conn:
|
||||||
smtp_conn.quit()
|
try:
|
||||||
except:
|
conn.quit()
|
||||||
pass
|
except:
|
||||||
|
pass
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
continue
|
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)
|
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
|
return False, error_msg, is_permanent
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Unbekannter Fehler - Connection verwerfen, aber nicht permanent
|
# Unbekannter Fehler
|
||||||
try:
|
if conn:
|
||||||
smtp_conn.quit()
|
try:
|
||||||
except:
|
conn.quit()
|
||||||
pass
|
except:
|
||||||
|
pass
|
||||||
log(f" ✗ {recipient}: Unexpected error - {e}", 'ERROR', worker_name)
|
log(f" ✗ {recipient}: Unexpected error - {e}", 'ERROR', worker_name)
|
||||||
return False, str(e), False
|
return False, str(e), False
|
||||||
|
|
||||||
|
|
@ -1286,7 +1380,10 @@ def main():
|
||||||
log(f"{'='*70}")
|
log(f"{'='*70}")
|
||||||
log(f" Domains: {len(worker.queue_urls)}")
|
log(f" Domains: {len(worker.queue_urls)}")
|
||||||
log(f" DynamoDB: {'Connected' if DYNAMODB_AVAILABLE else 'Not Available'}")
|
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" Poll Interval: {config.poll_interval}s")
|
||||||
log(f" Visibility: {config.visibility_timeout}s")
|
log(f" Visibility: {config.visibility_timeout}s")
|
||||||
log(f"")
|
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 '✗'} Auto-Reply / Out-of-Office")
|
||||||
log(f" {'✓' if DYNAMODB_AVAILABLE else '✗'} Email Forwarding")
|
log(f" {'✓' if DYNAMODB_AVAILABLE else '✗'} Email Forwarding")
|
||||||
log(f" {'✓' if PROMETHEUS_ENABLED else '✗'} Prometheus Metrics")
|
log(f" {'✓' if PROMETHEUS_ENABLED else '✗'} Prometheus Metrics")
|
||||||
|
log(f" {'✓' if config.lmtp_enabled else '✗'} LMTP Direct Delivery")
|
||||||
log(f"")
|
log(f"")
|
||||||
log(f" Active Domains:")
|
log(f" Active Domains:")
|
||||||
for domain in sorted(worker.queue_urls.keys()):
|
for domain in sorted(worker.queue_urls.keys()):
|
||||||
|
|
@ -1304,4 +1402,4 @@ def main():
|
||||||
worker.start()
|
worker.start()
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
Loading…
Reference in New Issue