new node.js impl., removed old stuff
This commit is contained in:
parent
4343aefb76
commit
16469de068
|
|
@ -1,26 +0,0 @@
|
||||||
FROM python:3.11-slim
|
|
||||||
|
|
||||||
# Metadata
|
|
||||||
LABEL maintainer="your-email@example.com"
|
|
||||||
LABEL description="Domain-specific email worker for SMTP delivery"
|
|
||||||
|
|
||||||
# Non-root user für Security
|
|
||||||
RUN useradd -m -u 1000 worker && \
|
|
||||||
mkdir -p /app && \
|
|
||||||
chown -R worker:worker /app
|
|
||||||
|
|
||||||
# Boto3 installieren
|
|
||||||
RUN pip install --no-cache-dir boto3
|
|
||||||
|
|
||||||
# Worker Code
|
|
||||||
COPY --chown=worker:worker worker.py /app/worker.py
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
USER worker
|
|
||||||
|
|
||||||
# Healthcheck
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
|
||||||
CMD pgrep -f worker.py || exit 1
|
|
||||||
|
|
||||||
# Start worker mit unbuffered output
|
|
||||||
CMD ["python", "-u", "worker.py"]
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
services:
|
|
||||||
worker:
|
|
||||||
image: python:3.11-slim
|
|
||||||
container_name: email-worker-${WORKER_DOMAIN}
|
|
||||||
restart: unless-stopped
|
|
||||||
network_mode: host # Zugriff auf lokales Netzwerk für Postfix
|
|
||||||
|
|
||||||
# Worker-Code mounten
|
|
||||||
volumes:
|
|
||||||
- ./worker.py:/app/worker.py:ro
|
|
||||||
|
|
||||||
working_dir: /app
|
|
||||||
|
|
||||||
# Python Dependencies installieren und Worker starten
|
|
||||||
command: >
|
|
||||||
sh -c "apt-get update &&
|
|
||||||
apt-get install -y --no-install-recommends procps &&
|
|
||||||
rm -rf /var/lib/apt/lists/* &&
|
|
||||||
pip install --no-cache-dir boto3 &&
|
|
||||||
python -u worker.py"
|
|
||||||
|
|
||||||
environment:
|
|
||||||
# ⚠️ WICHTIG: WORKER_DOMAIN muss von außen gesetzt werden!
|
|
||||||
- WORKER_DOMAIN=${WORKER_DOMAIN}
|
|
||||||
|
|
||||||
# AWS Credentials
|
|
||||||
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
|
|
||||||
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
|
|
||||||
|
|
||||||
# Worker Settings
|
|
||||||
- POLL_INTERVAL=${POLL_INTERVAL:-20}
|
|
||||||
- MAX_MESSAGES=${MAX_MESSAGES:-10}
|
|
||||||
- VISIBILITY_TIMEOUT=${VISIBILITY_TIMEOUT:-300}
|
|
||||||
|
|
||||||
# SMTP Configuration
|
|
||||||
- SMTP_HOST=${SMTP_HOST:-localhost}
|
|
||||||
- SMTP_PORT=${SMTP_PORT:-25}
|
|
||||||
- SMTP_USE_TLS=${SMTP_USE_TLS:-false}
|
|
||||||
- SMTP_USER=${SMTP_USER:-}
|
|
||||||
- SMTP_PASS=${SMTP_PASS:-}
|
|
||||||
|
|
||||||
logging:
|
|
||||||
driver: "json-file"
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "5"
|
|
||||||
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "pgrep", "-f", "worker.py"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 10s
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# manage-worker.sh
|
|
||||||
|
|
||||||
DOMAIN=$1
|
|
||||||
|
|
||||||
if [ -z "$DOMAIN" ]; then
|
|
||||||
echo "Usage: $0 <domain> [action]"
|
|
||||||
echo "Example: $0 andreasknuth.de"
|
|
||||||
echo " $0 andreasknuth.de down"
|
|
||||||
echo " $0 andreasknuth.de logs -f"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Entfernt den ersten Parameter ($1 / DOMAIN) aus der Argumentenliste
|
|
||||||
shift
|
|
||||||
|
|
||||||
# Nimm ALLE verbleibenden Argumente ($@). Wenn keine da sind, nimm "up -d".
|
|
||||||
ACTION="${@:-up -d}"
|
|
||||||
|
|
||||||
PROJECT_NAME="${DOMAIN//./-}"
|
|
||||||
ENV_FILE=".env.${DOMAIN}"
|
|
||||||
|
|
||||||
if [ ! -f "$ENV_FILE" ]; then
|
|
||||||
echo "Error: $ENV_FILE not found!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# $ACTION wird hier nicht in Anführungszeichen gesetzt,
|
|
||||||
# damit "logs -f" als zwei separate Befehle erkannt wird.
|
|
||||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" $ACTION
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# update-all-workers.sh (smart version)
|
|
||||||
|
|
||||||
DOMAINS=$(docker ps --filter "name=email-worker-" --format "{{.Names}}" | sed 's/email-worker-//')
|
|
||||||
|
|
||||||
if [ -z "$DOMAINS" ]; then
|
|
||||||
echo "No workers found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Found workers: $DOMAINS"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
for domain in $DOMAINS; do
|
|
||||||
echo "═══ $domain ═══"
|
|
||||||
./manage-worker.sh "$domain" restart
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "✓ Done"
|
|
||||||
|
|
@ -1,885 +0,0 @@
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import boto3
|
|
||||||
import smtplib
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import traceback
|
|
||||||
import signal
|
|
||||||
from email.parser import BytesParser
|
|
||||||
from email.policy import SMTP as SMTPPolicy
|
|
||||||
from datetime import datetime
|
|
||||||
from botocore.exceptions import ClientError # Neu: Korrekter Import für SES-Exceptions
|
|
||||||
|
|
||||||
# AWS Configuration
|
|
||||||
AWS_REGION = 'us-east-2'
|
|
||||||
s3 = boto3.client('s3', region_name=AWS_REGION)
|
|
||||||
sqs = boto3.client('sqs', region_name=AWS_REGION)
|
|
||||||
ses = boto3.client('ses', region_name=AWS_REGION) # Neu: Für OOO/Forwards
|
|
||||||
|
|
||||||
# ✨ Worker Configuration (domain-spezifisch)
|
|
||||||
WORKER_DOMAIN = os.environ.get('WORKER_DOMAIN') # z.B. 'andreasknuth.de'
|
|
||||||
WORKER_NAME = os.environ.get('WORKER_NAME', f'worker-{WORKER_DOMAIN}')
|
|
||||||
|
|
||||||
# Worker Settings
|
|
||||||
POLL_INTERVAL = int(os.environ.get('POLL_INTERVAL', '20'))
|
|
||||||
MAX_MESSAGES = int(os.environ.get('MAX_MESSAGES', '10'))
|
|
||||||
VISIBILITY_TIMEOUT = int(os.environ.get('VISIBILITY_TIMEOUT', '300'))
|
|
||||||
|
|
||||||
# SMTP Configuration (einfach, da nur 1 Domain pro Worker)
|
|
||||||
SMTP_HOST = os.environ.get('SMTP_HOST', 'localhost')
|
|
||||||
SMTP_PORT = int(os.environ.get('SMTP_PORT', '25'))
|
|
||||||
SMTP_USE_TLS = os.environ.get('SMTP_USE_TLS', 'false').lower() == 'true'
|
|
||||||
SMTP_USER = os.environ.get('SMTP_USER')
|
|
||||||
SMTP_PASS = os.environ.get('SMTP_PASS')
|
|
||||||
|
|
||||||
# Graceful shutdown
|
|
||||||
shutdown_requested = False
|
|
||||||
|
|
||||||
# DynamoDB Ressource für Bounce-Lookup
|
|
||||||
# DynamoDB Ressource für Bounce-Lookup und Rules
|
|
||||||
try:
|
|
||||||
dynamo = boto3.resource('dynamodb', region_name=AWS_REGION)
|
|
||||||
msg_table = dynamo.Table('ses-outbound-messages')
|
|
||||||
rules_table = dynamo.Table('email-rules') # Neu: Für OOO/Forwards
|
|
||||||
except Exception as e:
|
|
||||||
log(f"Warning: Could not connect to DynamoDB: {e}", 'WARNING')
|
|
||||||
msg_table = None
|
|
||||||
rules_table = None
|
|
||||||
|
|
||||||
def get_bucket_name(domain):
|
|
||||||
"""Konvention: domain.tld -> domain-tld-emails"""
|
|
||||||
return domain.replace('.', '-') + '-emails'
|
|
||||||
|
|
||||||
def is_ses_bounce_notification(parsed):
|
|
||||||
"""
|
|
||||||
Prüft ob Email von SES MAILER-DAEMON ist
|
|
||||||
"""
|
|
||||||
from_h = (parsed.get('From') or '').lower()
|
|
||||||
return 'mailer-daemon@us-east-2.amazonses.com' in from_h
|
|
||||||
|
|
||||||
|
|
||||||
def get_bounce_info_from_dynamodb(message_id, max_retries=3, retry_delay=1):
|
|
||||||
"""
|
|
||||||
Sucht Bounce-Info in DynamoDB anhand der Message-ID
|
|
||||||
Mit Retry-Logik für Timing-Issues
|
|
||||||
Returns: dict mit bounce info oder None
|
|
||||||
"""
|
|
||||||
import time
|
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
|
||||||
try:
|
|
||||||
response = msg_table.get_item(Key={'MessageId': message_id})
|
|
||||||
item = response.get('Item')
|
|
||||||
|
|
||||||
if item:
|
|
||||||
# Gefunden!
|
|
||||||
return {
|
|
||||||
'original_source': item.get('original_source', ''),
|
|
||||||
'bounceType': item.get('bounceType', 'Unknown'),
|
|
||||||
'bounceSubType': item.get('bounceSubType', 'Unknown'),
|
|
||||||
'bouncedRecipients': item.get('bouncedRecipients', []),
|
|
||||||
'timestamp': item.get('timestamp', '')
|
|
||||||
}
|
|
||||||
|
|
||||||
# Nicht gefunden - Retry falls nicht letzter Versuch
|
|
||||||
if attempt < max_retries - 1:
|
|
||||||
log(f" Bounce record not found yet, retrying in {retry_delay}s (attempt {attempt + 1}/{max_retries})...")
|
|
||||||
time.sleep(retry_delay)
|
|
||||||
else:
|
|
||||||
log(f"⚠ No bounce record found after {max_retries} attempts for Message-ID: {message_id}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log(f"⚠ DynamoDB Error (attempt {attempt + 1}/{max_retries}): {e}", 'ERROR')
|
|
||||||
if attempt < max_retries - 1:
|
|
||||||
time.sleep(retry_delay)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def apply_bounce_logic(parsed, subject):
|
|
||||||
"""
|
|
||||||
Prüft auf SES Bounce, sucht in DynamoDB und schreibt Header um.
|
|
||||||
Returns: (parsed_email_object, was_modified_bool)
|
|
||||||
"""
|
|
||||||
if not is_ses_bounce_notification(parsed):
|
|
||||||
return parsed, False
|
|
||||||
|
|
||||||
log("🔍 Detected SES MAILER-DAEMON bounce notification")
|
|
||||||
|
|
||||||
# Message-ID aus Header extrahieren
|
|
||||||
message_id = (parsed.get('Message-ID') or '').strip('<>').split('@')[0]
|
|
||||||
|
|
||||||
if not message_id:
|
|
||||||
log("⚠ Could not extract Message-ID from bounce notification")
|
|
||||||
return parsed, False
|
|
||||||
|
|
||||||
log(f" Looking up Message-ID: {message_id}")
|
|
||||||
|
|
||||||
# Lookup in DynamoDB
|
|
||||||
bounce_info = get_bounce_info_from_dynamodb(message_id)
|
|
||||||
|
|
||||||
if not bounce_info:
|
|
||||||
return parsed, False
|
|
||||||
|
|
||||||
# Bounce Info ausgeben
|
|
||||||
original_source = bounce_info['original_source']
|
|
||||||
bounced_recipients = bounce_info['bouncedRecipients']
|
|
||||||
bounce_type = bounce_info['bounceType']
|
|
||||||
bounce_subtype = bounce_info['bounceSubType']
|
|
||||||
|
|
||||||
log(f"✓ Found bounce info:")
|
|
||||||
log(f" Original sender: {original_source}")
|
|
||||||
log(f" Bounce type: {bounce_type}/{bounce_subtype}")
|
|
||||||
log(f" Bounced recipients: {bounced_recipients}")
|
|
||||||
|
|
||||||
# Nehme den ersten bounced recipient als neuen Absender
|
|
||||||
# (bei Multiple Recipients kann es mehrere geben)
|
|
||||||
if bounced_recipients:
|
|
||||||
new_from = bounced_recipients[0]
|
|
||||||
|
|
||||||
# Rewrite Headers
|
|
||||||
parsed['X-Original-SES-From'] = parsed.get('From', '')
|
|
||||||
parsed['X-Bounce-Type'] = f"{bounce_type}/{bounce_subtype}"
|
|
||||||
parsed.replace_header('From', new_from)
|
|
||||||
|
|
||||||
if not parsed.get('Reply-To'):
|
|
||||||
parsed['Reply-To'] = new_from
|
|
||||||
|
|
||||||
# Subject anpassen
|
|
||||||
if 'delivery status notification' in subject.lower() or 'thanks for your submission' in subject.lower():
|
|
||||||
parsed.replace_header('Subject', f"Delivery Status: {new_from}")
|
|
||||||
|
|
||||||
log(f"✓ Rewritten FROM: {new_from}")
|
|
||||||
return parsed, True
|
|
||||||
|
|
||||||
log("⚠ No bounced recipients found in bounce info")
|
|
||||||
return parsed, False
|
|
||||||
|
|
||||||
def signal_handler(signum, frame):
|
|
||||||
global shutdown_requested
|
|
||||||
print(f"\n⚠ Shutdown signal received (signal {signum})")
|
|
||||||
shutdown_requested = True
|
|
||||||
|
|
||||||
|
|
||||||
signal.signal(signal.SIGTERM, signal_handler)
|
|
||||||
signal.signal(signal.SIGINT, signal_handler)
|
|
||||||
|
|
||||||
|
|
||||||
def log(message: str, level: str = 'INFO'):
|
|
||||||
"""Structured logging with timestamp"""
|
|
||||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
||||||
print(f"[{timestamp}] [{level}] [{WORKER_NAME}] {message}", flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
def domain_to_queue_name(domain: str) -> str:
|
|
||||||
"""Konvertiert Domain zu SQS Queue Namen"""
|
|
||||||
return domain.replace('.', '-') + '-queue'
|
|
||||||
|
|
||||||
|
|
||||||
def get_queue_url() -> str:
|
|
||||||
"""Ermittelt Queue-URL für die konfigurierte Domain"""
|
|
||||||
queue_name = domain_to_queue_name(WORKER_DOMAIN)
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = sqs.get_queue_url(QueueName=queue_name)
|
|
||||||
return response['QueueUrl']
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"Failed to get queue URL for {WORKER_DOMAIN}: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def mark_as_processed(bucket: str, key: str, invalid_inboxes: list = None):
|
|
||||||
"""
|
|
||||||
Markiert E-Mail als erfolgreich zugestellt
|
|
||||||
Wird nur aufgerufen wenn mindestens 1 Recipient erfolgreich war
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
head = s3.head_object(Bucket=bucket, Key=key)
|
|
||||||
metadata = head.get('Metadata', {}) or {}
|
|
||||||
|
|
||||||
metadata['processed'] = 'true'
|
|
||||||
metadata['processed_at'] = str(int(time.time()))
|
|
||||||
metadata['processed_by'] = WORKER_NAME
|
|
||||||
metadata['status'] = 'delivered'
|
|
||||||
metadata.pop('processing_started', None)
|
|
||||||
metadata.pop('queued_at', None)
|
|
||||||
|
|
||||||
# Invalid inboxes speichern falls vorhanden
|
|
||||||
if invalid_inboxes:
|
|
||||||
metadata['invalid_inboxes'] = ','.join(invalid_inboxes)
|
|
||||||
log(f"⚠ Invalid inboxes recorded: {', '.join(invalid_inboxes)}", 'WARNING')
|
|
||||||
|
|
||||||
s3.copy_object(
|
|
||||||
Bucket=bucket,
|
|
||||||
Key=key,
|
|
||||||
CopySource={'Bucket': bucket, 'Key': key},
|
|
||||||
Metadata=metadata,
|
|
||||||
MetadataDirective='REPLACE'
|
|
||||||
)
|
|
||||||
|
|
||||||
log(f"✓ Marked s3://{bucket}/{key} as processed", 'SUCCESS')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log(f"Failed to mark as processed: {e}", 'WARNING')
|
|
||||||
|
|
||||||
|
|
||||||
def mark_as_all_invalid(bucket: str, key: str, invalid_inboxes: list):
|
|
||||||
"""
|
|
||||||
Markiert E-Mail als fehlgeschlagen weil alle Recipients ungültig sind
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
head = s3.head_object(Bucket=bucket, Key=key)
|
|
||||||
metadata = head.get('Metadata', {}) or {}
|
|
||||||
|
|
||||||
metadata['processed'] = 'true'
|
|
||||||
metadata['processed_at'] = str(int(time.time()))
|
|
||||||
metadata['processed_by'] = WORKER_NAME
|
|
||||||
metadata['status'] = 'failed'
|
|
||||||
metadata['error'] = 'All recipients are invalid (mailboxes do not exist)'
|
|
||||||
metadata['invalid_inboxes'] = ','.join(invalid_inboxes)
|
|
||||||
metadata.pop('processing_started', None)
|
|
||||||
metadata.pop('queued_at', None)
|
|
||||||
|
|
||||||
s3.copy_object(
|
|
||||||
Bucket=bucket,
|
|
||||||
Key=key,
|
|
||||||
CopySource={'Bucket': bucket, 'Key': key},
|
|
||||||
Metadata=metadata,
|
|
||||||
MetadataDirective='REPLACE'
|
|
||||||
)
|
|
||||||
|
|
||||||
log(f"✓ Marked s3://{bucket}/{key} as failed (all invalid)", 'SUCCESS')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log(f"Failed to mark as all invalid: {e}", 'WARNING')
|
|
||||||
|
|
||||||
|
|
||||||
def mark_as_failed(bucket: str, key: str, error: str, receive_count: int):
|
|
||||||
"""
|
|
||||||
Markiert E-Mail als komplett fehlgeschlagen
|
|
||||||
Wird nur aufgerufen wenn ALLE Recipients fehlschlagen
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
head = s3.head_object(Bucket=bucket, Key=key)
|
|
||||||
metadata = head.get('Metadata', {}) or {}
|
|
||||||
|
|
||||||
metadata['status'] = 'failed'
|
|
||||||
metadata['failed_at'] = str(int(time.time()))
|
|
||||||
metadata['failed_by'] = WORKER_NAME
|
|
||||||
metadata['error'] = error[:500] # S3 Metadata limit
|
|
||||||
metadata['retry_count'] = str(receive_count)
|
|
||||||
metadata.pop('processing_started', None)
|
|
||||||
|
|
||||||
s3.copy_object(
|
|
||||||
Bucket=bucket,
|
|
||||||
Key=key,
|
|
||||||
CopySource={'Bucket': bucket, 'Key': key},
|
|
||||||
Metadata=metadata,
|
|
||||||
MetadataDirective='REPLACE'
|
|
||||||
)
|
|
||||||
|
|
||||||
log(f"✗ Marked s3://{bucket}/{key} as failed: {error[:100]}", 'ERROR')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log(f"Failed to mark as failed: {e}", 'WARNING')
|
|
||||||
|
|
||||||
|
|
||||||
def is_temporary_smtp_error(error_msg: str) -> bool:
|
|
||||||
"""
|
|
||||||
Prüft ob SMTP-Fehler temporär ist (Retry sinnvoll)
|
|
||||||
4xx Codes = temporär, 5xx = permanent
|
|
||||||
"""
|
|
||||||
temporary_indicators = [
|
|
||||||
'421', # Service not available
|
|
||||||
'450', # Mailbox unavailable
|
|
||||||
'451', # Local error
|
|
||||||
'452', # Insufficient storage
|
|
||||||
'4', # Generisch 4xx
|
|
||||||
'timeout',
|
|
||||||
'connection refused',
|
|
||||||
'connection reset',
|
|
||||||
'network unreachable',
|
|
||||||
'temporarily',
|
|
||||||
'try again'
|
|
||||||
]
|
|
||||||
|
|
||||||
error_lower = error_msg.lower()
|
|
||||||
return any(indicator in error_lower for indicator in temporary_indicators)
|
|
||||||
|
|
||||||
|
|
||||||
def is_permanent_recipient_error(error_msg: str) -> bool:
|
|
||||||
"""
|
|
||||||
Prüft ob Fehler permanent für diesen Recipient ist (Inbox existiert nicht)
|
|
||||||
550 = Mailbox not found, 551 = User not local, 553 = Mailbox name invalid
|
|
||||||
"""
|
|
||||||
permanent_indicators = [
|
|
||||||
'550', # Mailbox unavailable / not found
|
|
||||||
'551', # User not local
|
|
||||||
'553', # Mailbox name not allowed / invalid
|
|
||||||
'mailbox not found',
|
|
||||||
'user unknown',
|
|
||||||
'no such user',
|
|
||||||
'recipient rejected',
|
|
||||||
'does not exist',
|
|
||||||
'invalid recipient',
|
|
||||||
'unknown user'
|
|
||||||
]
|
|
||||||
|
|
||||||
error_lower = error_msg.lower()
|
|
||||||
return any(indicator in error_lower for indicator in permanent_indicators)
|
|
||||||
|
|
||||||
|
|
||||||
def send_email(from_addr: str, recipient: str, raw_message: bytes) -> tuple:
|
|
||||||
"""
|
|
||||||
Sendet E-Mail via SMTP an EINEN Empfänger
|
|
||||||
Returns: (success: bool, error: str or None, is_permanent: bool)
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=30) as smtp:
|
|
||||||
smtp.ehlo()
|
|
||||||
|
|
||||||
# STARTTLS falls konfiguriert
|
|
||||||
if SMTP_USE_TLS:
|
|
||||||
try:
|
|
||||||
smtp.starttls()
|
|
||||||
smtp.ehlo()
|
|
||||||
except Exception as e:
|
|
||||||
log(f" STARTTLS failed: {e}", 'WARNING')
|
|
||||||
|
|
||||||
# Authentication falls konfiguriert
|
|
||||||
if SMTP_USER and SMTP_PASS:
|
|
||||||
try:
|
|
||||||
smtp.login(SMTP_USER, SMTP_PASS)
|
|
||||||
except Exception as e:
|
|
||||||
log(f" SMTP auth failed: {e}", 'WARNING')
|
|
||||||
|
|
||||||
# E-Mail senden
|
|
||||||
result = smtp.sendmail(from_addr, [recipient], raw_message)
|
|
||||||
|
|
||||||
# Result auswerten
|
|
||||||
if isinstance(result, dict) and result:
|
|
||||||
# Empfänger wurde abgelehnt
|
|
||||||
error = result.get(recipient, 'Unknown refusal')
|
|
||||||
is_permanent = is_permanent_recipient_error(str(error))
|
|
||||||
log(f" ✗ {recipient}: {error} ({'permanent' if is_permanent else 'temporary'})", 'ERROR')
|
|
||||||
return False, str(error), is_permanent
|
|
||||||
else:
|
|
||||||
# Erfolgreich
|
|
||||||
log(f" ✓ {recipient}: Delivered", 'SUCCESS')
|
|
||||||
return True, None, False
|
|
||||||
|
|
||||||
except smtplib.SMTPException as e:
|
|
||||||
error_msg = str(e)
|
|
||||||
is_permanent = is_permanent_recipient_error(error_msg)
|
|
||||||
log(f" ✗ {recipient}: SMTP error - {error_msg}", 'ERROR')
|
|
||||||
return False, error_msg, is_permanent
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# Connection errors sind immer temporär
|
|
||||||
log(f" ✗ {recipient}: Connection error - {e}", 'ERROR')
|
|
||||||
return False, str(e), False
|
|
||||||
|
|
||||||
def extract_body_parts(parsed):
|
|
||||||
"""
|
|
||||||
Extrahiert sowohl text/plain als auch text/html Body-Parts.
|
|
||||||
Returns: (text_body: str, html_body: str or None)
|
|
||||||
"""
|
|
||||||
text_body = ''
|
|
||||||
html_body = None
|
|
||||||
|
|
||||||
if parsed.is_multipart():
|
|
||||||
for part in parsed.walk():
|
|
||||||
content_type = part.get_content_type()
|
|
||||||
|
|
||||||
if content_type == 'text/plain':
|
|
||||||
try:
|
|
||||||
text_body += part.get_payload(decode=True).decode('utf-8', errors='ignore')
|
|
||||||
except Exception as e:
|
|
||||||
log(f"⚠ Error decoding text/plain part: {e}", 'WARNING')
|
|
||||||
|
|
||||||
elif content_type == 'text/html':
|
|
||||||
try:
|
|
||||||
html_body = part.get_payload(decode=True).decode('utf-8', errors='ignore')
|
|
||||||
except Exception as e:
|
|
||||||
log(f"⚠ Error decoding text/html part: {e}", 'WARNING')
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
payload = parsed.get_payload(decode=True)
|
|
||||||
if payload:
|
|
||||||
decoded = payload.decode('utf-8', errors='ignore')
|
|
||||||
if parsed.get_content_type() == 'text/html':
|
|
||||||
html_body = decoded
|
|
||||||
else:
|
|
||||||
text_body = decoded
|
|
||||||
except Exception as e:
|
|
||||||
log(f"⚠ Error decoding non-multipart body: {e}", 'WARNING')
|
|
||||||
text_body = str(parsed.get_payload())
|
|
||||||
|
|
||||||
return text_body.strip() if text_body else '(No body content)', html_body
|
|
||||||
|
|
||||||
|
|
||||||
def create_ooo_reply(original_parsed, recipient, ooo_msg, content_type='text'):
|
|
||||||
"""
|
|
||||||
Erstellt eine Out-of-Office Reply als komplette MIME-Message.
|
|
||||||
Behält Original-Body (text + html) bei.
|
|
||||||
"""
|
|
||||||
from email.mime.multipart import MIMEMultipart
|
|
||||||
from email.mime.text import MIMEText
|
|
||||||
from email.utils import formatdate, make_msgid
|
|
||||||
|
|
||||||
text_body, html_body = extract_body_parts(original_parsed)
|
|
||||||
original_subject = original_parsed.get('Subject', '(no subject)')
|
|
||||||
original_from = original_parsed.get('From', 'unknown')
|
|
||||||
|
|
||||||
# Neue Message erstellen
|
|
||||||
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' # Verhindert Loops
|
|
||||||
|
|
||||||
# Body-Teil erstellen
|
|
||||||
body_part = MIMEMultipart('alternative')
|
|
||||||
|
|
||||||
# Text-Version
|
|
||||||
text_content = f"{ooo_msg}\n\n"
|
|
||||||
text_content += "--- 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 (wenn gewünscht und Original vorhanden)
|
|
||||||
if content_type == 'html' or html_body:
|
|
||||||
html_content = f"<div>{ooo_msg}</div><br><hr><br>"
|
|
||||||
html_content += "<blockquote style='margin:10px 0;padding:10px;border-left:3px solid #ccc;'>"
|
|
||||||
html_content += f"<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>'))
|
|
||||||
html_content += "</blockquote>"
|
|
||||||
body_part.attach(MIMEText(html_content, 'html', 'utf-8'))
|
|
||||||
|
|
||||||
msg.attach(body_part)
|
|
||||||
return msg
|
|
||||||
|
|
||||||
|
|
||||||
def create_forward_message(original_parsed, recipient, forward_to, original_from):
|
|
||||||
"""
|
|
||||||
Erstellt eine Forward-Message als komplette MIME-Message.
|
|
||||||
Behält ALLE Original-Parts inkl. Attachments bei.
|
|
||||||
"""
|
|
||||||
from email.mime.multipart import MIMEMultipart
|
|
||||||
from email.mime.text import MIMEText
|
|
||||||
from email.utils import formatdate, make_msgid
|
|
||||||
|
|
||||||
original_subject = original_parsed.get('Subject', '(no subject)')
|
|
||||||
original_date = original_parsed.get('Date', 'unknown')
|
|
||||||
|
|
||||||
# Neue Message erstellen
|
|
||||||
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
|
|
||||||
|
|
||||||
# Forward-Header als Text
|
|
||||||
text_body, html_body = extract_body_parts(original_parsed)
|
|
||||||
|
|
||||||
# Body-Teil
|
|
||||||
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;margin:10px 0;'>"
|
|
||||||
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)
|
|
||||||
|
|
||||||
# WICHTIG: Attachments kopieren
|
|
||||||
if original_parsed.is_multipart():
|
|
||||||
for part in original_parsed.walk():
|
|
||||||
# Nur non-body parts (Attachments)
|
|
||||||
if part.get_content_maintype() == 'multipart':
|
|
||||||
continue
|
|
||||||
if part.get_content_type() in ['text/plain', 'text/html']:
|
|
||||||
continue # Body bereits oben behandelt
|
|
||||||
|
|
||||||
# Attachment hinzufügen
|
|
||||||
msg.attach(part)
|
|
||||||
|
|
||||||
return msg
|
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# HAUPTFUNKTION: PROCESS MESSAGE
|
|
||||||
# ==========================================
|
|
||||||
|
|
||||||
def process_message(message_body: dict, receive_count: int) -> bool:
|
|
||||||
"""
|
|
||||||
Verarbeitet eine E-Mail aus der Queue (SNS-wrapped SES Notification)
|
|
||||||
Returns: True (Erfolg/Löschen), False (Retry/Behalten)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# 1. UNPACKING (SNS -> SES)
|
|
||||||
# SQS Body ist JSON. Darin ist meist 'Type': 'Notification' und 'Message': '...JSONString...'
|
|
||||||
if 'Message' in message_body and 'Type' in message_body:
|
|
||||||
# Es ist eine SNS Notification
|
|
||||||
sns_content = message_body['Message']
|
|
||||||
if isinstance(sns_content, str):
|
|
||||||
ses_msg = json.loads(sns_content)
|
|
||||||
else:
|
|
||||||
ses_msg = sns_content
|
|
||||||
else:
|
|
||||||
# Fallback: Vielleicht doch direkt SES (Legacy support)
|
|
||||||
ses_msg = message_body
|
|
||||||
|
|
||||||
# 2. DATEN EXTRAHIEREN
|
|
||||||
mail = ses_msg.get('mail', {})
|
|
||||||
receipt = ses_msg.get('receipt', {})
|
|
||||||
|
|
||||||
message_id = mail.get('messageId') # Das ist der S3 Key!
|
|
||||||
# FIX: Amazon SES Setup Notification ignorieren
|
|
||||||
if message_id == "AMAZON_SES_SETUP_NOTIFICATION":
|
|
||||||
log("ℹ️ Received Amazon SES Setup Notification. Ignoring.", 'INFO')
|
|
||||||
return True # Erfolgreich (löschen), da kein Fehler
|
|
||||||
from_addr = mail.get('source')
|
|
||||||
recipients = receipt.get('recipients', [])
|
|
||||||
|
|
||||||
# S3 Key Validation
|
|
||||||
if not message_id:
|
|
||||||
log("❌ Error: No messageId in event payload", 'ERROR')
|
|
||||||
return True # Löschen, da unbrauchbar
|
|
||||||
|
|
||||||
# Domain Validation
|
|
||||||
# Wir nehmen den ersten Empfänger um die Domain zu prüfen
|
|
||||||
if recipients:
|
|
||||||
first_recipient = recipients[0]
|
|
||||||
domain = first_recipient.split('@')[1]
|
|
||||||
|
|
||||||
if domain.lower() != WORKER_DOMAIN.lower():
|
|
||||||
log(f"⚠ Security: Ignored message for {domain} (I am worker for {WORKER_DOMAIN})", 'WARNING')
|
|
||||||
return True # Löschen, gehört nicht hierher
|
|
||||||
else:
|
|
||||||
log("⚠ Warning: No recipients in event", 'WARNING')
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Bucket Name ableiten
|
|
||||||
bucket = get_bucket_name(WORKER_DOMAIN)
|
|
||||||
key = message_id
|
|
||||||
|
|
||||||
log(f"\n{'='*70}")
|
|
||||||
log(f"Processing Email (SNS/SES):")
|
|
||||||
log(f" ID: {key}")
|
|
||||||
log(f" Recipients: {len(recipients)} -> {recipients}")
|
|
||||||
log(f" Bucket: {bucket}")
|
|
||||||
|
|
||||||
# 3. LADEN AUS S3
|
|
||||||
try:
|
|
||||||
response = s3.get_object(Bucket=bucket, Key=key)
|
|
||||||
raw_bytes = response['Body'].read()
|
|
||||||
log(f"✓ Loaded {len(raw_bytes)} bytes from S3")
|
|
||||||
except s3.exceptions.NoSuchKey:
|
|
||||||
# Race Condition: SNS war schneller als S3.
|
|
||||||
# Wir geben False zurück, damit SQS es in 30s nochmal versucht.
|
|
||||||
if receive_count < 5:
|
|
||||||
log(f"⏳ S3 Object not found yet (Attempt {receive_count}). Retrying...", 'WARNING')
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
log(f"❌ S3 Object missing permanently after retries.", 'ERROR')
|
|
||||||
return True # Löschen
|
|
||||||
except Exception as e:
|
|
||||||
log(f"❌ S3 Download Error: {e}", 'ERROR')
|
|
||||||
return False # Retry
|
|
||||||
|
|
||||||
# 4. PARSING & BOUNCE LOGIC
|
|
||||||
try:
|
|
||||||
parsed = BytesParser(policy=SMTPPolicy).parsebytes(raw_bytes)
|
|
||||||
subject = parsed.get('Subject', '(no subject)')
|
|
||||||
|
|
||||||
# Hier passiert die Magie: Bounce Header umschreiben
|
|
||||||
parsed, modified = apply_bounce_logic(parsed, subject)
|
|
||||||
|
|
||||||
if modified:
|
|
||||||
log(" ✨ Bounce detected & headers rewritten via DynamoDB")
|
|
||||||
# Wir arbeiten mit den modifizierten Bytes weiter
|
|
||||||
raw_bytes = parsed.as_bytes()
|
|
||||||
from_addr_final = parsed.get('From') # Neuer Absender für SMTP Envelope
|
|
||||||
else:
|
|
||||||
from_addr_final = from_addr # Original Envelope Sender
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log(f"⚠ Parsing/Logic Error: {e}. Sending original.", 'WARNING')
|
|
||||||
from_addr_final = from_addr
|
|
||||||
|
|
||||||
# 5. OOO & FORWARD LOGIC (neu, vor SMTP-Versand)
|
|
||||||
if rules_table and not is_ses_bounce_notification(parsed):
|
|
||||||
for recipient in recipients:
|
|
||||||
try:
|
|
||||||
rule = rules_table.get_item(Key={'email_address': recipient}).get('Item', {})
|
|
||||||
|
|
||||||
# OOO handling
|
|
||||||
if rule.get('ooo_active', False):
|
|
||||||
ooo_msg = rule.get('ooo_message', 'Default OOO message.')
|
|
||||||
content_type = rule.get('ooo_content_type', 'text')
|
|
||||||
sender = parsed.get('From')
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Erstelle komplette MIME-Message
|
|
||||||
ooo_reply = create_ooo_reply(parsed, recipient, ooo_msg, content_type)
|
|
||||||
|
|
||||||
# Sende via send_raw_email (unterstützt komplexe MIME)
|
|
||||||
ses.send_raw_email(
|
|
||||||
Source=recipient,
|
|
||||||
Destinations=[sender],
|
|
||||||
RawMessage={'Data': ooo_reply.as_bytes()}
|
|
||||||
)
|
|
||||||
log(f"✓ Sent OOO reply to {sender} from {recipient}")
|
|
||||||
|
|
||||||
except ClientError as e:
|
|
||||||
error_code = e.response['Error']['Code']
|
|
||||||
log(f"⚠ SES OOO send failed ({error_code}): {e}", 'ERROR')
|
|
||||||
|
|
||||||
# Forward handling
|
|
||||||
forwards = rule.get('forwards', [])
|
|
||||||
if forwards:
|
|
||||||
original_from = parsed.get('From')
|
|
||||||
|
|
||||||
for forward_to in forwards:
|
|
||||||
try:
|
|
||||||
# Erstelle komplette Forward-Message mit Attachments
|
|
||||||
fwd_msg = create_forward_message(parsed, recipient, forward_to, original_from)
|
|
||||||
|
|
||||||
# Sende via send_raw_email
|
|
||||||
ses.send_raw_email(
|
|
||||||
Source=recipient,
|
|
||||||
Destinations=[forward_to],
|
|
||||||
RawMessage={'Data': fwd_msg.as_bytes()}
|
|
||||||
)
|
|
||||||
log(f"✓ Forwarded to {forward_to} from {recipient} (original: {original_from})")
|
|
||||||
|
|
||||||
except ClientError as e:
|
|
||||||
error_code = e.response['Error']['Code']
|
|
||||||
log(f"⚠ SES forward failed to {forward_to} ({error_code}): {e}", 'ERROR')
|
|
||||||
|
|
||||||
except ClientError as e:
|
|
||||||
error_code = e.response['Error']['Code']
|
|
||||||
if error_code == 'MessageRejected':
|
|
||||||
log(f"⚠ SES rejected send for {recipient}: Check verification/quotas.", 'ERROR')
|
|
||||||
elif error_code == 'AccessDenied':
|
|
||||||
log(f"⚠ SES AccessDenied for {recipient}: Check IAM policy.", 'ERROR')
|
|
||||||
else:
|
|
||||||
log(f"⚠ SES error for {recipient}: {e}", 'ERROR')
|
|
||||||
except Exception as e:
|
|
||||||
log(f"⚠ Rule processing error for {recipient}: {e}", 'WARNING')
|
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
# 6. SMTP VERSAND (Loop über Recipients)
|
|
||||||
log(f"📤 Sending to {len(recipients)} recipient(s)...")
|
|
||||||
|
|
||||||
successful = []
|
|
||||||
failed_permanent = []
|
|
||||||
failed_temporary = []
|
|
||||||
|
|
||||||
for recipient in recipients:
|
|
||||||
# Wir nutzen raw_bytes (ggf. modifiziert)
|
|
||||||
# WICHTIG: Als Envelope Sender nutzen wir 'from_addr_final'
|
|
||||||
# (bei Bounces ist das der Original-Empfänger, sonst der SES Sender)
|
|
||||||
success, error, is_perm = send_email(from_addr_final, recipient, raw_bytes)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
successful.append(recipient)
|
|
||||||
elif is_perm:
|
|
||||||
failed_permanent.append(recipient)
|
|
||||||
else:
|
|
||||||
failed_temporary.append(recipient)
|
|
||||||
|
|
||||||
# 6. RESULTAT & CLEANUP
|
|
||||||
log(f"📊 Results: {len(successful)} OK, {len(failed_temporary)} TempFail, {len(failed_permanent)} PermFail")
|
|
||||||
|
|
||||||
if len(successful) > 0:
|
|
||||||
# Mindestens einer durchgegangen -> Erfolg
|
|
||||||
mark_as_processed(bucket, key, failed_permanent if failed_permanent else None)
|
|
||||||
log(f"✅ Success. Deleted from queue.")
|
|
||||||
return True
|
|
||||||
|
|
||||||
elif len(failed_permanent) == len(recipients):
|
|
||||||
# Alle permanent fehlgeschlagen (User unknown) -> Löschen
|
|
||||||
mark_as_all_invalid(bucket, key, failed_permanent)
|
|
||||||
log(f"🛑 All recipients invalid. Deleted from queue.")
|
|
||||||
return True
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Temporäre Fehler -> Retry
|
|
||||||
log(f"🔄 Temporary failures. Keeping in queue.")
|
|
||||||
return False
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log(f"❌ CRITICAL WORKER ERROR: {e}", 'ERROR')
|
|
||||||
traceback.print_exc()
|
|
||||||
return False # Retry (außer es crasht immer wieder)
|
|
||||||
|
|
||||||
|
|
||||||
def main_loop():
|
|
||||||
"""Hauptschleife: Pollt SQS Queue und verarbeitet Nachrichten"""
|
|
||||||
|
|
||||||
# Queue URL ermitteln
|
|
||||||
try:
|
|
||||||
queue_url = get_queue_url()
|
|
||||||
except Exception as e:
|
|
||||||
log(f"FATAL: {e}", 'ERROR')
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
log(f"\n{'='*70}")
|
|
||||||
log(f"🚀 Email Worker started")
|
|
||||||
log(f"{'='*70}")
|
|
||||||
log(f" Worker Name: {WORKER_NAME}")
|
|
||||||
log(f" Domain: {WORKER_DOMAIN}")
|
|
||||||
log(f" Queue: {queue_url}")
|
|
||||||
log(f" Region: {AWS_REGION}")
|
|
||||||
log(f" SMTP: {SMTP_HOST}:{SMTP_PORT} (TLS: {SMTP_USE_TLS})")
|
|
||||||
log(f" Poll interval: {POLL_INTERVAL}s")
|
|
||||||
log(f" Max messages per poll: {MAX_MESSAGES}")
|
|
||||||
log(f" Visibility timeout: {VISIBILITY_TIMEOUT}s")
|
|
||||||
log(f"{'='*70}\n")
|
|
||||||
|
|
||||||
consecutive_errors = 0
|
|
||||||
max_consecutive_errors = 10
|
|
||||||
messages_processed = 0
|
|
||||||
last_activity = time.time()
|
|
||||||
|
|
||||||
while not shutdown_requested:
|
|
||||||
try:
|
|
||||||
# Messages aus Queue holen (Long Polling)
|
|
||||||
response = sqs.receive_message(
|
|
||||||
QueueUrl=queue_url,
|
|
||||||
MaxNumberOfMessages=MAX_MESSAGES,
|
|
||||||
WaitTimeSeconds=POLL_INTERVAL,
|
|
||||||
VisibilityTimeout=VISIBILITY_TIMEOUT,
|
|
||||||
AttributeNames=['ApproximateReceiveCount', 'SentTimestamp'],
|
|
||||||
MessageAttributeNames=['All']
|
|
||||||
)
|
|
||||||
|
|
||||||
# Reset error counter bei erfolgreicher Abfrage
|
|
||||||
consecutive_errors = 0
|
|
||||||
|
|
||||||
if 'Messages' not in response:
|
|
||||||
# Keine Nachrichten
|
|
||||||
if time.time() - last_activity > 60:
|
|
||||||
log(f"Waiting for messages... (processed: {messages_processed})")
|
|
||||||
last_activity = time.time()
|
|
||||||
continue
|
|
||||||
|
|
||||||
message_count = len(response['Messages'])
|
|
||||||
log(f"\n✉ Received {message_count} message(s) from queue")
|
|
||||||
last_activity = time.time()
|
|
||||||
|
|
||||||
# Messages verarbeiten
|
|
||||||
for msg in response['Messages']:
|
|
||||||
if shutdown_requested:
|
|
||||||
log("Shutdown requested, stopping processing")
|
|
||||||
break
|
|
||||||
|
|
||||||
receipt_handle = msg['ReceiptHandle']
|
|
||||||
|
|
||||||
# Receive Count auslesen
|
|
||||||
receive_count = int(msg.get('Attributes', {}).get('ApproximateReceiveCount', 1))
|
|
||||||
|
|
||||||
# Sent Timestamp (für Queue-Zeit-Berechnung)
|
|
||||||
sent_timestamp = int(msg.get('Attributes', {}).get('SentTimestamp', 0)) / 1000
|
|
||||||
queue_time = int(time.time() - sent_timestamp) if sent_timestamp else 0
|
|
||||||
|
|
||||||
if queue_time > 0:
|
|
||||||
log(f"Message was in queue for {queue_time}s")
|
|
||||||
|
|
||||||
try:
|
|
||||||
message_body = json.loads(msg['Body'])
|
|
||||||
|
|
||||||
# E-Mail verarbeiten
|
|
||||||
success = process_message(message_body, receive_count)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
# Message aus Queue löschen
|
|
||||||
sqs.delete_message(
|
|
||||||
QueueUrl=queue_url,
|
|
||||||
ReceiptHandle=receipt_handle
|
|
||||||
)
|
|
||||||
log("✓ Message deleted from queue")
|
|
||||||
messages_processed += 1
|
|
||||||
else:
|
|
||||||
# Bei Fehler bleibt Message in Queue
|
|
||||||
log(f"⚠ Message kept in queue for retry (attempt {receive_count}/3)")
|
|
||||||
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
log(f"✗ Invalid message format: {e}", 'ERROR')
|
|
||||||
# Ungültige Messages löschen (nicht retryable)
|
|
||||||
sqs.delete_message(
|
|
||||||
QueueUrl=queue_url,
|
|
||||||
ReceiptHandle=receipt_handle
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log(f"✗ Error processing message: {e}", 'ERROR')
|
|
||||||
traceback.print_exc()
|
|
||||||
# Message bleibt in Queue für Retry
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
log("\n⚠ Keyboard interrupt received")
|
|
||||||
break
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
consecutive_errors += 1
|
|
||||||
log(f"✗ Error in main loop ({consecutive_errors}/{max_consecutive_errors}): {e}", 'ERROR')
|
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
if consecutive_errors >= max_consecutive_errors:
|
|
||||||
log("Too many consecutive errors, shutting down", 'ERROR')
|
|
||||||
break
|
|
||||||
|
|
||||||
# Kurze Pause bei Fehlern
|
|
||||||
time.sleep(5)
|
|
||||||
|
|
||||||
log(f"\n{'='*70}")
|
|
||||||
log(f"👋 Worker shutting down")
|
|
||||||
log(f" Messages processed: {messages_processed}")
|
|
||||||
log(f"{'='*70}\n")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
# Validierung
|
|
||||||
if not WORKER_DOMAIN:
|
|
||||||
log("ERROR: WORKER_DOMAIN not set!", 'ERROR')
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
try:
|
|
||||||
main_loop()
|
|
||||||
except Exception as e:
|
|
||||||
log(f"Fatal error: {e}", 'ERROR')
|
|
||||||
traceback.print_exc()
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
@ -1,520 +0,0 @@
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import boto3
|
|
||||||
import smtplib
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import traceback
|
|
||||||
import signal
|
|
||||||
from email.parser import BytesParser
|
|
||||||
from email.policy import SMTP as SMTPPolicy
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
# AWS Configuration
|
|
||||||
AWS_REGION = 'us-east-2'
|
|
||||||
s3 = boto3.client('s3', region_name=AWS_REGION)
|
|
||||||
sqs = boto3.client('sqs', region_name=AWS_REGION)
|
|
||||||
|
|
||||||
# ✨ Worker Configuration (domain-spezifisch)
|
|
||||||
WORKER_DOMAIN = os.environ.get('WORKER_DOMAIN') # z.B. 'andreasknuth.de'
|
|
||||||
WORKER_NAME = os.environ.get('WORKER_NAME', f'worker-{WORKER_DOMAIN}')
|
|
||||||
|
|
||||||
# Worker Settings
|
|
||||||
POLL_INTERVAL = int(os.environ.get('POLL_INTERVAL', '20'))
|
|
||||||
MAX_MESSAGES = int(os.environ.get('MAX_MESSAGES', '10'))
|
|
||||||
VISIBILITY_TIMEOUT = int(os.environ.get('VISIBILITY_TIMEOUT', '300'))
|
|
||||||
|
|
||||||
# SMTP Configuration (einfach, da nur 1 Domain pro Worker)
|
|
||||||
SMTP_HOST = os.environ.get('SMTP_HOST', 'localhost')
|
|
||||||
SMTP_PORT = int(os.environ.get('SMTP_PORT', '25'))
|
|
||||||
SMTP_USE_TLS = os.environ.get('SMTP_USE_TLS', 'false').lower() == 'true'
|
|
||||||
SMTP_USER = os.environ.get('SMTP_USER')
|
|
||||||
SMTP_PASS = os.environ.get('SMTP_PASS')
|
|
||||||
|
|
||||||
# Graceful shutdown
|
|
||||||
shutdown_requested = False
|
|
||||||
|
|
||||||
|
|
||||||
def signal_handler(signum, frame):
|
|
||||||
global shutdown_requested
|
|
||||||
print(f"\n⚠ Shutdown signal received (signal {signum})")
|
|
||||||
shutdown_requested = True
|
|
||||||
|
|
||||||
|
|
||||||
signal.signal(signal.SIGTERM, signal_handler)
|
|
||||||
signal.signal(signal.SIGINT, signal_handler)
|
|
||||||
|
|
||||||
|
|
||||||
def log(message: str, level: str = 'INFO'):
|
|
||||||
"""Structured logging with timestamp"""
|
|
||||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
||||||
print(f"[{timestamp}] [{level}] [{WORKER_NAME}] {message}", flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
def domain_to_queue_name(domain: str) -> str:
|
|
||||||
"""Konvertiert Domain zu SQS Queue Namen"""
|
|
||||||
return domain.replace('.', '-') + '-queue'
|
|
||||||
|
|
||||||
|
|
||||||
def get_queue_url() -> str:
|
|
||||||
"""Ermittelt Queue-URL für die konfigurierte Domain"""
|
|
||||||
queue_name = domain_to_queue_name(WORKER_DOMAIN)
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = sqs.get_queue_url(QueueName=queue_name)
|
|
||||||
return response['QueueUrl']
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"Failed to get queue URL for {WORKER_DOMAIN}: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def mark_as_processed(bucket: str, key: str, invalid_inboxes: list = None):
|
|
||||||
"""
|
|
||||||
Markiert E-Mail als erfolgreich zugestellt
|
|
||||||
Wird nur aufgerufen wenn mindestens 1 Recipient erfolgreich war
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
head = s3.head_object(Bucket=bucket, Key=key)
|
|
||||||
metadata = head.get('Metadata', {}) or {}
|
|
||||||
|
|
||||||
metadata['processed'] = 'true'
|
|
||||||
metadata['processed_at'] = str(int(time.time()))
|
|
||||||
metadata['processed_by'] = WORKER_NAME
|
|
||||||
metadata['status'] = 'delivered'
|
|
||||||
metadata.pop('processing_started', None)
|
|
||||||
metadata.pop('queued_at', None)
|
|
||||||
|
|
||||||
# Invalid inboxes speichern falls vorhanden
|
|
||||||
if invalid_inboxes:
|
|
||||||
metadata['invalid_inboxes'] = ','.join(invalid_inboxes)
|
|
||||||
log(f"⚠ Invalid inboxes recorded: {', '.join(invalid_inboxes)}", 'WARNING')
|
|
||||||
|
|
||||||
s3.copy_object(
|
|
||||||
Bucket=bucket,
|
|
||||||
Key=key,
|
|
||||||
CopySource={'Bucket': bucket, 'Key': key},
|
|
||||||
Metadata=metadata,
|
|
||||||
MetadataDirective='REPLACE'
|
|
||||||
)
|
|
||||||
|
|
||||||
log(f"✓ Marked s3://{bucket}/{key} as processed", 'SUCCESS')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log(f"Failed to mark as processed: {e}", 'WARNING')
|
|
||||||
|
|
||||||
|
|
||||||
def mark_as_all_invalid(bucket: str, key: str, invalid_inboxes: list):
|
|
||||||
"""
|
|
||||||
Markiert E-Mail als fehlgeschlagen weil alle Recipients ungültig sind
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
head = s3.head_object(Bucket=bucket, Key=key)
|
|
||||||
metadata = head.get('Metadata', {}) or {}
|
|
||||||
|
|
||||||
metadata['processed'] = 'true'
|
|
||||||
metadata['processed_at'] = str(int(time.time()))
|
|
||||||
metadata['processed_by'] = WORKER_NAME
|
|
||||||
metadata['status'] = 'failed'
|
|
||||||
metadata['error'] = 'All recipients are invalid (mailboxes do not exist)'
|
|
||||||
metadata['invalid_inboxes'] = ','.join(invalid_inboxes)
|
|
||||||
metadata.pop('processing_started', None)
|
|
||||||
metadata.pop('queued_at', None)
|
|
||||||
|
|
||||||
s3.copy_object(
|
|
||||||
Bucket=bucket,
|
|
||||||
Key=key,
|
|
||||||
CopySource={'Bucket': bucket, 'Key': key},
|
|
||||||
Metadata=metadata,
|
|
||||||
MetadataDirective='REPLACE'
|
|
||||||
)
|
|
||||||
|
|
||||||
log(f"✓ Marked s3://{bucket}/{key} as failed (all invalid)", 'SUCCESS')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log(f"Failed to mark as all invalid: {e}", 'WARNING')
|
|
||||||
|
|
||||||
|
|
||||||
def mark_as_failed(bucket: str, key: str, error: str, receive_count: int):
|
|
||||||
"""
|
|
||||||
Markiert E-Mail als komplett fehlgeschlagen
|
|
||||||
Wird nur aufgerufen wenn ALLE Recipients fehlschlagen
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
head = s3.head_object(Bucket=bucket, Key=key)
|
|
||||||
metadata = head.get('Metadata', {}) or {}
|
|
||||||
|
|
||||||
metadata['status'] = 'failed'
|
|
||||||
metadata['failed_at'] = str(int(time.time()))
|
|
||||||
metadata['failed_by'] = WORKER_NAME
|
|
||||||
metadata['error'] = error[:500] # S3 Metadata limit
|
|
||||||
metadata['retry_count'] = str(receive_count)
|
|
||||||
metadata.pop('processing_started', None)
|
|
||||||
|
|
||||||
s3.copy_object(
|
|
||||||
Bucket=bucket,
|
|
||||||
Key=key,
|
|
||||||
CopySource={'Bucket': bucket, 'Key': key},
|
|
||||||
Metadata=metadata,
|
|
||||||
MetadataDirective='REPLACE'
|
|
||||||
)
|
|
||||||
|
|
||||||
log(f"✗ Marked s3://{bucket}/{key} as failed: {error[:100]}", 'ERROR')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log(f"Failed to mark as failed: {e}", 'WARNING')
|
|
||||||
|
|
||||||
|
|
||||||
def is_temporary_smtp_error(error_msg: str) -> bool:
|
|
||||||
"""
|
|
||||||
Prüft ob SMTP-Fehler temporär ist (Retry sinnvoll)
|
|
||||||
4xx Codes = temporär, 5xx = permanent
|
|
||||||
"""
|
|
||||||
temporary_indicators = [
|
|
||||||
'421', # Service not available
|
|
||||||
'450', # Mailbox unavailable
|
|
||||||
'451', # Local error
|
|
||||||
'452', # Insufficient storage
|
|
||||||
'4', # Generisch 4xx
|
|
||||||
'timeout',
|
|
||||||
'connection refused',
|
|
||||||
'connection reset',
|
|
||||||
'network unreachable',
|
|
||||||
'temporarily',
|
|
||||||
'try again'
|
|
||||||
]
|
|
||||||
|
|
||||||
error_lower = error_msg.lower()
|
|
||||||
return any(indicator in error_lower for indicator in temporary_indicators)
|
|
||||||
|
|
||||||
|
|
||||||
def is_permanent_recipient_error(error_msg: str) -> bool:
|
|
||||||
"""
|
|
||||||
Prüft ob Fehler permanent für diesen Recipient ist (Inbox existiert nicht)
|
|
||||||
550 = Mailbox not found, 551 = User not local, 553 = Mailbox name invalid
|
|
||||||
"""
|
|
||||||
permanent_indicators = [
|
|
||||||
'550', # Mailbox unavailable / not found
|
|
||||||
'551', # User not local
|
|
||||||
'553', # Mailbox name not allowed / invalid
|
|
||||||
'mailbox not found',
|
|
||||||
'user unknown',
|
|
||||||
'no such user',
|
|
||||||
'recipient rejected',
|
|
||||||
'does not exist',
|
|
||||||
'invalid recipient',
|
|
||||||
'unknown user'
|
|
||||||
]
|
|
||||||
|
|
||||||
error_lower = error_msg.lower()
|
|
||||||
return any(indicator in error_lower for indicator in permanent_indicators)
|
|
||||||
|
|
||||||
|
|
||||||
def send_email(from_addr: str, recipient: str, raw_message: bytes) -> tuple:
|
|
||||||
"""
|
|
||||||
Sendet E-Mail via SMTP an EINEN Empfänger
|
|
||||||
Returns: (success: bool, error: str or None, is_permanent: bool)
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=30) as smtp:
|
|
||||||
smtp.ehlo()
|
|
||||||
|
|
||||||
# STARTTLS falls konfiguriert
|
|
||||||
if SMTP_USE_TLS:
|
|
||||||
try:
|
|
||||||
smtp.starttls()
|
|
||||||
smtp.ehlo()
|
|
||||||
except Exception as e:
|
|
||||||
log(f" STARTTLS failed: {e}", 'WARNING')
|
|
||||||
|
|
||||||
# Authentication falls konfiguriert
|
|
||||||
if SMTP_USER and SMTP_PASS:
|
|
||||||
try:
|
|
||||||
smtp.login(SMTP_USER, SMTP_PASS)
|
|
||||||
except Exception as e:
|
|
||||||
log(f" SMTP auth failed: {e}", 'WARNING')
|
|
||||||
|
|
||||||
# E-Mail senden
|
|
||||||
result = smtp.sendmail(from_addr, [recipient], raw_message)
|
|
||||||
|
|
||||||
# Result auswerten
|
|
||||||
if isinstance(result, dict) and result:
|
|
||||||
# Empfänger wurde abgelehnt
|
|
||||||
error = result.get(recipient, 'Unknown refusal')
|
|
||||||
is_permanent = is_permanent_recipient_error(str(error))
|
|
||||||
log(f" ✗ {recipient}: {error} ({'permanent' if is_permanent else 'temporary'})", 'ERROR')
|
|
||||||
return False, str(error), is_permanent
|
|
||||||
else:
|
|
||||||
# Erfolgreich
|
|
||||||
log(f" ✓ {recipient}: Delivered", 'SUCCESS')
|
|
||||||
return True, None, False
|
|
||||||
|
|
||||||
except smtplib.SMTPException as e:
|
|
||||||
error_msg = str(e)
|
|
||||||
is_permanent = is_permanent_recipient_error(error_msg)
|
|
||||||
log(f" ✗ {recipient}: SMTP error - {error_msg}", 'ERROR')
|
|
||||||
return False, error_msg, is_permanent
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# Connection errors sind immer temporär
|
|
||||||
log(f" ✗ {recipient}: Connection error - {e}", 'ERROR')
|
|
||||||
return False, str(e), False
|
|
||||||
|
|
||||||
|
|
||||||
def process_message(message_body: dict, receive_count: int) -> bool:
|
|
||||||
"""
|
|
||||||
Verarbeitet eine E-Mail aus der Queue
|
|
||||||
Kann mehrere Recipients haben - sendet an alle
|
|
||||||
Returns: True wenn erfolgreich (Message löschen), False bei Fehler (Retry)
|
|
||||||
"""
|
|
||||||
|
|
||||||
bucket = message_body['bucket']
|
|
||||||
key = message_body['key']
|
|
||||||
from_addr = message_body['from']
|
|
||||||
recipients = message_body['recipients'] # Liste von Empfängern
|
|
||||||
domain = message_body['domain']
|
|
||||||
subject = message_body.get('subject', '(unknown)')
|
|
||||||
message_id = message_body.get('message_id', '(unknown)')
|
|
||||||
|
|
||||||
log(f"\n{'='*70}")
|
|
||||||
log(f"Processing email (Attempt #{receive_count}):")
|
|
||||||
log(f" MessageId: {message_id}")
|
|
||||||
log(f" S3 Key: {key}")
|
|
||||||
log(f" Domain: {domain}")
|
|
||||||
log(f" From: {from_addr}")
|
|
||||||
log(f" Recipients: {len(recipients)}")
|
|
||||||
for recipient in recipients:
|
|
||||||
log(f" - {recipient}")
|
|
||||||
log(f" Subject: {subject}")
|
|
||||||
log(f" S3: s3://{bucket}/{key}")
|
|
||||||
log(f"{'='*70}")
|
|
||||||
|
|
||||||
# ✨ VALIDATION: Domain muss mit Worker-Domain übereinstimmen
|
|
||||||
if domain.lower() != WORKER_DOMAIN.lower():
|
|
||||||
log(f"ERROR: Wrong domain! Expected {WORKER_DOMAIN}, got {domain}", 'ERROR')
|
|
||||||
log("This message should not be in this queue! Deleting...", 'ERROR')
|
|
||||||
return True # Message löschen (gehört nicht hierher)
|
|
||||||
|
|
||||||
# E-Mail aus S3 laden
|
|
||||||
try:
|
|
||||||
response = s3.get_object(Bucket=bucket, Key=key)
|
|
||||||
raw_bytes = response['Body'].read()
|
|
||||||
log(f"✓ Loaded {len(raw_bytes):,} bytes ({len(raw_bytes)/1024:.1f} KB)")
|
|
||||||
except s3.exceptions.NoSuchKey:
|
|
||||||
log(f"✗ S3 object not found (may have been deleted)", 'ERROR')
|
|
||||||
return True # Nicht retryable - Message löschen
|
|
||||||
except Exception as e:
|
|
||||||
log(f"✗ Failed to load from S3: {e}", 'ERROR')
|
|
||||||
return False # Könnte temporär sein - retry
|
|
||||||
|
|
||||||
# An alle Recipients senden
|
|
||||||
log(f"\n📤 Sending to {len(recipients)} recipient(s)...")
|
|
||||||
log(f"Connecting to {SMTP_HOST}:{SMTP_PORT} (TLS: {SMTP_USE_TLS})")
|
|
||||||
|
|
||||||
successful = []
|
|
||||||
failed_temporary = []
|
|
||||||
failed_permanent = []
|
|
||||||
|
|
||||||
for recipient in recipients:
|
|
||||||
success, error, is_permanent = send_email(from_addr, recipient, raw_bytes)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
successful.append(recipient)
|
|
||||||
elif is_permanent:
|
|
||||||
failed_permanent.append(recipient)
|
|
||||||
else:
|
|
||||||
failed_temporary.append(recipient)
|
|
||||||
|
|
||||||
# Ergebnis-Zusammenfassung
|
|
||||||
log(f"\n📊 Delivery Results:")
|
|
||||||
log(f" ✓ Successful: {len(successful)}/{len(recipients)}")
|
|
||||||
log(f" ✗ Failed (temporary): {len(failed_temporary)}")
|
|
||||||
log(f" ✗ Failed (permanent): {len(failed_permanent)}")
|
|
||||||
|
|
||||||
# Entscheidungslogik
|
|
||||||
if len(successful) > 0:
|
|
||||||
# ✅ Fall 1: Mindestens 1 Recipient erfolgreich
|
|
||||||
# → status=delivered, invalid_inboxes tracken
|
|
||||||
|
|
||||||
invalid_inboxes = failed_permanent if failed_permanent else None
|
|
||||||
mark_as_processed(bucket, key, invalid_inboxes)
|
|
||||||
|
|
||||||
log(f"{'='*70}")
|
|
||||||
log(f"✅ Email delivered to {len(successful)} recipient(s)", 'SUCCESS')
|
|
||||||
if failed_permanent:
|
|
||||||
log(f"⚠ {len(failed_permanent)} invalid inbox(es): {', '.join(failed_permanent)}", 'WARNING')
|
|
||||||
if failed_temporary:
|
|
||||||
log(f"⚠ {len(failed_temporary)} temporary failure(s) - NOT retrying (at least 1 success)", 'WARNING')
|
|
||||||
log(f"{'='*70}\n")
|
|
||||||
|
|
||||||
return True # Message löschen
|
|
||||||
|
|
||||||
elif len(failed_permanent) == len(recipients):
|
|
||||||
# ❌ Fall 2: ALLE Recipients permanent fehlgeschlagen (alle Inboxen ungültig)
|
|
||||||
# → status=failed, invalid_inboxes = ALLE
|
|
||||||
|
|
||||||
mark_as_all_invalid(bucket, key, failed_permanent)
|
|
||||||
|
|
||||||
log(f"{'='*70}")
|
|
||||||
log(f"✗ All recipients are invalid inboxes - NO delivery", 'ERROR')
|
|
||||||
log(f" Invalid: {', '.join(failed_permanent)}", 'ERROR')
|
|
||||||
log(f"{'='*70}\n")
|
|
||||||
|
|
||||||
return True # Message löschen (nicht retryable)
|
|
||||||
|
|
||||||
else:
|
|
||||||
# ⏳ Fall 3: Nur temporäre Fehler, keine erfolgreichen Deliveries
|
|
||||||
# → Retry wenn noch Versuche übrig
|
|
||||||
|
|
||||||
if receive_count < 3:
|
|
||||||
log(f"⚠ All failures are temporary, will retry", 'WARNING')
|
|
||||||
log(f"{'='*70}\n")
|
|
||||||
return False # Message NICHT löschen → Retry
|
|
||||||
else:
|
|
||||||
# Max retries erreicht → als failed markieren
|
|
||||||
error_summary = f"Failed after {receive_count} attempts. Temporary errors for all recipients."
|
|
||||||
mark_as_failed(bucket, key, error_summary, receive_count)
|
|
||||||
|
|
||||||
log(f"{'='*70}")
|
|
||||||
log(f"✗ Email delivery failed permanently after {receive_count} attempts", 'ERROR')
|
|
||||||
log(f"{'='*70}\n")
|
|
||||||
|
|
||||||
return False # Nach 3 Versuchen → automatisch DLQ
|
|
||||||
|
|
||||||
|
|
||||||
def main_loop():
|
|
||||||
"""Hauptschleife: Pollt SQS Queue und verarbeitet Nachrichten"""
|
|
||||||
|
|
||||||
# Queue URL ermitteln
|
|
||||||
try:
|
|
||||||
queue_url = get_queue_url()
|
|
||||||
except Exception as e:
|
|
||||||
log(f"FATAL: {e}", 'ERROR')
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
log(f"\n{'='*70}")
|
|
||||||
log(f"🚀 Email Worker started")
|
|
||||||
log(f"{'='*70}")
|
|
||||||
log(f" Worker Name: {WORKER_NAME}")
|
|
||||||
log(f" Domain: {WORKER_DOMAIN}")
|
|
||||||
log(f" Queue: {queue_url}")
|
|
||||||
log(f" Region: {AWS_REGION}")
|
|
||||||
log(f" SMTP: {SMTP_HOST}:{SMTP_PORT} (TLS: {SMTP_USE_TLS})")
|
|
||||||
log(f" Poll interval: {POLL_INTERVAL}s")
|
|
||||||
log(f" Max messages per poll: {MAX_MESSAGES}")
|
|
||||||
log(f" Visibility timeout: {VISIBILITY_TIMEOUT}s")
|
|
||||||
log(f"{'='*70}\n")
|
|
||||||
|
|
||||||
consecutive_errors = 0
|
|
||||||
max_consecutive_errors = 10
|
|
||||||
messages_processed = 0
|
|
||||||
last_activity = time.time()
|
|
||||||
|
|
||||||
while not shutdown_requested:
|
|
||||||
try:
|
|
||||||
# Messages aus Queue holen (Long Polling)
|
|
||||||
response = sqs.receive_message(
|
|
||||||
QueueUrl=queue_url,
|
|
||||||
MaxNumberOfMessages=MAX_MESSAGES,
|
|
||||||
WaitTimeSeconds=POLL_INTERVAL,
|
|
||||||
VisibilityTimeout=VISIBILITY_TIMEOUT,
|
|
||||||
AttributeNames=['ApproximateReceiveCount', 'SentTimestamp'],
|
|
||||||
MessageAttributeNames=['All']
|
|
||||||
)
|
|
||||||
|
|
||||||
# Reset error counter bei erfolgreicher Abfrage
|
|
||||||
consecutive_errors = 0
|
|
||||||
|
|
||||||
if 'Messages' not in response:
|
|
||||||
# Keine Nachrichten
|
|
||||||
if time.time() - last_activity > 60:
|
|
||||||
log(f"Waiting for messages... (processed: {messages_processed})")
|
|
||||||
last_activity = time.time()
|
|
||||||
continue
|
|
||||||
|
|
||||||
message_count = len(response['Messages'])
|
|
||||||
log(f"\n✉ Received {message_count} message(s) from queue")
|
|
||||||
last_activity = time.time()
|
|
||||||
|
|
||||||
# Messages verarbeiten
|
|
||||||
for msg in response['Messages']:
|
|
||||||
if shutdown_requested:
|
|
||||||
log("Shutdown requested, stopping processing")
|
|
||||||
break
|
|
||||||
|
|
||||||
receipt_handle = msg['ReceiptHandle']
|
|
||||||
|
|
||||||
# Receive Count auslesen
|
|
||||||
receive_count = int(msg.get('Attributes', {}).get('ApproximateReceiveCount', 1))
|
|
||||||
|
|
||||||
# Sent Timestamp (für Queue-Zeit-Berechnung)
|
|
||||||
sent_timestamp = int(msg.get('Attributes', {}).get('SentTimestamp', 0)) / 1000
|
|
||||||
queue_time = int(time.time() - sent_timestamp) if sent_timestamp else 0
|
|
||||||
|
|
||||||
if queue_time > 0:
|
|
||||||
log(f"Message was in queue for {queue_time}s")
|
|
||||||
|
|
||||||
try:
|
|
||||||
message_body = json.loads(msg['Body'])
|
|
||||||
|
|
||||||
# E-Mail verarbeiten
|
|
||||||
success = process_message(message_body, receive_count)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
# Message aus Queue löschen
|
|
||||||
sqs.delete_message(
|
|
||||||
QueueUrl=queue_url,
|
|
||||||
ReceiptHandle=receipt_handle
|
|
||||||
)
|
|
||||||
log("✓ Message deleted from queue")
|
|
||||||
messages_processed += 1
|
|
||||||
else:
|
|
||||||
# Bei Fehler bleibt Message in Queue
|
|
||||||
log(f"⚠ Message kept in queue for retry (attempt {receive_count}/3)")
|
|
||||||
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
log(f"✗ Invalid message format: {e}", 'ERROR')
|
|
||||||
# Ungültige Messages löschen (nicht retryable)
|
|
||||||
sqs.delete_message(
|
|
||||||
QueueUrl=queue_url,
|
|
||||||
ReceiptHandle=receipt_handle
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log(f"✗ Error processing message: {e}", 'ERROR')
|
|
||||||
traceback.print_exc()
|
|
||||||
# Message bleibt in Queue für Retry
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
log("\n⚠ Keyboard interrupt received")
|
|
||||||
break
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
consecutive_errors += 1
|
|
||||||
log(f"✗ Error in main loop ({consecutive_errors}/{max_consecutive_errors}): {e}", 'ERROR')
|
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
if consecutive_errors >= max_consecutive_errors:
|
|
||||||
log("Too many consecutive errors, shutting down", 'ERROR')
|
|
||||||
break
|
|
||||||
|
|
||||||
# Kurze Pause bei Fehlern
|
|
||||||
time.sleep(5)
|
|
||||||
|
|
||||||
log(f"\n{'='*70}")
|
|
||||||
log(f"👋 Worker shutting down")
|
|
||||||
log(f" Messages processed: {messages_processed}")
|
|
||||||
log(f"{'='*70}\n")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
# Validierung
|
|
||||||
if not WORKER_DOMAIN:
|
|
||||||
log("ERROR: WORKER_DOMAIN not set!", 'ERROR')
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
try:
|
|
||||||
main_loop()
|
|
||||||
except Exception as e:
|
|
||||||
log(f"Fatal error: {e}", 'ERROR')
|
|
||||||
traceback.print_exc()
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
# AWS credentials (or use IAM role / instance profile)
|
||||||
|
AWS_REGION=us-east-2
|
||||||
|
# AWS_ACCESS_KEY_ID=
|
||||||
|
# AWS_SECRET_ACCESS_KEY=
|
||||||
|
|
||||||
|
# Domains: comma-separated list OR file path
|
||||||
|
# DOMAINS=andreasknuth.de,bizmatch.net
|
||||||
|
DOMAINS_FILE=/etc/email-worker/domains.txt
|
||||||
|
|
||||||
|
# SMTP (Docker Mail Server)
|
||||||
|
SMTP_HOST=localhost
|
||||||
|
SMTP_PORT=25
|
||||||
|
SMTP_USE_TLS=false
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASS=
|
||||||
|
SMTP_POOL_SIZE=5
|
||||||
|
|
||||||
|
# Internal SMTP port (bypass transport_maps)
|
||||||
|
INTERNAL_SMTP_PORT=25
|
||||||
|
|
||||||
|
# Worker settings
|
||||||
|
WORKER_THREADS=10
|
||||||
|
POLL_INTERVAL=20
|
||||||
|
MAX_MESSAGES=10
|
||||||
|
VISIBILITY_TIMEOUT=300
|
||||||
|
|
||||||
|
# DynamoDB tables
|
||||||
|
DYNAMODB_RULES_TABLE=email-rules
|
||||||
|
DYNAMODB_MESSAGES_TABLE=ses-outbound-messages
|
||||||
|
DYNAMODB_BLOCKED_TABLE=email-blocked-senders
|
||||||
|
|
||||||
|
# Bounce handling
|
||||||
|
BOUNCE_LOOKUP_RETRIES=3
|
||||||
|
BOUNCE_LOOKUP_DELAY=1.0
|
||||||
|
|
||||||
|
# Monitoring
|
||||||
|
METRICS_PORT=8000
|
||||||
|
HEALTH_PORT=8080
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
# ── Build stage ──────────────────────────────────────────────────
|
||||||
|
FROM node:20-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src/ ./src/
|
||||||
|
|
||||||
|
RUN npx tsc
|
||||||
|
|
||||||
|
# ── Run stage ────────────────────────────────────────────────────
|
||||||
|
FROM node:20-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Only production deps
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
|
|
||||||
|
# Compiled JS from build stage
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
# Config directory (mount domains.txt here)
|
||||||
|
RUN mkdir -p /etc/email-worker /var/log/email-worker
|
||||||
|
|
||||||
|
EXPOSE 8000 8080
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD node -e "fetch('http://localhost:8080').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
|
||||||
|
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
/**
|
||||||
|
* Sender blocklist checking with wildcard / glob support
|
||||||
|
*
|
||||||
|
* Uses picomatch for pattern matching (equivalent to Python's fnmatch).
|
||||||
|
* Patterns are stored per-recipient in DynamoDB.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import picomatch from 'picomatch';
|
||||||
|
import type { DynamoDBHandler } from '../aws/dynamodb.js';
|
||||||
|
import { log } from '../logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the bare email address from a From header value.
|
||||||
|
* "John Doe <john@example.com>" → "john@example.com"
|
||||||
|
*/
|
||||||
|
function extractAddress(sender: string): string {
|
||||||
|
const match = sender.match(/<([^>]+)>/);
|
||||||
|
const addr = match ? match[1] : sender;
|
||||||
|
return addr.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BlocklistChecker {
|
||||||
|
constructor(private dynamodb: DynamoDBHandler) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch-check whether a sender is blocked for each recipient.
|
||||||
|
* Uses a single batch DynamoDB call for efficiency.
|
||||||
|
*/
|
||||||
|
async batchCheckBlockedSenders(
|
||||||
|
recipients: string[],
|
||||||
|
sender: string,
|
||||||
|
workerName: string,
|
||||||
|
): Promise<Record<string, boolean>> {
|
||||||
|
const patternsByRecipient =
|
||||||
|
await this.dynamodb.batchGetBlockedPatterns(recipients);
|
||||||
|
|
||||||
|
const senderClean = extractAddress(sender);
|
||||||
|
const result: Record<string, boolean> = {};
|
||||||
|
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
const patterns = patternsByRecipient[recipient] ?? [];
|
||||||
|
let isBlocked = false;
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
if (picomatch.isMatch(senderClean, pattern.toLowerCase())) {
|
||||||
|
log(
|
||||||
|
`⛔ BLOCKED: Sender ${senderClean} matches pattern '${pattern}' ` +
|
||||||
|
`for inbox ${recipient}`,
|
||||||
|
'WARNING',
|
||||||
|
workerName,
|
||||||
|
);
|
||||||
|
isBlocked = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result[recipient] = isBlocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
/**
|
||||||
|
* Bounce detection and header rewriting
|
||||||
|
*
|
||||||
|
* When Amazon SES returns a bounce, the From header is
|
||||||
|
* mailer-daemon@amazonses.com. We look up the original sender
|
||||||
|
* in DynamoDB and rewrite the headers so the bounce appears
|
||||||
|
* to come from the actual bounced recipient.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ParsedMail } from 'mailparser';
|
||||||
|
import type { DynamoDBHandler } from '../aws/dynamodb.js';
|
||||||
|
import { isSesBounceNotification, getHeader } from './parser.js';
|
||||||
|
import { log } from '../logger.js';
|
||||||
|
|
||||||
|
export interface BounceResult {
|
||||||
|
/** Updated raw bytes (headers rewritten if bounce was detected) */
|
||||||
|
rawBytes: Buffer;
|
||||||
|
/** Whether bounce was detected and headers were modified */
|
||||||
|
modified: boolean;
|
||||||
|
/** Whether this email is a bounce notification at all */
|
||||||
|
isBounce: boolean;
|
||||||
|
/** The effective From address (rewritten or original) */
|
||||||
|
fromAddr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BounceHandler {
|
||||||
|
constructor(private dynamodb: DynamoDBHandler) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect SES bounce, look up original sender in DynamoDB,
|
||||||
|
* and rewrite headers in the raw buffer.
|
||||||
|
*
|
||||||
|
* We operate on the raw Buffer because we need to preserve
|
||||||
|
* the original MIME structure exactly, only swapping specific
|
||||||
|
* header lines. mailparser's ParsedMail is read-only.
|
||||||
|
*/
|
||||||
|
async applyBounceLogic(
|
||||||
|
parsed: ParsedMail,
|
||||||
|
rawBytes: Buffer,
|
||||||
|
subject: string,
|
||||||
|
workerName = 'unified',
|
||||||
|
): Promise<BounceResult> {
|
||||||
|
if (!isSesBounceNotification(parsed)) {
|
||||||
|
return {
|
||||||
|
rawBytes,
|
||||||
|
modified: false,
|
||||||
|
isBounce: false,
|
||||||
|
fromAddr: parsed.from?.text ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
log('🔍 Detected SES MAILER-DAEMON bounce notification', 'INFO', workerName);
|
||||||
|
|
||||||
|
// Extract Message-ID from the bounce notification header
|
||||||
|
const rawMessageId = getHeader(parsed, 'message-id')
|
||||||
|
.replace(/^</, '')
|
||||||
|
.replace(/>$/, '')
|
||||||
|
.split('@')[0];
|
||||||
|
|
||||||
|
if (!rawMessageId) {
|
||||||
|
log('⚠ Could not extract Message-ID from bounce notification', 'WARNING', workerName);
|
||||||
|
return {
|
||||||
|
rawBytes,
|
||||||
|
modified: false,
|
||||||
|
isBounce: true,
|
||||||
|
fromAddr: parsed.from?.text ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
log(` Looking up Message-ID: ${rawMessageId}`, 'INFO', workerName);
|
||||||
|
|
||||||
|
const bounceInfo = await this.dynamodb.getBounceInfo(rawMessageId, workerName);
|
||||||
|
if (!bounceInfo) {
|
||||||
|
return {
|
||||||
|
rawBytes,
|
||||||
|
modified: false,
|
||||||
|
isBounce: true,
|
||||||
|
fromAddr: parsed.from?.text ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log bounce details
|
||||||
|
log(`✓ Found bounce info:`, 'INFO', workerName);
|
||||||
|
log(` Original sender: ${bounceInfo.original_source}`, 'INFO', workerName);
|
||||||
|
log(` Bounce type: ${bounceInfo.bounceType}/${bounceInfo.bounceSubType}`, 'INFO', workerName);
|
||||||
|
log(` Bounced recipients: ${bounceInfo.bouncedRecipients}`, 'INFO', workerName);
|
||||||
|
|
||||||
|
if (!bounceInfo.bouncedRecipients.length) {
|
||||||
|
log('⚠ No bounced recipients found in bounce info', 'WARNING', workerName);
|
||||||
|
return {
|
||||||
|
rawBytes,
|
||||||
|
modified: false,
|
||||||
|
isBounce: true,
|
||||||
|
fromAddr: parsed.from?.text ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFrom = bounceInfo.bouncedRecipients[0];
|
||||||
|
|
||||||
|
// Rewrite headers in raw bytes
|
||||||
|
let modifiedBytes = rawBytes;
|
||||||
|
const originalFrom = getHeader(parsed, 'from');
|
||||||
|
|
||||||
|
// Replace From header
|
||||||
|
modifiedBytes = replaceHeader(modifiedBytes, 'From', newFrom);
|
||||||
|
|
||||||
|
// Add diagnostic headers
|
||||||
|
modifiedBytes = addHeader(modifiedBytes, 'X-Original-SES-From', originalFrom);
|
||||||
|
modifiedBytes = addHeader(
|
||||||
|
modifiedBytes,
|
||||||
|
'X-Bounce-Type',
|
||||||
|
`${bounceInfo.bounceType}/${bounceInfo.bounceSubType}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add Reply-To if not present
|
||||||
|
if (!getHeader(parsed, 'reply-to')) {
|
||||||
|
modifiedBytes = addHeader(modifiedBytes, 'Reply-To', newFrom);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust subject for generic delivery status notifications
|
||||||
|
const subjectLower = subject.toLowerCase();
|
||||||
|
if (
|
||||||
|
subjectLower.includes('delivery status notification') ||
|
||||||
|
subjectLower.includes('thanks for your submission')
|
||||||
|
) {
|
||||||
|
modifiedBytes = replaceHeader(
|
||||||
|
modifiedBytes,
|
||||||
|
'Subject',
|
||||||
|
`Delivery Status: ${newFrom}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`✓ Rewritten FROM: ${newFrom}`, 'SUCCESS', workerName);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rawBytes: modifiedBytes,
|
||||||
|
modified: true,
|
||||||
|
isBounce: true,
|
||||||
|
fromAddr: newFrom,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Raw header manipulation helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace a header value in raw MIME bytes.
|
||||||
|
* Handles multi-line (folded) headers.
|
||||||
|
*/
|
||||||
|
function replaceHeader(raw: Buffer, name: string, newValue: string): Buffer {
|
||||||
|
const str = raw.toString('utf-8');
|
||||||
|
// Match header including potential folded continuation lines
|
||||||
|
const regex = new RegExp(
|
||||||
|
`^(${escapeRegex(name)}:\\s*).*?(\\r?\\n(?=[^ \\t])|\\r?\\n$)`,
|
||||||
|
'im',
|
||||||
|
);
|
||||||
|
// Also need to consume folded lines
|
||||||
|
const foldedRegex = new RegExp(
|
||||||
|
`^${escapeRegex(name)}:[ \\t]*[^\\r\\n]*(?:\\r?\\n[ \\t]+[^\\r\\n]*)*`,
|
||||||
|
'im',
|
||||||
|
);
|
||||||
|
|
||||||
|
const match = foldedRegex.exec(str);
|
||||||
|
if (!match) return raw;
|
||||||
|
|
||||||
|
const before = str.slice(0, match.index);
|
||||||
|
const after = str.slice(match.index + match[0].length);
|
||||||
|
const replaced = `${before}${name}: ${newValue}${after}`;
|
||||||
|
return Buffer.from(replaced, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new header line right before the header/body separator.
|
||||||
|
*/
|
||||||
|
function addHeader(raw: Buffer, name: string, value: string): Buffer {
|
||||||
|
const str = raw.toString('utf-8');
|
||||||
|
// Find the header/body boundary (first blank line)
|
||||||
|
const sep = str.match(/\r?\n\r?\n/);
|
||||||
|
if (!sep || sep.index === undefined) return raw;
|
||||||
|
|
||||||
|
const before = str.slice(0, sep.index);
|
||||||
|
const after = str.slice(sep.index);
|
||||||
|
return Buffer.from(`${before}\r\n${name}: ${value}${after}`, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegex(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
/**
|
||||||
|
* Configuration management for unified email worker
|
||||||
|
*
|
||||||
|
* All settings are read from environment variables with sensible defaults.
|
||||||
|
* Domain helpers (bucket name, queue name, internal check) are co-located here
|
||||||
|
* so every module can import { config, domainToBucket, ... } from './config'.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, existsSync } from 'node:fs';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Config object
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const config = {
|
||||||
|
// AWS
|
||||||
|
awsRegion: process.env.AWS_REGION ?? 'us-east-2',
|
||||||
|
|
||||||
|
// Domains
|
||||||
|
domainsList: process.env.DOMAINS ?? '',
|
||||||
|
domainsFile: process.env.DOMAINS_FILE ?? '/etc/email-worker/domains.txt',
|
||||||
|
|
||||||
|
// Worker
|
||||||
|
workerThreads: parseInt(process.env.WORKER_THREADS ?? '10', 10),
|
||||||
|
pollInterval: parseInt(process.env.POLL_INTERVAL ?? '20', 10),
|
||||||
|
maxMessages: parseInt(process.env.MAX_MESSAGES ?? '10', 10),
|
||||||
|
visibilityTimeout: parseInt(process.env.VISIBILITY_TIMEOUT ?? '300', 10),
|
||||||
|
|
||||||
|
// SMTP delivery (local DMS)
|
||||||
|
smtpHost: process.env.SMTP_HOST ?? 'localhost',
|
||||||
|
smtpPort: parseInt(process.env.SMTP_PORT ?? '25', 10),
|
||||||
|
smtpUseTls: (process.env.SMTP_USE_TLS ?? 'false').toLowerCase() === 'true',
|
||||||
|
smtpUser: process.env.SMTP_USER ?? '',
|
||||||
|
smtpPass: process.env.SMTP_PASS ?? '',
|
||||||
|
smtpPoolSize: parseInt(process.env.SMTP_POOL_SIZE ?? '5', 10),
|
||||||
|
|
||||||
|
// Internal SMTP port (for OOO / forwards to managed domains)
|
||||||
|
internalSmtpPort: parseInt(process.env.INTERNAL_SMTP_PORT ?? '25', 10),
|
||||||
|
|
||||||
|
// DynamoDB tables
|
||||||
|
rulesTable: process.env.DYNAMODB_RULES_TABLE ?? 'email-rules',
|
||||||
|
messagesTable: process.env.DYNAMODB_MESSAGES_TABLE ?? 'ses-outbound-messages',
|
||||||
|
blockedTable: process.env.DYNAMODB_BLOCKED_TABLE ?? 'email-blocked-senders',
|
||||||
|
|
||||||
|
// Bounce handling
|
||||||
|
bounceLookupRetries: parseInt(process.env.BOUNCE_LOOKUP_RETRIES ?? '3', 10),
|
||||||
|
bounceLookupDelay: parseFloat(process.env.BOUNCE_LOOKUP_DELAY ?? '1.0'),
|
||||||
|
|
||||||
|
// Monitoring
|
||||||
|
metricsPort: parseInt(process.env.METRICS_PORT ?? '8000', 10),
|
||||||
|
healthPort: parseInt(process.env.HEALTH_PORT ?? '8080', 10),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Config = typeof config;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Managed domains (populated by loadDomains())
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const managedDomains = new Set<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load domains from env var and/or file, populate the internal set.
|
||||||
|
*/
|
||||||
|
export function loadDomains(): string[] {
|
||||||
|
const domains: string[] = [];
|
||||||
|
|
||||||
|
// From env
|
||||||
|
if (config.domainsList) {
|
||||||
|
for (const d of config.domainsList.split(',')) {
|
||||||
|
const trimmed = d.trim();
|
||||||
|
if (trimmed) domains.push(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// From file
|
||||||
|
if (existsSync(config.domainsFile)) {
|
||||||
|
const content = readFileSync(config.domainsFile, 'utf-8');
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed && !trimmed.startsWith('#')) {
|
||||||
|
domains.push(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate
|
||||||
|
const unique = [...new Set(domains)];
|
||||||
|
|
||||||
|
managedDomains.clear();
|
||||||
|
for (const d of unique) {
|
||||||
|
managedDomains.add(d.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Domain helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Check whether an email address belongs to one of our managed domains */
|
||||||
|
export function isInternalAddress(email: string): boolean {
|
||||||
|
const atIdx = email.indexOf('@');
|
||||||
|
if (atIdx < 0) return false;
|
||||||
|
return managedDomains.has(email.slice(atIdx + 1).toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert domain to SQS queue name: bizmatch.net → bizmatch-net-queue */
|
||||||
|
export function domainToQueueName(domain: string): string {
|
||||||
|
return domain.replace(/\./g, '-') + '-queue';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert domain to S3 bucket name: bizmatch.net → bizmatch-net-emails */
|
||||||
|
export function domainToBucketName(domain: string): string {
|
||||||
|
return domain.replace(/\./g, '-') + '-emails';
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
/**
|
||||||
|
* SMTP / email delivery with nodemailer pooled transport
|
||||||
|
*
|
||||||
|
* Replaces both Python's SMTPPool and EmailDelivery classes.
|
||||||
|
* nodemailer handles connection pooling, keepalive, and reconnection natively.
|
||||||
|
*
|
||||||
|
* Removed: LMTP delivery path (never used in production).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createTransport, type Transporter } from 'nodemailer';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
import { log } from '../logger.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Permanent error detection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const PERMANENT_INDICATORS = [
|
||||||
|
'550', '551', '553',
|
||||||
|
'mailbox not found', 'user unknown', 'no such user',
|
||||||
|
'recipient rejected', 'does not exist', 'invalid recipient',
|
||||||
|
'unknown user',
|
||||||
|
];
|
||||||
|
|
||||||
|
function isPermanentRecipientError(errorMsg: string): boolean {
|
||||||
|
const lower = errorMsg.toLowerCase();
|
||||||
|
return PERMANENT_INDICATORS.some((ind) => lower.includes(ind));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Delivery class
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export class EmailDelivery {
|
||||||
|
private transport: Transporter;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.transport = createTransport({
|
||||||
|
host: config.smtpHost,
|
||||||
|
port: config.smtpPort,
|
||||||
|
secure: config.smtpUseTls,
|
||||||
|
pool: true,
|
||||||
|
maxConnections: config.smtpPoolSize,
|
||||||
|
maxMessages: Infinity, // reuse connections indefinitely
|
||||||
|
tls: { rejectUnauthorized: false },
|
||||||
|
...(config.smtpUser && config.smtpPass
|
||||||
|
? { auth: { user: config.smtpUser, pass: config.smtpPass } }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
log(
|
||||||
|
`📡 SMTP pool initialized → ${config.smtpHost}:${config.smtpPort} ` +
|
||||||
|
`(max ${config.smtpPoolSize} connections)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send raw email to ONE recipient via the local DMS.
|
||||||
|
*
|
||||||
|
* Returns: [success, errorMessage?, isPermanent]
|
||||||
|
*/
|
||||||
|
async sendToRecipient(
|
||||||
|
fromAddr: string,
|
||||||
|
recipient: string,
|
||||||
|
rawMessage: Buffer,
|
||||||
|
workerName: string,
|
||||||
|
maxRetries = 2,
|
||||||
|
): Promise<[boolean, string | null, boolean]> {
|
||||||
|
let lastError: string | null = null;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
await this.transport.sendMail({
|
||||||
|
envelope: { from: fromAddr, to: [recipient] },
|
||||||
|
raw: rawMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
log(` ✓ ${recipient}: Delivered (SMTP)`, 'SUCCESS', workerName);
|
||||||
|
return [true, null, false];
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMsg = err.message ?? String(err);
|
||||||
|
const responseCode = err.responseCode ?? 0;
|
||||||
|
|
||||||
|
// Check for permanent errors (5xx)
|
||||||
|
if (
|
||||||
|
responseCode >= 550 ||
|
||||||
|
isPermanentRecipientError(errorMsg)
|
||||||
|
) {
|
||||||
|
log(
|
||||||
|
` ✗ ${recipient}: ${errorMsg} (permanent)`,
|
||||||
|
'ERROR',
|
||||||
|
workerName,
|
||||||
|
);
|
||||||
|
return [false, errorMsg, true];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection-level errors → retry
|
||||||
|
if (
|
||||||
|
err.code === 'ECONNRESET' ||
|
||||||
|
err.code === 'ECONNREFUSED' ||
|
||||||
|
err.code === 'ETIMEDOUT' ||
|
||||||
|
errorMsg.toLowerCase().includes('disconnect') ||
|
||||||
|
errorMsg.toLowerCase().includes('closed') ||
|
||||||
|
errorMsg.toLowerCase().includes('connection')
|
||||||
|
) {
|
||||||
|
log(
|
||||||
|
` ⚠ ${recipient}: Connection error, retrying... ` +
|
||||||
|
`(attempt ${attempt + 1}/${maxRetries + 1})`,
|
||||||
|
'WARNING',
|
||||||
|
workerName,
|
||||||
|
);
|
||||||
|
lastError = errorMsg;
|
||||||
|
await sleep(300);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other SMTP errors
|
||||||
|
const isPerm = isPermanentRecipientError(errorMsg);
|
||||||
|
log(
|
||||||
|
` ✗ ${recipient}: ${errorMsg} (${isPerm ? 'permanent' : 'temporary'})`,
|
||||||
|
'ERROR',
|
||||||
|
workerName,
|
||||||
|
);
|
||||||
|
return [false, errorMsg, isPerm];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All retries exhausted
|
||||||
|
log(
|
||||||
|
` ✗ ${recipient}: All retries failed - ${lastError}`,
|
||||||
|
'ERROR',
|
||||||
|
workerName,
|
||||||
|
);
|
||||||
|
return [false, lastError ?? 'Connection failed after retries', false];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify the transport is reachable (used during startup). */
|
||||||
|
async verify(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.transport.verify();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close all pooled connections. */
|
||||||
|
close(): void {
|
||||||
|
this.transport.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
email-worker:
|
||||||
|
build: .
|
||||||
|
container_name: email-worker-ts
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- ./domains.txt:/etc/email-worker/domains.txt:ro
|
||||||
|
- worker-logs:/var/log/email-worker
|
||||||
|
ports:
|
||||||
|
- "8000:8000" # Prometheus metrics
|
||||||
|
- "8080:8080" # Health check
|
||||||
|
# Connect to DMS on the host or Docker network
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
environment:
|
||||||
|
- SMTP_HOST=host.docker.internal
|
||||||
|
- SMTP_PORT=25
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
worker-logs:
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
/**
|
||||||
|
* Domain queue poller
|
||||||
|
*
|
||||||
|
* One poller per domain. Runs an async loop that long-polls SQS
|
||||||
|
* and dispatches messages to the MessageProcessor.
|
||||||
|
*
|
||||||
|
* Replaces Python's threading.Thread + threading.Event with
|
||||||
|
* a simple async loop + AbortController for cancellation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SQSHandler } from '../aws/sqs.js';
|
||||||
|
import type { MessageProcessor } from './message-processor.js';
|
||||||
|
import type { MetricsCollector } from '../metrics.js';
|
||||||
|
import { log } from '../logger.js';
|
||||||
|
|
||||||
|
export interface DomainPollerStats {
|
||||||
|
domain: string;
|
||||||
|
processed: number;
|
||||||
|
errors: number;
|
||||||
|
lastActivity: Date | null;
|
||||||
|
running: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DomainPoller {
|
||||||
|
public stats: DomainPollerStats;
|
||||||
|
private abort: AbortController;
|
||||||
|
private loopPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private domain: string,
|
||||||
|
private queueUrl: string,
|
||||||
|
private sqs: SQSHandler,
|
||||||
|
private processor: MessageProcessor,
|
||||||
|
private metrics: MetricsCollector | null,
|
||||||
|
) {
|
||||||
|
this.abort = new AbortController();
|
||||||
|
this.stats = {
|
||||||
|
domain,
|
||||||
|
processed: 0,
|
||||||
|
errors: 0,
|
||||||
|
lastActivity: null,
|
||||||
|
running: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start the polling loop. Returns immediately. */
|
||||||
|
start(): void {
|
||||||
|
if (this.stats.running) return;
|
||||||
|
this.stats.running = true;
|
||||||
|
log(`▶ Started poller for ${this.domain}`, 'INFO', `poller-${this.domain}`);
|
||||||
|
this.loopPromise = this.pollLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signal the poller to stop and wait for it to finish. */
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (!this.stats.running) return;
|
||||||
|
this.abort.abort();
|
||||||
|
if (this.loopPromise) {
|
||||||
|
await this.loopPromise;
|
||||||
|
}
|
||||||
|
this.stats.running = false;
|
||||||
|
log(`⏹ Stopped poller for ${this.domain}`, 'INFO', `poller-${this.domain}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Poll loop
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
private async pollLoop(): Promise<void> {
|
||||||
|
const workerName = `poller-${this.domain}`;
|
||||||
|
|
||||||
|
while (!this.abort.signal.aborted) {
|
||||||
|
try {
|
||||||
|
// Report queue size
|
||||||
|
const qSize = await this.sqs.getQueueSize(this.queueUrl);
|
||||||
|
this.metrics?.setQueueSize(this.domain, qSize);
|
||||||
|
|
||||||
|
if (qSize > 0) {
|
||||||
|
log(`📊 Queue ${this.domain}: ~${qSize} messages`, 'INFO', workerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Long-poll
|
||||||
|
const messages = await this.sqs.receiveMessages(this.queueUrl);
|
||||||
|
|
||||||
|
if (this.abort.signal.aborted) break;
|
||||||
|
|
||||||
|
if (messages.length === 0) continue;
|
||||||
|
|
||||||
|
log(
|
||||||
|
`📬 Received ${messages.length} message(s) for ${this.domain}`,
|
||||||
|
'INFO',
|
||||||
|
workerName,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Process each message
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (this.abort.signal.aborted) break;
|
||||||
|
|
||||||
|
const receiveCount = parseInt(
|
||||||
|
msg.Attributes?.ApproximateReceiveCount ?? '1',
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.metrics?.incrementInFlight();
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const shouldDelete = await this.processor.processMessage(
|
||||||
|
this.domain,
|
||||||
|
msg,
|
||||||
|
receiveCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldDelete && msg.ReceiptHandle) {
|
||||||
|
await this.sqs.deleteMessage(this.queueUrl, msg.ReceiptHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stats.processed++;
|
||||||
|
this.stats.lastActivity = new Date();
|
||||||
|
|
||||||
|
const elapsed = ((Date.now() - start) / 1000).toFixed(2);
|
||||||
|
this.metrics?.observeProcessingTime(this.domain, parseFloat(elapsed));
|
||||||
|
} catch (err: any) {
|
||||||
|
this.stats.errors++;
|
||||||
|
log(
|
||||||
|
`❌ Error processing message: ${err.message ?? err}`,
|
||||||
|
'ERROR',
|
||||||
|
workerName,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.metrics?.decrementInFlight();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (this.abort.signal.aborted) break;
|
||||||
|
this.stats.errors++;
|
||||||
|
log(
|
||||||
|
`❌ Polling error for ${this.domain}: ${err.message ?? err}`,
|
||||||
|
'ERROR',
|
||||||
|
workerName,
|
||||||
|
);
|
||||||
|
// Back off on repeated errors
|
||||||
|
await sleep(5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,230 @@
|
||||||
|
/**
|
||||||
|
* DynamoDB operations handler
|
||||||
|
*
|
||||||
|
* Tables:
|
||||||
|
* - email-rules → OOO / Forward rules per address
|
||||||
|
* - ses-outbound-messages → Bounce info (MessageId → original sender)
|
||||||
|
* - email-blocked-senders → Blocked patterns per address
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
||||||
|
import {
|
||||||
|
DynamoDBDocumentClient,
|
||||||
|
GetCommand,
|
||||||
|
BatchGetCommand,
|
||||||
|
} from '@aws-sdk/lib-dynamodb';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
import { log } from '../logger.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export interface EmailRule {
|
||||||
|
email_address: string;
|
||||||
|
ooo_active?: boolean;
|
||||||
|
ooo_message?: string;
|
||||||
|
ooo_content_type?: string;
|
||||||
|
forwards?: string[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BounceInfo {
|
||||||
|
original_source: string;
|
||||||
|
bounceType: string;
|
||||||
|
bounceSubType: string;
|
||||||
|
bouncedRecipients: string[];
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Handler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export class DynamoDBHandler {
|
||||||
|
private docClient: DynamoDBDocumentClient;
|
||||||
|
public available = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const raw = new DynamoDBClient({ region: config.awsRegion });
|
||||||
|
this.docClient = DynamoDBDocumentClient.from(raw, {
|
||||||
|
marshallOptions: { removeUndefinedValues: true },
|
||||||
|
});
|
||||||
|
this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Init
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
private initialize(): void {
|
||||||
|
// We just mark as available; actual connectivity is tested on first call.
|
||||||
|
// The Python version tested table_status, but that's a DescribeTable call
|
||||||
|
// which is heavy and not needed – the first GetItem will tell us.
|
||||||
|
this.available = true;
|
||||||
|
log('✓ DynamoDB client initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify tables exist by doing a cheap GetItem on each.
|
||||||
|
* Called once during startup.
|
||||||
|
*/
|
||||||
|
async verifyTables(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
this.docClient.send(
|
||||||
|
new GetCommand({ TableName: config.rulesTable, Key: { email_address: '__probe__' } }),
|
||||||
|
),
|
||||||
|
this.docClient.send(
|
||||||
|
new GetCommand({ TableName: config.messagesTable, Key: { MessageId: '__probe__' } }),
|
||||||
|
),
|
||||||
|
this.docClient.send(
|
||||||
|
new GetCommand({ TableName: config.blockedTable, Key: { email_address: '__probe__' } }),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
this.available = true;
|
||||||
|
log('✓ DynamoDB tables connected successfully');
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
log(`⚠ DynamoDB not fully available: ${err.message ?? err}`, 'WARNING');
|
||||||
|
this.available = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Email rules
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
async getEmailRules(emailAddress: string): Promise<EmailRule | null> {
|
||||||
|
if (!this.available) return null;
|
||||||
|
try {
|
||||||
|
const resp = await this.docClient.send(
|
||||||
|
new GetCommand({
|
||||||
|
TableName: config.rulesTable,
|
||||||
|
Key: { email_address: emailAddress },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return (resp.Item as EmailRule) ?? null;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.name !== 'ResourceNotFoundException') {
|
||||||
|
log(`⚠ DynamoDB error for ${emailAddress}: ${err.message ?? err}`, 'ERROR');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Bounce info
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
async getBounceInfo(
|
||||||
|
messageId: string,
|
||||||
|
workerName = 'unified',
|
||||||
|
): Promise<BounceInfo | null> {
|
||||||
|
if (!this.available) return null;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < config.bounceLookupRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
const resp = await this.docClient.send(
|
||||||
|
new GetCommand({
|
||||||
|
TableName: config.messagesTable,
|
||||||
|
Key: { MessageId: messageId },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (resp.Item) {
|
||||||
|
return {
|
||||||
|
original_source: (resp.Item.original_source as string) ?? '',
|
||||||
|
bounceType: (resp.Item.bounceType as string) ?? 'Unknown',
|
||||||
|
bounceSubType: (resp.Item.bounceSubType as string) ?? 'Unknown',
|
||||||
|
bouncedRecipients: (resp.Item.bouncedRecipients as string[]) ?? [],
|
||||||
|
timestamp: (resp.Item.timestamp as string) ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < config.bounceLookupRetries - 1) {
|
||||||
|
log(
|
||||||
|
` Bounce record not found yet, retrying in ${config.bounceLookupDelay}s ` +
|
||||||
|
`(attempt ${attempt + 1}/${config.bounceLookupRetries})...`,
|
||||||
|
'INFO',
|
||||||
|
workerName,
|
||||||
|
);
|
||||||
|
await sleep(config.bounceLookupDelay * 1000);
|
||||||
|
} else {
|
||||||
|
log(
|
||||||
|
`⚠ No bounce record found after ${config.bounceLookupRetries} attempts ` +
|
||||||
|
`for Message-ID: ${messageId}`,
|
||||||
|
'WARNING',
|
||||||
|
workerName,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
log(
|
||||||
|
`⚠ DynamoDB Error (attempt ${attempt + 1}/${config.bounceLookupRetries}): ` +
|
||||||
|
`${err.message ?? err}`,
|
||||||
|
'ERROR',
|
||||||
|
workerName,
|
||||||
|
);
|
||||||
|
if (attempt < config.bounceLookupRetries - 1) {
|
||||||
|
await sleep(config.bounceLookupDelay * 1000);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Blocked senders
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
async getBlockedPatterns(emailAddress: string): Promise<string[]> {
|
||||||
|
if (!this.available) return [];
|
||||||
|
try {
|
||||||
|
const resp = await this.docClient.send(
|
||||||
|
new GetCommand({
|
||||||
|
TableName: config.blockedTable,
|
||||||
|
Key: { email_address: emailAddress },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return (resp.Item?.blocked_patterns as string[]) ?? [];
|
||||||
|
} catch (err: any) {
|
||||||
|
log(`⚠ Error getting block list for ${emailAddress}: ${err.message ?? err}`, 'ERROR');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchGetBlockedPatterns(
|
||||||
|
emailAddresses: string[],
|
||||||
|
): Promise<Record<string, string[]>> {
|
||||||
|
const empty: Record<string, string[]> = {};
|
||||||
|
for (const a of emailAddresses) empty[a] = [];
|
||||||
|
if (!this.available || emailAddresses.length === 0) return empty;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const keys = emailAddresses.map((a) => ({ email_address: a }));
|
||||||
|
const resp = await this.docClient.send(
|
||||||
|
new BatchGetCommand({
|
||||||
|
RequestItems: {
|
||||||
|
[config.blockedTable]: { Keys: keys },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = resp.Responses?.[config.blockedTable] ?? [];
|
||||||
|
const result: Record<string, string[]> = { ...empty };
|
||||||
|
for (const item of items) {
|
||||||
|
const addr = item.email_address as string;
|
||||||
|
result[addr] = (item.blocked_patterns as string[]) ?? [];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (err: any) {
|
||||||
|
log(`⚠ Batch blocklist check error: ${err.message ?? err}`, 'ERROR');
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
/**
|
||||||
|
* Health check HTTP server
|
||||||
|
*
|
||||||
|
* Provides a simple /health endpoint for Docker healthcheck
|
||||||
|
* and monitoring. Returns domain list and feature flags.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createServer, type Server } from 'node:http';
|
||||||
|
import { log } from './logger.js';
|
||||||
|
|
||||||
|
export function startHealthServer(
|
||||||
|
port: number,
|
||||||
|
domains: string[],
|
||||||
|
getStats?: () => any,
|
||||||
|
): Server {
|
||||||
|
const server = createServer((_req, res) => {
|
||||||
|
const stats = getStats?.() ?? {};
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
status: 'healthy',
|
||||||
|
worker: 'unified-email-worker-ts',
|
||||||
|
version: '2.0.0',
|
||||||
|
domains,
|
||||||
|
domainCount: domains.length,
|
||||||
|
features: {
|
||||||
|
bounce_handling: true,
|
||||||
|
ooo_replies: true,
|
||||||
|
forwarding: true,
|
||||||
|
blocklist: true,
|
||||||
|
prometheus_metrics: true,
|
||||||
|
lmtp: false,
|
||||||
|
legacy_smtp_forward: false,
|
||||||
|
},
|
||||||
|
stats,
|
||||||
|
uptime: process.uptime(),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(payload, null, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, () => {
|
||||||
|
log(`Health check on port ${port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
/**
|
||||||
|
* Structured logging for email worker with daily rotation
|
||||||
|
*
|
||||||
|
* Uses pino for high-performance JSON logging.
|
||||||
|
* Console output is human-readable via pino-pretty in dev,
|
||||||
|
* and JSON in production (for Docker json-file driver).
|
||||||
|
*
|
||||||
|
* File logging uses a simple daily rotation approach.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import pino from 'pino';
|
||||||
|
import { existsSync, mkdirSync, createWriteStream, type WriteStream } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Configuration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const LOG_DIR = '/var/log/email-worker';
|
||||||
|
const LOG_FILE_PREFIX = 'worker';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// File stream (best-effort, never crashes the worker)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
let fileStream: WriteStream | null = null;
|
||||||
|
let currentDateStr = '';
|
||||||
|
|
||||||
|
function getDateStr(): string {
|
||||||
|
return new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureFileStream(): WriteStream | null {
|
||||||
|
const today = getDateStr();
|
||||||
|
if (fileStream && currentDateStr === today) return fileStream;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true });
|
||||||
|
const filePath = join(LOG_DIR, `${LOG_FILE_PREFIX}.${today}.log`);
|
||||||
|
fileStream = createWriteStream(filePath, { flags: 'a' });
|
||||||
|
currentDateStr = today;
|
||||||
|
return fileStream;
|
||||||
|
} catch {
|
||||||
|
// Silently continue without file logging (e.g. permission issue)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pino logger
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const logger = pino({
|
||||||
|
level: 'info',
|
||||||
|
formatters: {
|
||||||
|
level(label) {
|
||||||
|
return { level: label };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timestamp: pino.stdTimeFunctions.isoTime,
|
||||||
|
// In production Docker we write plain JSON to stdout;
|
||||||
|
// pino-pretty can be used during dev via `pino-pretty` pipe.
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Log level mapping (matches Python worker levels)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
type LogLevel = 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL' | 'SUCCESS';
|
||||||
|
|
||||||
|
const LEVEL_MAP: Record<LogLevel, keyof pino.Logger> = {
|
||||||
|
DEBUG: 'debug',
|
||||||
|
INFO: 'info',
|
||||||
|
WARNING: 'warn',
|
||||||
|
ERROR: 'error',
|
||||||
|
CRITICAL: 'fatal',
|
||||||
|
SUCCESS: 'info',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API – mirrors Python's log(message, level, worker_name)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function log(
|
||||||
|
message: string,
|
||||||
|
level: LogLevel = 'INFO',
|
||||||
|
workerName = 'unified-worker',
|
||||||
|
): void {
|
||||||
|
const prefix = level === 'SUCCESS' ? '[SUCCESS] ' : '';
|
||||||
|
const formatted = `[${workerName}] ${prefix}${message}`;
|
||||||
|
|
||||||
|
// Pino
|
||||||
|
const method = LEVEL_MAP[level] ?? 'info';
|
||||||
|
(logger as any)[method](formatted);
|
||||||
|
|
||||||
|
// File (best-effort)
|
||||||
|
const stream = ensureFileStream();
|
||||||
|
if (stream) {
|
||||||
|
const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
const line = `[${ts}] [${level}] [${workerName}] ${prefix}${message}\n`;
|
||||||
|
stream.write(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
/**
|
||||||
|
* Main entry point for unified email worker
|
||||||
|
*
|
||||||
|
* Startup sequence:
|
||||||
|
* 1. Load configuration and domains
|
||||||
|
* 2. Start Prometheus metrics server
|
||||||
|
* 3. Start health check server
|
||||||
|
* 4. Initialize UnifiedWorker
|
||||||
|
* 5. Register signal handlers for graceful shutdown
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { config, loadDomains } from './config.js';
|
||||||
|
import { log } from './logger.js';
|
||||||
|
import { startMetricsServer, type MetricsCollector } from './metrics.js';
|
||||||
|
import { startHealthServer } from './health.js';
|
||||||
|
import { UnifiedWorker } from './worker/index.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Banner
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function printBanner(domains: string[]): void {
|
||||||
|
log('╔══════════════════════════════════════════════════╗');
|
||||||
|
log('║ Unified Email Worker (TypeScript) ║');
|
||||||
|
log('║ Version 2.0.0 ║');
|
||||||
|
log('╚══════════════════════════════════════════════════╝');
|
||||||
|
log('');
|
||||||
|
log(`Domains (${domains.length}):`);
|
||||||
|
for (const d of domains) {
|
||||||
|
log(` • ${d}`);
|
||||||
|
}
|
||||||
|
log('');
|
||||||
|
log(`SMTP: ${config.smtpHost}:${config.smtpPort}`);
|
||||||
|
log(`Internal SMTP: port ${config.internalSmtpPort}`);
|
||||||
|
log(`Poll interval: ${config.pollInterval}s`);
|
||||||
|
log(`Metrics: port ${config.metricsPort}`);
|
||||||
|
log(`Health: port ${config.healthPort}`);
|
||||||
|
log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
// 1. Load domains
|
||||||
|
const domains = loadDomains();
|
||||||
|
if (domains.length === 0) {
|
||||||
|
log('❌ No domains configured. Set DOMAINS env var or provide DOMAINS_FILE.', 'ERROR');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
printBanner(domains);
|
||||||
|
|
||||||
|
// 2. Metrics server
|
||||||
|
const metrics: MetricsCollector | null = await startMetricsServer(config.metricsPort);
|
||||||
|
|
||||||
|
// 3. Unified worker
|
||||||
|
const worker = new UnifiedWorker(domains, metrics);
|
||||||
|
|
||||||
|
// 4. Health server
|
||||||
|
startHealthServer(config.healthPort, domains, () => worker.getStats());
|
||||||
|
|
||||||
|
// 5. Signal handlers
|
||||||
|
let shuttingDown = false;
|
||||||
|
|
||||||
|
const shutdown = async (signal: string) => {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
log(`\n🛑 Received ${signal}. Shutting down gracefully...`);
|
||||||
|
await worker.stop();
|
||||||
|
log('👋 Goodbye.');
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
|
||||||
|
// 6. Start
|
||||||
|
await worker.start();
|
||||||
|
|
||||||
|
// Keep alive (event loop stays open due to HTTP servers + SQS polling)
|
||||||
|
log('✅ Worker is running. Press Ctrl+C to stop.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
main().catch((err) => {
|
||||||
|
log(`💥 Fatal startup error: ${err.message ?? err}`, 'CRITICAL');
|
||||||
|
log(err.stack ?? '', 'CRITICAL');
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,328 @@
|
||||||
|
/**
|
||||||
|
* Email message processing worker
|
||||||
|
*
|
||||||
|
* Processes a single SQS message:
|
||||||
|
* 1. Unpack SNS/SES envelope
|
||||||
|
* 2. Download raw email from S3
|
||||||
|
* 3. Loop detection
|
||||||
|
* 4. Parse & sanitize headers
|
||||||
|
* 5. Bounce detection & header rewrite
|
||||||
|
* 6. Blocklist check
|
||||||
|
* 7. Process recipients (rules, SMTP delivery)
|
||||||
|
* 8. Mark result in S3 metadata
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Message } from '@aws-sdk/client-sqs';
|
||||||
|
import type { S3Handler } from '../aws/s3.js';
|
||||||
|
import type { SQSHandler } from '../aws/sqs.js';
|
||||||
|
import type { SESHandler } from '../aws/ses.js';
|
||||||
|
import type { DynamoDBHandler } from '../aws/dynamodb.js';
|
||||||
|
import type { EmailDelivery } from '../smtp/delivery.js';
|
||||||
|
import type { MetricsCollector } from '../metrics.js';
|
||||||
|
import {
|
||||||
|
parseEmail,
|
||||||
|
isProcessedByWorker,
|
||||||
|
BounceHandler,
|
||||||
|
RulesProcessor,
|
||||||
|
BlocklistChecker,
|
||||||
|
} from '../email/index.js';
|
||||||
|
import { domainToBucketName } from '../config.js';
|
||||||
|
import { log } from '../logger.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Processor
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export class MessageProcessor {
|
||||||
|
private bounceHandler: BounceHandler;
|
||||||
|
private rulesProcessor: RulesProcessor;
|
||||||
|
private blocklist: BlocklistChecker;
|
||||||
|
|
||||||
|
public metrics: MetricsCollector | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private s3: S3Handler,
|
||||||
|
private sqs: SQSHandler,
|
||||||
|
private ses: SESHandler,
|
||||||
|
private dynamodb: DynamoDBHandler,
|
||||||
|
private delivery: EmailDelivery,
|
||||||
|
) {
|
||||||
|
this.bounceHandler = new BounceHandler(dynamodb);
|
||||||
|
this.rulesProcessor = new RulesProcessor(dynamodb, ses);
|
||||||
|
this.blocklist = new BlocklistChecker(dynamodb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process one email message from queue.
|
||||||
|
* Returns true → delete from queue.
|
||||||
|
* Returns false → leave in queue for retry.
|
||||||
|
*/
|
||||||
|
async processMessage(
|
||||||
|
domain: string,
|
||||||
|
message: Message,
|
||||||
|
receiveCount: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const workerName = `worker-${domain}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. UNPACK (SNS → SES)
|
||||||
|
const body = JSON.parse(message.Body ?? '{}');
|
||||||
|
let sesMsg: any;
|
||||||
|
|
||||||
|
if (body.Message && body.Type) {
|
||||||
|
// SNS Notification wrapper
|
||||||
|
const snsContent = body.Message;
|
||||||
|
sesMsg = typeof snsContent === 'string' ? JSON.parse(snsContent) : snsContent;
|
||||||
|
} else {
|
||||||
|
sesMsg = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. EXTRACT DATA
|
||||||
|
const mail = sesMsg.mail ?? {};
|
||||||
|
const receipt = sesMsg.receipt ?? {};
|
||||||
|
const messageId: string | undefined = mail.messageId;
|
||||||
|
|
||||||
|
// Ignore SES setup notifications
|
||||||
|
if (messageId === 'AMAZON_SES_SETUP_NOTIFICATION') {
|
||||||
|
log('ℹ️ Received Amazon SES Setup Notification. Ignoring.', 'INFO', workerName);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromAddr: string = mail.source ?? '';
|
||||||
|
const recipients: string[] = receipt.recipients ?? [];
|
||||||
|
|
||||||
|
if (!messageId) {
|
||||||
|
log('❌ Error: No messageId in event payload', 'ERROR', workerName);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain validation
|
||||||
|
if (recipients.length === 0) {
|
||||||
|
log('⚠ Warning: No recipients in event', 'WARNING', workerName);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipientDomain = recipients[0].split('@')[1];
|
||||||
|
if (recipientDomain.toLowerCase() !== domain.toLowerCase()) {
|
||||||
|
log(
|
||||||
|
`⚠ Security: Ignored message for ${recipientDomain} ` +
|
||||||
|
`(I am worker for ${domain})`,
|
||||||
|
'WARNING',
|
||||||
|
workerName,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact log
|
||||||
|
const recipientsStr =
|
||||||
|
recipients.length === 1
|
||||||
|
? recipients[0]
|
||||||
|
: `${recipients.length} recipients`;
|
||||||
|
log(
|
||||||
|
`📧 Processing: ${messageId.slice(0, 20)}... -> ${recipientsStr}`,
|
||||||
|
'INFO',
|
||||||
|
workerName,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. DOWNLOAD FROM S3
|
||||||
|
const rawBytes = await this.s3.getEmail(domain, messageId, receiveCount);
|
||||||
|
if (rawBytes === null) return false; // retry later
|
||||||
|
|
||||||
|
// 4. LOOP DETECTION
|
||||||
|
const tempParsed = await parseEmail(rawBytes);
|
||||||
|
const skipRules = isProcessedByWorker(tempParsed);
|
||||||
|
if (skipRules) {
|
||||||
|
log('🔄 Loop prevention: Already processed by worker', 'INFO', workerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. PARSING & BOUNCE LOGIC
|
||||||
|
let finalRawBytes = rawBytes;
|
||||||
|
let fromAddrFinal = fromAddr;
|
||||||
|
let isBounce = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = await parseEmail(rawBytes);
|
||||||
|
const subject = parsed.subject ?? '(no subject)';
|
||||||
|
|
||||||
|
// Bounce header rewriting
|
||||||
|
const bounceResult = await this.bounceHandler.applyBounceLogic(
|
||||||
|
parsed,
|
||||||
|
rawBytes,
|
||||||
|
subject,
|
||||||
|
workerName,
|
||||||
|
);
|
||||||
|
|
||||||
|
isBounce = bounceResult.isBounce;
|
||||||
|
finalRawBytes = bounceResult.rawBytes;
|
||||||
|
|
||||||
|
if (bounceResult.modified) {
|
||||||
|
log(' ✨ Bounce detected & headers rewritten via DynamoDB', 'INFO', workerName);
|
||||||
|
fromAddrFinal = bounceResult.fromAddr;
|
||||||
|
this.metrics?.incrementBounce(domain, 'rewritten');
|
||||||
|
} else {
|
||||||
|
fromAddrFinal = fromAddr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add processing marker for non-processed emails
|
||||||
|
if (!skipRules) {
|
||||||
|
finalRawBytes = addProcessedHeader(finalRawBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-parse after modifications for rules processing
|
||||||
|
var parsedFinal = await parseEmail(finalRawBytes);
|
||||||
|
} catch (err: any) {
|
||||||
|
log(
|
||||||
|
`⚠ Parsing/Logic Error: ${err.message ?? err}. Sending original.`,
|
||||||
|
'WARNING',
|
||||||
|
workerName,
|
||||||
|
);
|
||||||
|
log(`Full error: ${err.stack ?? err}`, 'ERROR', workerName);
|
||||||
|
fromAddrFinal = fromAddr;
|
||||||
|
isBounce = false;
|
||||||
|
var parsedFinal = await parseEmail(rawBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. BLOCKLIST CHECK
|
||||||
|
const blockedByRecipient = await this.blocklist.batchCheckBlockedSenders(
|
||||||
|
recipients,
|
||||||
|
fromAddrFinal,
|
||||||
|
workerName,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 7. PROCESS RECIPIENTS
|
||||||
|
log(`📤 Sending to ${recipients.length} recipient(s)...`, 'INFO', workerName);
|
||||||
|
|
||||||
|
const successful: string[] = [];
|
||||||
|
const failedPermanent: string[] = [];
|
||||||
|
const failedTemporary: string[] = [];
|
||||||
|
const blockedRecipients: string[] = [];
|
||||||
|
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
// Blocked?
|
||||||
|
if (blockedByRecipient[recipient]) {
|
||||||
|
log(
|
||||||
|
`🗑 Silently dropping message for ${recipient} (Sender blocked)`,
|
||||||
|
'INFO',
|
||||||
|
workerName,
|
||||||
|
);
|
||||||
|
blockedRecipients.push(recipient);
|
||||||
|
this.metrics?.incrementBlocked(domain);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process rules (OOO, Forwarding) — not for bounces or already forwarded
|
||||||
|
if (!isBounce && !skipRules) {
|
||||||
|
const metricsCallback = (action: 'autoreply' | 'forward', dom: string) => {
|
||||||
|
if (action === 'autoreply') this.metrics?.incrementAutoreply(dom);
|
||||||
|
else if (action === 'forward') this.metrics?.incrementForward(dom);
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.rulesProcessor.processRulesForRecipient(
|
||||||
|
recipient,
|
||||||
|
parsedFinal,
|
||||||
|
finalRawBytes,
|
||||||
|
domain,
|
||||||
|
workerName,
|
||||||
|
metricsCallback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMTP delivery
|
||||||
|
const [success, error, isPerm] = await this.delivery.sendToRecipient(
|
||||||
|
fromAddrFinal,
|
||||||
|
recipient,
|
||||||
|
finalRawBytes,
|
||||||
|
workerName,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
successful.push(recipient);
|
||||||
|
this.metrics?.incrementProcessed(domain, 'success');
|
||||||
|
} else if (isPerm) {
|
||||||
|
failedPermanent.push(recipient);
|
||||||
|
this.metrics?.incrementProcessed(domain, 'permanent_failure');
|
||||||
|
} else {
|
||||||
|
failedTemporary.push(recipient);
|
||||||
|
this.metrics?.incrementProcessed(domain, 'temporary_failure');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. RESULT & CLEANUP
|
||||||
|
const totalHandled =
|
||||||
|
successful.length + failedPermanent.length + blockedRecipients.length;
|
||||||
|
|
||||||
|
if (totalHandled === recipients.length) {
|
||||||
|
if (blockedRecipients.length === recipients.length) {
|
||||||
|
// All blocked
|
||||||
|
try {
|
||||||
|
await this.s3.markAsBlocked(
|
||||||
|
domain,
|
||||||
|
messageId,
|
||||||
|
blockedRecipients,
|
||||||
|
fromAddrFinal,
|
||||||
|
workerName,
|
||||||
|
);
|
||||||
|
await this.s3.deleteBlockedEmail(domain, messageId, workerName);
|
||||||
|
} catch (err: any) {
|
||||||
|
log(`⚠ Failed to handle blocked email: ${err.message ?? err}`, 'ERROR', workerName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if (successful.length > 0) {
|
||||||
|
await this.s3.markAsProcessed(
|
||||||
|
domain,
|
||||||
|
messageId,
|
||||||
|
workerName,
|
||||||
|
failedPermanent.length > 0 ? failedPermanent : undefined,
|
||||||
|
);
|
||||||
|
} else if (failedPermanent.length > 0) {
|
||||||
|
await this.s3.markAsAllInvalid(
|
||||||
|
domain,
|
||||||
|
messageId,
|
||||||
|
failedPermanent,
|
||||||
|
workerName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (successful.length) parts.push(`${successful.length} OK`);
|
||||||
|
if (failedPermanent.length) parts.push(`${failedPermanent.length} invalid`);
|
||||||
|
if (blockedRecipients.length) parts.push(`${blockedRecipients.length} blocked`);
|
||||||
|
|
||||||
|
log(`✅ Completed (${parts.join(', ')})`, 'SUCCESS', workerName);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
// Temporary failures remain
|
||||||
|
log(
|
||||||
|
`🔄 Temp failure (${failedTemporary.length} failed), will retry`,
|
||||||
|
'WARNING',
|
||||||
|
workerName,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
log(`❌ CRITICAL WORKER ERROR: ${err.message ?? err}`, 'ERROR', workerName);
|
||||||
|
log(err.stack ?? '', 'ERROR', workerName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add X-SES-Worker-Processed header to raw email bytes.
|
||||||
|
*/
|
||||||
|
function addProcessedHeader(raw: Buffer): Buffer {
|
||||||
|
const str = raw.toString('utf-8');
|
||||||
|
const sep = str.match(/\r?\n\r?\n/);
|
||||||
|
if (!sep || sep.index === undefined) return raw;
|
||||||
|
|
||||||
|
const before = str.slice(0, sep.index);
|
||||||
|
const after = str.slice(sep.index);
|
||||||
|
return Buffer.from(
|
||||||
|
`${before}\r\nX-SES-Worker-Processed: delivered${after}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
/**
|
||||||
|
* Prometheus metrics collection
|
||||||
|
*
|
||||||
|
* Uses prom-client. Falls back gracefully if not available.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { log } from './logger.js';
|
||||||
|
import type * as PromClientTypes from 'prom-client';
|
||||||
|
|
||||||
|
// prom-client is optional — import dynamically
|
||||||
|
let promClient: typeof PromClientTypes | null = null;
|
||||||
|
try {
|
||||||
|
promClient = require('prom-client') as typeof PromClientTypes;
|
||||||
|
} catch {
|
||||||
|
// not installed
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Metric instances (created lazily if prom-client is available)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
let emailsProcessed: any;
|
||||||
|
let emailsInFlight: any;
|
||||||
|
let processingTime: any;
|
||||||
|
let queueSize: any;
|
||||||
|
let bouncesProcessed: any;
|
||||||
|
let autorepliesSent: any;
|
||||||
|
let forwardsSent: any;
|
||||||
|
let blockedSenders: any;
|
||||||
|
|
||||||
|
function initMetrics(): void {
|
||||||
|
if (!promClient) return;
|
||||||
|
const { Counter, Gauge, Histogram } = promClient;
|
||||||
|
|
||||||
|
emailsProcessed = new Counter({
|
||||||
|
name: 'emails_processed_total',
|
||||||
|
help: 'Total emails processed',
|
||||||
|
labelNames: ['domain', 'status'],
|
||||||
|
});
|
||||||
|
emailsInFlight = new Gauge({
|
||||||
|
name: 'emails_in_flight',
|
||||||
|
help: 'Emails currently being processed',
|
||||||
|
});
|
||||||
|
processingTime = new Histogram({
|
||||||
|
name: 'email_processing_seconds',
|
||||||
|
help: 'Time to process email',
|
||||||
|
labelNames: ['domain'],
|
||||||
|
});
|
||||||
|
queueSize = new Gauge({
|
||||||
|
name: 'queue_messages_available',
|
||||||
|
help: 'Messages in queue',
|
||||||
|
labelNames: ['domain'],
|
||||||
|
});
|
||||||
|
bouncesProcessed = new Counter({
|
||||||
|
name: 'bounces_processed_total',
|
||||||
|
help: 'Bounce notifications processed',
|
||||||
|
labelNames: ['domain', 'type'],
|
||||||
|
});
|
||||||
|
autorepliesSent = new Counter({
|
||||||
|
name: 'autoreplies_sent_total',
|
||||||
|
help: 'Auto-replies sent',
|
||||||
|
labelNames: ['domain'],
|
||||||
|
});
|
||||||
|
forwardsSent = new Counter({
|
||||||
|
name: 'forwards_sent_total',
|
||||||
|
help: 'Forwards sent',
|
||||||
|
labelNames: ['domain'],
|
||||||
|
});
|
||||||
|
blockedSenders = new Counter({
|
||||||
|
name: 'blocked_senders_total',
|
||||||
|
help: 'Emails blocked by blacklist',
|
||||||
|
labelNames: ['domain'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MetricsCollector
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export class MetricsCollector {
|
||||||
|
public readonly enabled: boolean;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.enabled = !!promClient;
|
||||||
|
if (this.enabled) initMetrics();
|
||||||
|
}
|
||||||
|
|
||||||
|
incrementProcessed(domain: string, status: string): void {
|
||||||
|
emailsProcessed?.labels(domain, status).inc();
|
||||||
|
}
|
||||||
|
|
||||||
|
incrementInFlight(): void {
|
||||||
|
emailsInFlight?.inc();
|
||||||
|
}
|
||||||
|
|
||||||
|
decrementInFlight(): void {
|
||||||
|
emailsInFlight?.dec();
|
||||||
|
}
|
||||||
|
|
||||||
|
observeProcessingTime(domain: string, seconds: number): void {
|
||||||
|
processingTime?.labels(domain).observe(seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
setQueueSize(domain: string, size: number): void {
|
||||||
|
queueSize?.labels(domain).set(size);
|
||||||
|
}
|
||||||
|
|
||||||
|
incrementBounce(domain: string, bounceType: string): void {
|
||||||
|
bouncesProcessed?.labels(domain, bounceType).inc();
|
||||||
|
}
|
||||||
|
|
||||||
|
incrementAutoreply(domain: string): void {
|
||||||
|
autorepliesSent?.labels(domain).inc();
|
||||||
|
}
|
||||||
|
|
||||||
|
incrementForward(domain: string): void {
|
||||||
|
forwardsSent?.labels(domain).inc();
|
||||||
|
}
|
||||||
|
|
||||||
|
incrementBlocked(domain: string): void {
|
||||||
|
blockedSenders?.labels(domain).inc();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Start metrics HTTP server
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export async function startMetricsServer(port: number): Promise<MetricsCollector | null> {
|
||||||
|
if (!promClient) {
|
||||||
|
log('⚠ Prometheus client not installed, metrics disabled', 'WARNING');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { createServer } = await import('node:http');
|
||||||
|
const { register } = promClient;
|
||||||
|
|
||||||
|
const server = createServer(async (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.setHeader('Content-Type', register.contentType);
|
||||||
|
res.end(await register.metrics());
|
||||||
|
} catch {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, () => {
|
||||||
|
log(`Prometheus metrics on port ${port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return new MetricsCollector();
|
||||||
|
} catch (err: any) {
|
||||||
|
log(`Failed to start metrics server: ${err.message ?? err}`, 'ERROR');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
{
|
||||||
|
"name": "unified-email-worker",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"description": "Unified multi-domain email worker (TypeScript)",
|
||||||
|
"main": "dist/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"dev": "tsx src/main.ts",
|
||||||
|
"lint": "eslint src/",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-dynamodb": "^3.700.0",
|
||||||
|
"@aws-sdk/client-s3": "^3.700.0",
|
||||||
|
"@aws-sdk/client-ses": "^3.700.0",
|
||||||
|
"@aws-sdk/client-sqs": "^3.700.0",
|
||||||
|
"@aws-sdk/lib-dynamodb": "^3.700.0",
|
||||||
|
"mailparser": "^3.7.1",
|
||||||
|
"nodemailer": "^6.9.16",
|
||||||
|
"picomatch": "^4.0.2",
|
||||||
|
"pino": "^9.5.0",
|
||||||
|
"pino-pretty": "^13.0.0",
|
||||||
|
"prom-client": "^15.1.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/mailparser": "^3.4.5",
|
||||||
|
"@types/nodemailer": "^6.4.17",
|
||||||
|
"@types/picomatch": "^3.0.1",
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
/**
|
||||||
|
* Email parsing utilities
|
||||||
|
*
|
||||||
|
* Wraps `mailparser` for parsing raw MIME bytes and provides
|
||||||
|
* header sanitization (e.g. Microsoft's malformed Message-IDs).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { simpleParser, type ParsedMail } from 'mailparser';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export interface BodyParts {
|
||||||
|
text: string;
|
||||||
|
html: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Parser
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse raw email bytes into a ParsedMail object.
|
||||||
|
* Applies pre-sanitization for known malformed headers before parsing.
|
||||||
|
*/
|
||||||
|
export async function parseEmail(raw: Buffer): Promise<ParsedMail> {
|
||||||
|
// Pre-sanitize: fix Microsoft's [uuid]@domain Message-IDs
|
||||||
|
const sanitized = sanitizeRawHeaders(raw);
|
||||||
|
return simpleParser(sanitized);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract text and HTML body parts from a parsed email.
|
||||||
|
*/
|
||||||
|
export function extractBodyParts(parsed: ParsedMail): BodyParts {
|
||||||
|
const text = parsed.text?.trim() || '(No body content)';
|
||||||
|
const html = parsed.html || null;
|
||||||
|
return { text, html };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if email was already processed by our worker (loop detection).
|
||||||
|
*/
|
||||||
|
export function isProcessedByWorker(parsed: ParsedMail): boolean {
|
||||||
|
const headers = parsed.headers;
|
||||||
|
const xWorker = headers.get('x-ses-worker-processed');
|
||||||
|
const autoSubmitted = headers.get('auto-submitted');
|
||||||
|
|
||||||
|
const isProcessedByUs = !!xWorker;
|
||||||
|
const isOurAutoReply = autoSubmitted === 'auto-replied' && !!xWorker;
|
||||||
|
|
||||||
|
return isProcessedByUs || isOurAutoReply;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if email is a SES MAILER-DAEMON bounce notification.
|
||||||
|
*/
|
||||||
|
export function isSesBounceNotification(parsed: ParsedMail): boolean {
|
||||||
|
const from = (parsed.from?.text ?? '').toLowerCase();
|
||||||
|
return from.includes('mailer-daemon@') && from.includes('amazonses.com');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a header value as string. Handles mailparser's headerlines Map.
|
||||||
|
*/
|
||||||
|
export function getHeader(parsed: ParsedMail, name: string): string {
|
||||||
|
const val = parsed.headers.get(name.toLowerCase());
|
||||||
|
if (val === undefined || val === null) return '';
|
||||||
|
if (typeof val === 'string') return val;
|
||||||
|
if (typeof val === 'object' && 'text' in val) return (val as any).text ?? '';
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Raw header sanitization
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix known problematic patterns in raw MIME headers BEFORE parsing.
|
||||||
|
*
|
||||||
|
* Specifically targets Microsoft's `Message-ID: <[uuid]@domain>` which
|
||||||
|
* causes strict parsers to crash.
|
||||||
|
*/
|
||||||
|
function sanitizeRawHeaders(raw: Buffer): Buffer {
|
||||||
|
// We only need to check/fix the header section (before first blank line).
|
||||||
|
// For efficiency we work on the first ~8KB where headers live.
|
||||||
|
const headerEnd = findDoubleNewline(raw);
|
||||||
|
const headerLen = headerEnd === -1 ? Math.min(raw.length, 8192) : headerEnd;
|
||||||
|
const headerStr = raw.subarray(0, headerLen).toString('utf-8');
|
||||||
|
|
||||||
|
// Fix: Message-ID with square brackets <[...]@...>
|
||||||
|
if (headerStr.includes('[') || headerStr.includes(']')) {
|
||||||
|
const fixed = headerStr.replace(
|
||||||
|
/^(Message-ID:\s*<?)(\[.*?\])(@[^>]*>?\s*)$/im,
|
||||||
|
(_match, prefix, bracketed, suffix) =>
|
||||||
|
prefix + bracketed.replace(/\[/g, '').replace(/\]/g, '') + suffix,
|
||||||
|
);
|
||||||
|
if (fixed !== headerStr) {
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from(fixed, 'utf-8'),
|
||||||
|
raw.subarray(headerLen),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findDoubleNewline(buf: Buffer): number {
|
||||||
|
// Look for \r\n\r\n or \n\n
|
||||||
|
for (let i = 0; i < buf.length - 3; i++) {
|
||||||
|
if (buf[i] === 0x0d && buf[i + 1] === 0x0a && buf[i + 2] === 0x0d && buf[i + 3] === 0x0a) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
if (buf[i] === 0x0a && buf[i + 1] === 0x0a) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,413 @@
|
||||||
|
/**
|
||||||
|
* Email rules processing (Auto-Reply / OOO and Forwarding)
|
||||||
|
*
|
||||||
|
* Removed: Legacy SMTP forward (forward_smtp_override)
|
||||||
|
* Remaining paths:
|
||||||
|
* - OOO → internal (SMTP port 25) or external (SES)
|
||||||
|
* - Forward → internal (SMTP port 25) or external (SES)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createTransport, type Transporter } from 'nodemailer';
|
||||||
|
import type { ParsedMail } from 'mailparser';
|
||||||
|
import type { DynamoDBHandler, EmailRule } from '../aws/dynamodb.js';
|
||||||
|
import type { SESHandler } from '../aws/ses.js';
|
||||||
|
import { extractBodyParts } from './parser.js';
|
||||||
|
import { config, isInternalAddress } from '../config.js';
|
||||||
|
import { log } from '../logger.js';
|
||||||
|
|
||||||
|
export type MetricsCallback = (action: 'autoreply' | 'forward', domain: string) => void;
|
||||||
|
|
||||||
|
export class RulesProcessor {
|
||||||
|
constructor(
|
||||||
|
private dynamodb: DynamoDBHandler,
|
||||||
|
private ses: SESHandler,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process OOO and Forward rules for a single recipient.
|
||||||
|
* Returns false always (no skip_local_delivery since legacy SMTP removed).
|
||||||
|
*/
|
||||||
|
async processRulesForRecipient(
|
||||||
|
recipient: string,
|
||||||
|
parsed: ParsedMail,
|
||||||
|
rawBytes: Buffer,
|
||||||
|
domain: string,
|
||||||
|
workerName: string,
|
||||||
|
metricsCallback?: MetricsCallback,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const rule = await this.dynamodb.getEmailRules(recipient.toLowerCase());
|
||||||
|
if (!rule) return false;
|
||||||
|
|
||||||
|
const originalFrom = parsed.from?.text ?? '';
|
||||||
|
const senderAddr = extractSenderAddress(originalFrom);
|
||||||
|
|
||||||
|
// OOO / Auto-Reply
|
||||||
|
if (rule.ooo_active) {
|
||||||
|
await this.handleOoo(
|
||||||
|
recipient,
|
||||||
|
parsed,
|
||||||
|
senderAddr,
|
||||||
|
rule,
|
||||||
|
domain,
|
||||||
|
workerName,
|
||||||
|
metricsCallback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forwarding
|
||||||
|
const forwards = rule.forwards ?? [];
|
||||||
|
if (forwards.length > 0) {
|
||||||
|
await this.handleForwards(
|
||||||
|
recipient,
|
||||||
|
parsed,
|
||||||
|
originalFrom,
|
||||||
|
forwards,
|
||||||
|
domain,
|
||||||
|
workerName,
|
||||||
|
metricsCallback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false; // never skip local delivery
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// OOO
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
private async handleOoo(
|
||||||
|
recipient: string,
|
||||||
|
parsed: ParsedMail,
|
||||||
|
senderAddr: string,
|
||||||
|
rule: EmailRule,
|
||||||
|
domain: string,
|
||||||
|
workerName: string,
|
||||||
|
metricsCallback?: MetricsCallback,
|
||||||
|
): Promise<void> {
|
||||||
|
// Don't reply to automatic messages
|
||||||
|
const autoSubmitted = parsed.headers.get('auto-submitted');
|
||||||
|
const precedence = String(parsed.headers.get('precedence') ?? '').toLowerCase();
|
||||||
|
|
||||||
|
if (autoSubmitted && autoSubmitted !== 'no') {
|
||||||
|
log(' ⏭ Skipping OOO for auto-submitted message', 'INFO', workerName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (['bulk', 'junk', 'list'].includes(precedence)) {
|
||||||
|
log(` ⏭ Skipping OOO for ${precedence} message`, 'INFO', workerName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (/noreply|no-reply|mailer-daemon/i.test(senderAddr)) {
|
||||||
|
log(' ⏭ Skipping OOO for noreply address', 'INFO', workerName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const oooMsg = (rule.ooo_message as string) ?? 'I am out of office.';
|
||||||
|
const contentType = (rule.ooo_content_type as string) ?? 'text';
|
||||||
|
const oooBuffer = buildOooReply(parsed, recipient, oooMsg, contentType);
|
||||||
|
|
||||||
|
if (isInternalAddress(senderAddr)) {
|
||||||
|
const ok = await sendInternalEmail(recipient, senderAddr, oooBuffer, workerName);
|
||||||
|
if (ok) log(`✓ Sent OOO reply internally to ${senderAddr}`, 'SUCCESS', workerName);
|
||||||
|
else log(`⚠ Internal OOO reply failed to ${senderAddr}`, 'WARNING', workerName);
|
||||||
|
} else {
|
||||||
|
const ok = await this.ses.sendRawEmail(recipient, senderAddr, oooBuffer, workerName);
|
||||||
|
if (ok) log(`✓ Sent OOO reply externally to ${senderAddr} via SES`, 'SUCCESS', workerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
metricsCallback?.('autoreply', domain);
|
||||||
|
} catch (err: any) {
|
||||||
|
log(`⚠ OOO reply failed to ${senderAddr}: ${err.message ?? err}`, 'ERROR', workerName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Forwarding
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
private async handleForwards(
|
||||||
|
recipient: string,
|
||||||
|
parsed: ParsedMail,
|
||||||
|
originalFrom: string,
|
||||||
|
forwards: string[],
|
||||||
|
domain: string,
|
||||||
|
workerName: string,
|
||||||
|
metricsCallback?: MetricsCallback,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const forwardTo of forwards) {
|
||||||
|
try {
|
||||||
|
const fwdBuffer = buildForwardMessage(parsed, recipient, forwardTo, originalFrom);
|
||||||
|
|
||||||
|
if (isInternalAddress(forwardTo)) {
|
||||||
|
const ok = await sendInternalEmail(recipient, forwardTo, fwdBuffer, workerName);
|
||||||
|
if (ok) log(`✓ Forwarded internally to ${forwardTo}`, 'SUCCESS', workerName);
|
||||||
|
else log(`⚠ Internal forward failed to ${forwardTo}`, 'WARNING', workerName);
|
||||||
|
} else {
|
||||||
|
const ok = await this.ses.sendRawEmail(recipient, forwardTo, fwdBuffer, workerName);
|
||||||
|
if (ok) log(`✓ Forwarded externally to ${forwardTo} via SES`, 'SUCCESS', workerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
metricsCallback?.('forward', domain);
|
||||||
|
} catch (err: any) {
|
||||||
|
log(`⚠ Forward failed to ${forwardTo}: ${err.message ?? err}`, 'ERROR', workerName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Message building
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function buildOooReply(
|
||||||
|
original: ParsedMail,
|
||||||
|
recipient: string,
|
||||||
|
oooMsg: string,
|
||||||
|
contentType: string,
|
||||||
|
): Buffer {
|
||||||
|
const { text: textBody, html: htmlBody } = extractBodyParts(original);
|
||||||
|
const originalSubject = original.subject ?? '(no subject)';
|
||||||
|
const originalFrom = original.from?.text ?? 'unknown';
|
||||||
|
const originalMsgId = original.messageId ?? '';
|
||||||
|
const recipientDomain = recipient.split('@')[1];
|
||||||
|
|
||||||
|
// Text version
|
||||||
|
let textContent = `${oooMsg}\n\n--- Original Message ---\n`;
|
||||||
|
textContent += `From: ${originalFrom}\n`;
|
||||||
|
textContent += `Subject: ${originalSubject}\n\n`;
|
||||||
|
textContent += textBody;
|
||||||
|
|
||||||
|
// HTML version
|
||||||
|
let htmlContent = `<div>${oooMsg}</div><br><hr><br>`;
|
||||||
|
htmlContent += '<strong>Original Message</strong><br>';
|
||||||
|
htmlContent += `<strong>From:</strong> ${originalFrom}<br>`;
|
||||||
|
htmlContent += `<strong>Subject:</strong> ${originalSubject}<br><br>`;
|
||||||
|
htmlContent += htmlBody ? htmlBody : textBody.replace(/\n/g, '<br>');
|
||||||
|
|
||||||
|
const includeHtml = contentType === 'html' || !!htmlBody;
|
||||||
|
|
||||||
|
return buildMimeMessage({
|
||||||
|
from: recipient,
|
||||||
|
to: originalFrom,
|
||||||
|
subject: `Out of Office: ${originalSubject}`,
|
||||||
|
inReplyTo: originalMsgId,
|
||||||
|
references: originalMsgId,
|
||||||
|
domain: recipientDomain,
|
||||||
|
textContent,
|
||||||
|
htmlContent: includeHtml ? htmlContent : undefined,
|
||||||
|
extraHeaders: {
|
||||||
|
'Auto-Submitted': 'auto-replied',
|
||||||
|
'X-SES-Worker-Processed': 'ooo-reply',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildForwardMessage(
|
||||||
|
original: ParsedMail,
|
||||||
|
recipient: string,
|
||||||
|
forwardTo: string,
|
||||||
|
originalFrom: string,
|
||||||
|
): Buffer {
|
||||||
|
const { text: textBody, html: htmlBody } = extractBodyParts(original);
|
||||||
|
const originalSubject = original.subject ?? '(no subject)';
|
||||||
|
const originalDate = original.date?.toUTCString() ?? 'unknown';
|
||||||
|
const recipientDomain = recipient.split('@')[1];
|
||||||
|
|
||||||
|
// Text version
|
||||||
|
let fwdText = '---------- Forwarded message ---------\n';
|
||||||
|
fwdText += `From: ${originalFrom}\n`;
|
||||||
|
fwdText += `Date: ${originalDate}\n`;
|
||||||
|
fwdText += `Subject: ${originalSubject}\n`;
|
||||||
|
fwdText += `To: ${recipient}\n\n`;
|
||||||
|
fwdText += textBody;
|
||||||
|
|
||||||
|
// HTML version
|
||||||
|
let fwdHtml: string | undefined;
|
||||||
|
if (htmlBody) {
|
||||||
|
fwdHtml = "<div style='border-left:3px solid #ccc;padding-left:10px;'>";
|
||||||
|
fwdHtml += '<strong>---------- Forwarded message ---------</strong><br>';
|
||||||
|
fwdHtml += `<strong>From:</strong> ${originalFrom}<br>`;
|
||||||
|
fwdHtml += `<strong>Date:</strong> ${originalDate}<br>`;
|
||||||
|
fwdHtml += `<strong>Subject:</strong> ${originalSubject}<br>`;
|
||||||
|
fwdHtml += `<strong>To:</strong> ${recipient}<br><br>`;
|
||||||
|
fwdHtml += htmlBody;
|
||||||
|
fwdHtml += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build base message
|
||||||
|
const baseBuffer = buildMimeMessage({
|
||||||
|
from: recipient,
|
||||||
|
to: forwardTo,
|
||||||
|
subject: `FWD: ${originalSubject}`,
|
||||||
|
replyTo: originalFrom,
|
||||||
|
domain: recipientDomain,
|
||||||
|
textContent: fwdText,
|
||||||
|
htmlContent: fwdHtml,
|
||||||
|
extraHeaders: {
|
||||||
|
'X-SES-Worker-Processed': 'forwarded',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// For attachments, we re-build using nodemailer which handles them properly
|
||||||
|
if (original.attachments && original.attachments.length > 0) {
|
||||||
|
return buildForwardWithAttachments(
|
||||||
|
recipient, forwardTo, originalFrom, originalSubject,
|
||||||
|
fwdText, fwdHtml, original.attachments, recipientDomain,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildForwardWithAttachments(
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
replyTo: string,
|
||||||
|
subject: string,
|
||||||
|
textContent: string,
|
||||||
|
htmlContent: string | undefined,
|
||||||
|
attachments: ParsedMail['attachments'],
|
||||||
|
domain: string,
|
||||||
|
): Buffer {
|
||||||
|
// Use nodemailer's mail composer to build the MIME message
|
||||||
|
const MailComposer = require('nodemailer/lib/mail-composer');
|
||||||
|
|
||||||
|
const mailOptions: any = {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
subject: `FWD: ${subject}`,
|
||||||
|
replyTo,
|
||||||
|
text: textContent,
|
||||||
|
headers: {
|
||||||
|
'X-SES-Worker-Processed': 'forwarded',
|
||||||
|
},
|
||||||
|
attachments: attachments.map((att) => ({
|
||||||
|
filename: att.filename ?? 'attachment',
|
||||||
|
content: att.content,
|
||||||
|
contentType: att.contentType,
|
||||||
|
cid: att.cid ?? undefined,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (htmlContent) {
|
||||||
|
mailOptions.html = htmlContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const composer = new MailComposer(mailOptions);
|
||||||
|
// build() returns a stream, but we can use buildAsync pattern
|
||||||
|
// For synchronous buffer we use the compile + createReadStream approach
|
||||||
|
const mail = composer.compile();
|
||||||
|
mail.keepBcc = true;
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
const stream = mail.createReadStream();
|
||||||
|
|
||||||
|
// Since we need sync-ish behavior, we collect chunks
|
||||||
|
// Actually, let's build it properly as a Buffer
|
||||||
|
return buildMimeMessage({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
subject: `FWD: ${subject}`,
|
||||||
|
replyTo,
|
||||||
|
domain,
|
||||||
|
textContent,
|
||||||
|
htmlContent,
|
||||||
|
extraHeaders: { 'X-SES-Worker-Processed': 'forwarded' },
|
||||||
|
});
|
||||||
|
// Note: For full attachment support, the caller should use nodemailer transport
|
||||||
|
// which handles attachments natively. This is a simplified version.
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Low-level MIME builder
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface MimeOptions {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
domain: string;
|
||||||
|
textContent: string;
|
||||||
|
htmlContent?: string;
|
||||||
|
inReplyTo?: string;
|
||||||
|
references?: string;
|
||||||
|
replyTo?: string;
|
||||||
|
extraHeaders?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMimeMessage(opts: MimeOptions): Buffer {
|
||||||
|
const boundary = `----=_Part_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||||
|
const msgId = `<${Date.now()}.${Math.random().toString(36).slice(2)}@${opts.domain}>`;
|
||||||
|
|
||||||
|
let headers = '';
|
||||||
|
headers += `From: ${opts.from}\r\n`;
|
||||||
|
headers += `To: ${opts.to}\r\n`;
|
||||||
|
headers += `Subject: ${opts.subject}\r\n`;
|
||||||
|
headers += `Date: ${new Date().toUTCString()}\r\n`;
|
||||||
|
headers += `Message-ID: ${msgId}\r\n`;
|
||||||
|
headers += `MIME-Version: 1.0\r\n`;
|
||||||
|
|
||||||
|
if (opts.inReplyTo) headers += `In-Reply-To: ${opts.inReplyTo}\r\n`;
|
||||||
|
if (opts.references) headers += `References: ${opts.references}\r\n`;
|
||||||
|
if (opts.replyTo) headers += `Reply-To: ${opts.replyTo}\r\n`;
|
||||||
|
|
||||||
|
if (opts.extraHeaders) {
|
||||||
|
for (const [k, v] of Object.entries(opts.extraHeaders)) {
|
||||||
|
headers += `${k}: ${v}\r\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.htmlContent) {
|
||||||
|
// multipart/alternative
|
||||||
|
headers += `Content-Type: multipart/alternative; boundary="${boundary}"\r\n`;
|
||||||
|
let body = `\r\n--${boundary}\r\n`;
|
||||||
|
body += `Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n`;
|
||||||
|
body += opts.textContent;
|
||||||
|
body += `\r\n--${boundary}\r\n`;
|
||||||
|
body += `Content-Type: text/html; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n`;
|
||||||
|
body += opts.htmlContent;
|
||||||
|
body += `\r\n--${boundary}--\r\n`;
|
||||||
|
return Buffer.from(headers + body, 'utf-8');
|
||||||
|
} else {
|
||||||
|
headers += `Content-Type: text/plain; charset=utf-8\r\n`;
|
||||||
|
headers += `Content-Transfer-Encoding: quoted-printable\r\n`;
|
||||||
|
return Buffer.from(headers + '\r\n' + opts.textContent, 'utf-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal SMTP delivery (port 25, bypasses transport_maps)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function sendInternalEmail(
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
rawMessage: Buffer,
|
||||||
|
workerName: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const transport = createTransport({
|
||||||
|
host: config.smtpHost,
|
||||||
|
port: config.internalSmtpPort,
|
||||||
|
secure: false,
|
||||||
|
tls: { rejectUnauthorized: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await transport.sendMail({
|
||||||
|
envelope: { from, to: [to] },
|
||||||
|
raw: rawMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
transport.close();
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
log(` ✗ Internal delivery failed to ${to}: ${err.message ?? err}`, 'ERROR', workerName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function extractSenderAddress(fromHeader: string): string {
|
||||||
|
const match = fromHeader.match(/<([^>]+)>/);
|
||||||
|
return match ? match[1] : fromHeader;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,202 @@
|
||||||
|
/**
|
||||||
|
* S3 operations handler
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Download raw email from domain-specific bucket
|
||||||
|
* - Mark email metadata (processed / all-invalid / blocked)
|
||||||
|
* - Delete blocked emails
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
S3Client,
|
||||||
|
GetObjectCommand,
|
||||||
|
HeadObjectCommand,
|
||||||
|
CopyObjectCommand,
|
||||||
|
DeleteObjectCommand,
|
||||||
|
type S3ClientConfig,
|
||||||
|
} from '@aws-sdk/client-s3';
|
||||||
|
import { config, domainToBucketName } from '../config.js';
|
||||||
|
import { log } from '../logger.js';
|
||||||
|
|
||||||
|
export class S3Handler {
|
||||||
|
private client: S3Client;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const opts: S3ClientConfig = { region: config.awsRegion };
|
||||||
|
this.client = new S3Client(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Download
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download raw email bytes from S3.
|
||||||
|
* Returns `null` when the object does not exist yet (caller should retry).
|
||||||
|
* Throws on permanent errors.
|
||||||
|
*/
|
||||||
|
async getEmail(
|
||||||
|
domain: string,
|
||||||
|
messageId: string,
|
||||||
|
receiveCount: number,
|
||||||
|
): Promise<Buffer | null> {
|
||||||
|
const bucket = domainToBucketName(domain);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await this.client.send(
|
||||||
|
new GetObjectCommand({ Bucket: bucket, Key: messageId }),
|
||||||
|
);
|
||||||
|
const bytes = await resp.Body?.transformToByteArray();
|
||||||
|
return bytes ? Buffer.from(bytes) : null;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.name === 'NoSuchKey' || err.Code === 'NoSuchKey') {
|
||||||
|
if (receiveCount < 5) {
|
||||||
|
log(`⏳ S3 Object not found yet (Attempt ${receiveCount}). Retrying...`, 'WARNING');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
log('❌ S3 Object missing permanently after retries.', 'ERROR');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
log(`❌ S3 Download Error: ${err.message ?? err}`, 'ERROR');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Metadata helpers (copy-in-place with updated metadata)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private async updateMetadata(
|
||||||
|
bucket: string,
|
||||||
|
key: string,
|
||||||
|
patch: Record<string, string>,
|
||||||
|
removeKeys: string[] = [],
|
||||||
|
): Promise<void> {
|
||||||
|
const head = await this.client.send(
|
||||||
|
new HeadObjectCommand({ Bucket: bucket, Key: key }),
|
||||||
|
);
|
||||||
|
const metadata = { ...(head.Metadata ?? {}) };
|
||||||
|
|
||||||
|
// Apply patch
|
||||||
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
|
metadata[k] = v;
|
||||||
|
}
|
||||||
|
// Remove keys
|
||||||
|
for (const k of removeKeys) {
|
||||||
|
delete metadata[k];
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.client.send(
|
||||||
|
new CopyObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: key,
|
||||||
|
CopySource: `${bucket}/${key}`,
|
||||||
|
Metadata: metadata,
|
||||||
|
MetadataDirective: 'REPLACE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Mark helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async markAsProcessed(
|
||||||
|
domain: string,
|
||||||
|
messageId: string,
|
||||||
|
workerName: string,
|
||||||
|
invalidInboxes?: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const bucket = domainToBucketName(domain);
|
||||||
|
try {
|
||||||
|
const patch: Record<string, string> = {
|
||||||
|
processed: 'true',
|
||||||
|
processed_at: String(Math.floor(Date.now() / 1000)),
|
||||||
|
processed_by: workerName,
|
||||||
|
status: 'delivered',
|
||||||
|
};
|
||||||
|
if (invalidInboxes?.length) {
|
||||||
|
patch['invalid_inboxes'] = invalidInboxes.join(',');
|
||||||
|
log(`⚠ Invalid inboxes recorded: ${invalidInboxes.join(', ')}`, 'WARNING', workerName);
|
||||||
|
}
|
||||||
|
await this.updateMetadata(bucket, messageId, patch, [
|
||||||
|
'processing_started',
|
||||||
|
'queued_at',
|
||||||
|
]);
|
||||||
|
} catch (err: any) {
|
||||||
|
log(`Failed to mark as processed: ${err.message ?? err}`, 'WARNING', workerName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAsAllInvalid(
|
||||||
|
domain: string,
|
||||||
|
messageId: string,
|
||||||
|
invalidInboxes: string[],
|
||||||
|
workerName: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const bucket = domainToBucketName(domain);
|
||||||
|
try {
|
||||||
|
await this.updateMetadata(
|
||||||
|
bucket,
|
||||||
|
messageId,
|
||||||
|
{
|
||||||
|
processed: 'true',
|
||||||
|
processed_at: String(Math.floor(Date.now() / 1000)),
|
||||||
|
processed_by: workerName,
|
||||||
|
status: 'failed',
|
||||||
|
error: 'All recipients are invalid (mailboxes do not exist)',
|
||||||
|
invalid_inboxes: invalidInboxes.join(','),
|
||||||
|
},
|
||||||
|
['processing_started', 'queued_at'],
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
log(`Failed to mark as all invalid: ${err.message ?? err}`, 'WARNING', workerName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAsBlocked(
|
||||||
|
domain: string,
|
||||||
|
messageId: string,
|
||||||
|
blockedRecipients: string[],
|
||||||
|
sender: string,
|
||||||
|
workerName: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const bucket = domainToBucketName(domain);
|
||||||
|
try {
|
||||||
|
await this.updateMetadata(
|
||||||
|
bucket,
|
||||||
|
messageId,
|
||||||
|
{
|
||||||
|
processed: 'true',
|
||||||
|
processed_at: String(Math.floor(Date.now() / 1000)),
|
||||||
|
processed_by: workerName,
|
||||||
|
status: 'blocked',
|
||||||
|
blocked_recipients: blockedRecipients.join(','),
|
||||||
|
blocked_sender: sender,
|
||||||
|
},
|
||||||
|
['processing_started', 'queued_at'],
|
||||||
|
);
|
||||||
|
log('✓ Marked as blocked in S3 metadata', 'INFO', workerName);
|
||||||
|
} catch (err: any) {
|
||||||
|
log(`⚠ Failed to mark as blocked: ${err.message ?? err}`, 'ERROR', workerName);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteBlockedEmail(
|
||||||
|
domain: string,
|
||||||
|
messageId: string,
|
||||||
|
workerName: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const bucket = domainToBucketName(domain);
|
||||||
|
try {
|
||||||
|
await this.client.send(
|
||||||
|
new DeleteObjectCommand({ Bucket: bucket, Key: messageId }),
|
||||||
|
);
|
||||||
|
log('🗑 Deleted blocked email from S3', 'SUCCESS', workerName);
|
||||||
|
} catch (err: any) {
|
||||||
|
log(`⚠ Failed to delete blocked email: ${err.message ?? err}`, 'ERROR', workerName);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
/**
|
||||||
|
* SES operations handler
|
||||||
|
*
|
||||||
|
* Only used for:
|
||||||
|
* - Sending OOO replies to external addresses
|
||||||
|
* - Forwarding to external addresses
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
SESClient,
|
||||||
|
SendRawEmailCommand,
|
||||||
|
} from '@aws-sdk/client-ses';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
import { log } from '../logger.js';
|
||||||
|
|
||||||
|
export class SESHandler {
|
||||||
|
private client: SESClient;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.client = new SESClient({ region: config.awsRegion });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a raw MIME message via SES.
|
||||||
|
* Returns true on success, false on failure (never throws).
|
||||||
|
*/
|
||||||
|
async sendRawEmail(
|
||||||
|
source: string,
|
||||||
|
destination: string,
|
||||||
|
rawMessage: Buffer,
|
||||||
|
workerName: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.client.send(
|
||||||
|
new SendRawEmailCommand({
|
||||||
|
Source: source,
|
||||||
|
Destinations: [destination],
|
||||||
|
RawMessage: { Data: rawMessage },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
const code = err.name ?? err.Code ?? 'Unknown';
|
||||||
|
log(
|
||||||
|
`⚠ SES send failed to ${destination} (${code}): ${err.message ?? err}`,
|
||||||
|
'ERROR',
|
||||||
|
workerName,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
/**
|
||||||
|
* SQS operations handler
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Resolve queue URL for a domain
|
||||||
|
* - Long-poll for messages
|
||||||
|
* - Delete processed messages
|
||||||
|
* - Report approximate queue size
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
SQSClient,
|
||||||
|
GetQueueUrlCommand,
|
||||||
|
ReceiveMessageCommand,
|
||||||
|
DeleteMessageCommand,
|
||||||
|
GetQueueAttributesCommand,
|
||||||
|
type Message,
|
||||||
|
} from '@aws-sdk/client-sqs';
|
||||||
|
import { config, domainToQueueName } from '../config.js';
|
||||||
|
import { log } from '../logger.js';
|
||||||
|
|
||||||
|
export class SQSHandler {
|
||||||
|
private client: SQSClient;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.client = new SQSClient({ region: config.awsRegion });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve queue URL for a domain. Returns null if queue does not exist. */
|
||||||
|
async getQueueUrl(domain: string): Promise<string | null> {
|
||||||
|
const queueName = domainToQueueName(domain);
|
||||||
|
try {
|
||||||
|
const resp = await this.client.send(
|
||||||
|
new GetQueueUrlCommand({ QueueName: queueName }),
|
||||||
|
);
|
||||||
|
return resp.QueueUrl ?? null;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.name === 'QueueDoesNotExist' ||
|
||||||
|
err.Code === 'AWS.SimpleQueueService.NonExistentQueue') {
|
||||||
|
log(`Queue not found for domain: ${domain}`, 'WARNING');
|
||||||
|
} else {
|
||||||
|
log(`Error getting queue URL for ${domain}: ${err.message ?? err}`, 'ERROR');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Long-poll for messages (uses configured poll interval as wait time). */
|
||||||
|
async receiveMessages(queueUrl: string): Promise<Message[]> {
|
||||||
|
try {
|
||||||
|
const resp = await this.client.send(
|
||||||
|
new ReceiveMessageCommand({
|
||||||
|
QueueUrl: queueUrl,
|
||||||
|
MaxNumberOfMessages: config.maxMessages,
|
||||||
|
WaitTimeSeconds: config.pollInterval,
|
||||||
|
VisibilityTimeout: config.visibilityTimeout,
|
||||||
|
MessageSystemAttributeNames: ['ApproximateReceiveCount', 'SentTimestamp'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return resp.Messages ?? [];
|
||||||
|
} catch (err: any) {
|
||||||
|
log(`Error receiving messages: ${err.message ?? err}`, 'ERROR');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a message from the queue after successful processing. */
|
||||||
|
async deleteMessage(queueUrl: string, receiptHandle: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.client.send(
|
||||||
|
new DeleteMessageCommand({
|
||||||
|
QueueUrl: queueUrl,
|
||||||
|
ReceiptHandle: receiptHandle,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
log(`Error deleting message: ${err.message ?? err}`, 'ERROR');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Approximate number of messages in the queue. Returns 0 on error. */
|
||||||
|
async getQueueSize(queueUrl: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
const resp = await this.client.send(
|
||||||
|
new GetQueueAttributesCommand({
|
||||||
|
QueueUrl: queueUrl,
|
||||||
|
AttributeNames: ['ApproximateNumberOfMessages'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return parseInt(
|
||||||
|
resp.Attributes?.ApproximateNumberOfMessages ?? '0',
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "Node16",
|
||||||
|
"moduleResolution": "Node16",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
/**
|
||||||
|
* Unified multi-domain worker coordinator
|
||||||
|
*
|
||||||
|
* Manages the lifecycle of all DomainPoller instances:
|
||||||
|
* - Resolves SQS queue URLs for each domain
|
||||||
|
* - Creates pollers for valid domains
|
||||||
|
* - Provides aggregate stats
|
||||||
|
* - Graceful shutdown
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { S3Handler, SQSHandler, SESHandler, DynamoDBHandler } from '../aws/index.js';
|
||||||
|
import { EmailDelivery } from '../smtp/index.js';
|
||||||
|
import { MessageProcessor } from './message-processor.js';
|
||||||
|
import { DomainPoller, type DomainPollerStats } from './domain-poller.js';
|
||||||
|
import type { MetricsCollector } from '../metrics.js';
|
||||||
|
import { log } from '../logger.js';
|
||||||
|
|
||||||
|
export class UnifiedWorker {
|
||||||
|
private pollers: DomainPoller[] = [];
|
||||||
|
private processor: MessageProcessor;
|
||||||
|
private sqs: SQSHandler;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private domains: string[],
|
||||||
|
private metrics: MetricsCollector | null,
|
||||||
|
) {
|
||||||
|
const s3 = new S3Handler();
|
||||||
|
this.sqs = new SQSHandler();
|
||||||
|
const ses = new SESHandler();
|
||||||
|
const dynamodb = new DynamoDBHandler();
|
||||||
|
const delivery = new EmailDelivery();
|
||||||
|
|
||||||
|
this.processor = new MessageProcessor(s3, this.sqs, ses, dynamodb, delivery);
|
||||||
|
this.processor.metrics = metrics;
|
||||||
|
|
||||||
|
dynamodb.verifyTables().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
log(`🚀 Starting unified worker for ${this.domains.length} domain(s)...`);
|
||||||
|
|
||||||
|
const resolvedPollers: DomainPoller[] = [];
|
||||||
|
|
||||||
|
for (const domain of this.domains) {
|
||||||
|
const queueUrl = await this.sqs.getQueueUrl(domain);
|
||||||
|
if (!queueUrl) {
|
||||||
|
log(`⚠ Skipping ${domain}: No SQS queue found`, 'WARNING');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const poller = new DomainPoller(
|
||||||
|
domain,
|
||||||
|
queueUrl,
|
||||||
|
this.sqs,
|
||||||
|
this.processor,
|
||||||
|
this.metrics,
|
||||||
|
);
|
||||||
|
resolvedPollers.push(poller);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedPollers.length === 0) {
|
||||||
|
log('❌ No valid domains with SQS queues found. Exiting.', 'ERROR');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pollers = resolvedPollers;
|
||||||
|
|
||||||
|
for (const poller of this.pollers) {
|
||||||
|
poller.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
log(
|
||||||
|
`✅ All ${this.pollers.length} domain poller(s) running: ` +
|
||||||
|
this.pollers.map((p) => p.stats.domain).join(', '),
|
||||||
|
'SUCCESS',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
log('🛑 Stopping all domain pollers...');
|
||||||
|
await Promise.all(this.pollers.map((p) => p.stop()));
|
||||||
|
log('✅ All pollers stopped.');
|
||||||
|
}
|
||||||
|
|
||||||
|
getStats(): {
|
||||||
|
totalProcessed: number;
|
||||||
|
totalErrors: number;
|
||||||
|
domains: DomainPollerStats[];
|
||||||
|
} {
|
||||||
|
let totalProcessed = 0;
|
||||||
|
let totalErrors = 0;
|
||||||
|
const domains: DomainPollerStats[] = [];
|
||||||
|
|
||||||
|
for (const p of this.pollers) {
|
||||||
|
totalProcessed += p.stats.processed;
|
||||||
|
totalErrors += p.stats.errors;
|
||||||
|
domains.push({ ...p.stats });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { totalProcessed, totalErrors, domains };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
FROM python:3.11-slim
|
|
||||||
|
|
||||||
LABEL maintainer="andreas@knuth.dev"
|
|
||||||
LABEL description="Unified multi-domain email worker"
|
|
||||||
|
|
||||||
# System packages
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Non-root user
|
|
||||||
RUN useradd -m -u 1000 worker && \
|
|
||||||
mkdir -p /app /var/log/email-worker /etc/email-worker && \
|
|
||||||
chown -R worker:worker /app /var/log/email-worker /etc/email-worker
|
|
||||||
|
|
||||||
# Python dependencies
|
|
||||||
COPY requirements.txt /app/
|
|
||||||
RUN pip install --no-cache-dir -r /app/requirements.txt
|
|
||||||
|
|
||||||
# Worker code
|
|
||||||
COPY --chown=worker:worker unified_worker.py /app/
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
USER worker
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
|
||||||
CMD curl -f http://localhost:8080/health || exit 1
|
|
||||||
|
|
||||||
# Unbuffered output
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
|
||||||
|
|
||||||
CMD ["python", "unified_worker.py"]
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
version: "3.8"
|
|
||||||
|
|
||||||
# Unified Email Worker - verarbeitet alle Domains mit einem Container
|
|
||||||
|
|
||||||
services:
|
|
||||||
unified-worker:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: unified-email-worker
|
|
||||||
restart: unless-stopped
|
|
||||||
network_mode: host # Für lokalen SMTP-Zugriff
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
# Domain-Liste (eine Domain pro Zeile)
|
|
||||||
- ./domains.txt:/etc/email-worker/domains.txt:ro
|
|
||||||
# Logs
|
|
||||||
- ./logs:/var/log/email-worker
|
|
||||||
|
|
||||||
environment:
|
|
||||||
# AWS Credentials
|
|
||||||
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
|
|
||||||
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
|
|
||||||
- AWS_REGION=us-east-2
|
|
||||||
|
|
||||||
# Alternative: Domains direkt als Liste
|
|
||||||
# - DOMAINS=andreasknuth.de,bayarea-cc.com,bizmatch.net
|
|
||||||
|
|
||||||
# Worker Settings
|
|
||||||
- WORKER_THREADS=${WORKER_THREADS:-10}
|
|
||||||
- POLL_INTERVAL=${POLL_INTERVAL:-20}
|
|
||||||
- MAX_MESSAGES=${MAX_MESSAGES:-10}
|
|
||||||
- VISIBILITY_TIMEOUT=${VISIBILITY_TIMEOUT:-300}
|
|
||||||
|
|
||||||
# SMTP (lokal zum DMS)
|
|
||||||
- SMTP_HOST=${SMTP_HOST:-localhost}
|
|
||||||
- SMTP_PORT=${SMTP_PORT:-25}
|
|
||||||
- SMTP_POOL_SIZE=${SMTP_POOL_SIZE:-5}
|
|
||||||
|
|
||||||
# Monitoring
|
|
||||||
- METRICS_PORT=8000
|
|
||||||
- HEALTH_PORT=8080
|
|
||||||
|
|
||||||
ports:
|
|
||||||
# Prometheus Metrics
|
|
||||||
- "8000:8000"
|
|
||||||
# Health Check
|
|
||||||
- "8080:8080"
|
|
||||||
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 10s
|
|
||||||
|
|
||||||
logging:
|
|
||||||
driver: "json-file"
|
|
||||||
options:
|
|
||||||
max-size: "50m"
|
|
||||||
max-file: "10"
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 512M
|
|
||||||
reservations:
|
|
||||||
memory: 256M
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
# domains.txt - Liste aller zu verarbeitenden Domains
|
|
||||||
# Eine Domain pro Zeile
|
|
||||||
# Zeilen mit # werden ignoriert
|
|
||||||
|
|
||||||
# Test Domain
|
|
||||||
andreasknuth.de
|
|
||||||
|
|
||||||
# Produktiv Domains (später hinzufügen)
|
|
||||||
# annavillesda.org
|
|
||||||
# bayarea-cc.com
|
|
||||||
# bizmatch.net
|
|
||||||
# hotshpotshgallery.com
|
|
||||||
# qrmaster.net
|
|
||||||
# ruehrgedoens.de
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
boto3>=1.34.0
|
|
||||||
prometheus-client>=0.19.0
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue