This commit is contained in:
2026-01-25 13:20:58 -06:00
parent 3884abc695
commit 2d9aba7e04
37 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
#!/usr/bin/env python3
"""
SMTP connection handling
"""
from .pool import SMTPPool
__all__ = ['SMTPPool']

View File

@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""
SMTP/LMTP email delivery with retry logic
"""
import time
import smtplib
from typing import Tuple, Optional
from logger import log
from config import config
from smtp.pool import SMTPPool
class EmailDelivery:
"""Handles email delivery via SMTP or LMTP"""
def __init__(self, smtp_pool: SMTPPool):
self.smtp_pool = smtp_pool
@staticmethod
def is_permanent_recipient_error(error_msg: str) -> bool:
"""Check if error is permanent for this recipient (inbox doesn't exist)"""
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_to_recipient(
self,
from_addr: str,
recipient: str,
raw_message: bytes,
worker_name: str,
max_retries: int = 2
) -> Tuple[bool, Optional[str], bool]:
"""
Send email via SMTP/LMTP to ONE recipient
If LMTP is enabled, delivers directly to Dovecot (bypasses transport_maps).
With retry logic for connection errors.
Args:
from_addr: From address
recipient: Recipient address
raw_message: Raw MIME message bytes
worker_name: Worker name for logging
max_retries: Maximum retry attempts
Returns:
Tuple of (success: bool, error: str or None, is_permanent: bool)
"""
last_error = None
use_lmtp = config.lmtp_enabled
for attempt in range(max_retries + 1):
conn = None
try:
if use_lmtp:
# LMTP connection directly to Dovecot (bypasses Postfix/transport_maps)
conn = smtplib.LMTP(config.lmtp_host, config.lmtp_port, timeout=30)
conn.ehlo()
else:
# Normal SMTP connection from pool
conn = self.smtp_pool.get_connection()
if not conn:
last_error = "Could not get SMTP connection"
log(
f"{recipient}: No SMTP connection "
f"(attempt {attempt + 1}/{max_retries + 1})",
'WARNING',
worker_name
)
time.sleep(0.5)
continue
result = conn.sendmail(from_addr, [recipient], raw_message)
# Success
if use_lmtp:
conn.quit()
else:
self.smtp_pool.return_connection(conn)
if isinstance(result, dict) and result:
error = str(result.get(recipient, 'Unknown refusal'))
is_permanent = self.is_permanent_recipient_error(error)
log(
f"{recipient}: {error} ({'permanent' if is_permanent else 'temporary'})",
'ERROR',
worker_name
)
return False, error, is_permanent
else:
delivery_method = "LMTP" if use_lmtp else "SMTP"
log(f"{recipient}: Delivered ({delivery_method})", 'SUCCESS', worker_name)
return True, None, False
except smtplib.SMTPServerDisconnected as e:
# Connection was closed - Retry with new connection
log(
f"{recipient}: Connection lost, retrying... "
f"(attempt {attempt + 1}/{max_retries + 1})",
'WARNING',
worker_name
)
last_error = str(e)
if conn:
try:
conn.quit()
except:
pass
time.sleep(0.3)
continue
except smtplib.SMTPRecipientsRefused as e:
if conn and not use_lmtp:
self.smtp_pool.return_connection(conn)
elif conn:
try:
conn.quit()
except:
pass
error_msg = str(e)
is_permanent = self.is_permanent_recipient_error(error_msg)
log(f"{recipient}: Recipients refused - {error_msg}", 'ERROR', worker_name)
return False, error_msg, is_permanent
except smtplib.SMTPException as e:
error_msg = str(e)
# On connection errors: Retry
if 'disconnect' in error_msg.lower() or 'closed' in error_msg.lower() or 'connection' in error_msg.lower():
log(
f"{recipient}: Connection error, retrying... "
f"(attempt {attempt + 1}/{max_retries + 1})",
'WARNING',
worker_name
)
last_error = error_msg
if conn:
try:
conn.quit()
except:
pass
time.sleep(0.3)
continue
if conn and not use_lmtp:
self.smtp_pool.return_connection(conn)
elif conn:
try:
conn.quit()
except:
pass
is_permanent = self.is_permanent_recipient_error(error_msg)
log(f"{recipient}: Error - {error_msg}", 'ERROR', worker_name)
return False, error_msg, is_permanent
except Exception as e:
# Unknown error
if conn:
try:
conn.quit()
except:
pass
log(f"{recipient}: Unexpected error - {e}", 'ERROR', worker_name)
return False, str(e), False
# All retries failed
log(
f"{recipient}: All retries failed - {last_error}",
'ERROR',
worker_name
)
return False, last_error or "Connection failed after retries", False

113
email-worker/smtp/pool.py Normal file
View File

@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""
SMTP Connection Pool with robust connection handling
"""
import smtplib
from queue import Queue, Empty
from typing import Optional
from logger import log
from config import config
class SMTPPool:
"""Thread-safe SMTP Connection Pool"""
def __init__(self, host: str, port: int, pool_size: int = 5):
self.host = host
self.port = port
self.pool_size = pool_size
self._pool: Queue = Queue(maxsize=pool_size)
self._initialized = False
def _create_connection(self) -> Optional[smtplib.SMTP]:
"""Create new SMTP connection"""
try:
conn = smtplib.SMTP(self.host, self.port, timeout=30)
conn.ehlo()
if config.smtp_use_tls:
conn.starttls()
conn.ehlo()
if config.smtp_user and config.smtp_pass:
conn.login(config.smtp_user, config.smtp_pass)
log(f" 📡 New SMTP connection created to {self.host}:{self.port}")
return conn
except Exception as e:
log(f"Failed to create SMTP connection: {e}", 'ERROR')
return None
def _test_connection(self, conn: smtplib.SMTP) -> bool:
"""Test if connection is still alive"""
try:
status = conn.noop()[0]
return status == 250
except Exception:
return False
def initialize(self):
"""Pre-create connections"""
if self._initialized:
return
# Only 1-2 connections initially, rest on-demand
for _ in range(min(2, self.pool_size)):
conn = self._create_connection()
if conn:
self._pool.put(conn)
self._initialized = True
log(f"SMTP pool initialized with {self._pool.qsize()} connections (max: {self.pool_size})")
def get_connection(self, timeout: float = 5.0) -> Optional[smtplib.SMTP]:
"""Get a valid connection from pool or create new one"""
# Try to get from pool
try:
conn = self._pool.get(block=False)
# Test if connection is still alive
if self._test_connection(conn):
return conn
else:
# Connection is dead, close and create new one
try:
conn.quit()
except:
pass
log(f" ♻ Recycled stale SMTP connection")
return self._create_connection()
except Empty:
# Pool empty, create new connection
return self._create_connection()
def return_connection(self, conn: smtplib.SMTP):
"""Return connection to pool if still valid"""
if conn is None:
return
# Check if connection is still good
if not self._test_connection(conn):
try:
conn.quit()
except:
pass
log(f" 🗑 Discarded broken SMTP connection")
return
# Try to return to pool
try:
self._pool.put_nowait(conn)
except:
# Pool full, close connection
try:
conn.quit()
except:
pass
def close_all(self):
"""Close all connections"""
while not self._pool.empty():
try:
conn = self._pool.get_nowait()
conn.quit()
except:
pass