moved
This commit is contained in:
8
email-worker/smtp/__init__.py
Normal file
8
email-worker/smtp/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SMTP connection handling
|
||||
"""
|
||||
|
||||
from .pool import SMTPPool
|
||||
|
||||
__all__ = ['SMTPPool']
|
||||
187
email-worker/smtp/delivery.py
Normal file
187
email-worker/smtp/delivery.py
Normal 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
113
email-worker/smtp/pool.py
Normal 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
|
||||
Reference in New Issue
Block a user