forward/reply solution for internal mails
This commit is contained in:
21
DMS/Dockerfile.custom
Normal file
21
DMS/Dockerfile.custom
Normal file
@@ -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
|
||||
217
DMS/content_filter.py
Normal file
217
DMS/content_filter.py
Normal file
@@ -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)
|
||||
3
DMS/docker-data/dms/config/postfix/main.cf.append
Normal file
3
DMS/docker-data/dms/config/postfix/main.cf.append
Normal file
@@ -0,0 +1,3 @@
|
||||
# Content Filter Configuration
|
||||
# Routes all local deliveries through content filter on port 10025
|
||||
content_filter = smtp:[localhost]:10025
|
||||
34
DMS/docker-data/dms/config/postfix/master.cf.append
Normal file
34
DMS/docker-data/dms/config/postfix/master.cf.append
Normal file
@@ -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"
|
||||
DST_DIR="/etc/postfix"
|
||||
|
||||
# Dateien nach /etc/postfix kopieren (oder aktualisieren)
|
||||
# install -D -m 0644 "$SRC_DIR/transport" "$DST_DIR/transport"
|
||||
# install -D -m 0600 "$SRC_DIR/sasl_passwd" "$DST_DIR/sasl_passwd"
|
||||
install -D -m 0644 "$SRC_DIR/header_checks" "$DST_DIR/header_checks"
|
||||
echo "[user-patches.sh] Starting Postfix customizations..."
|
||||
|
||||
# Existing patches (header_checks, etc.)
|
||||
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"
|
||||
|
||||
# Maps bauen
|
||||
# postmap "$DST_DIR/transport"
|
||||
# postmap "$DST_DIR/sasl_passwd"
|
||||
# NEW: Append content filter configuration to main.cf
|
||||
if [ -f "$SRC_DIR/main.cf.append" ]; then
|
||||
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
|
||||
# chmod 600 "$DST_DIR/sasl_passwd.db" || true
|
||||
# NEW: Append content filter services to master.cf
|
||||
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 reload || true
|
||||
Reference in New Issue
Block a user