class SmtpEngine:
def __init__(
self,
accounts: list[SMTPAccount],
db: Database,
rate_limiter: RateLimiter,
timeout: float = 30.0,
) -> None:
self.accounts = accounts
self.db = db
self.rate_limiter = rate_limiter
self.timeout = timeout
self.bounce = BounceClassifier(db)
self.pool = SmtpConnectionPool(accounts, timeout)
for acct in accounts:
self.db.upsert_account(acct.email, acct.provider, acct.server, acct.port)
self.rate_limiter.register_provider(
acct.provider, acct.max_per_hour, acct.max_per_day
)
def send_atomic(
self,
account_idx: int,
recipient_email: str,
subject: str,
text_content: str,
html_content: str,
attachment_path: str | None = None,
reply_to: str | None = None,
custom_headers: dict[str, str] | None = None,
correlation_id: str = "",
) -> tuple[bool, SmtpErrorClass, int, str, dict[str, int]]:
phases: dict[str, int] = {}
start_ms = int(time.time() * 1000)
if account_idx >= len(self.accounts):
return False, SmtpErrorClass.UNKNOWN, 0, "No account", phases
acct = self.accounts[account_idx]
try:
msg = MIMEMultipart("alternative")
msg["From"] = formataddr((
str(Header(acct.display_name, "utf-8")), acct.email
))
msg["To"] = recipient_email
msg["Subject"] = str(Header(subject, "utf-8"))
msg["Message-ID"] = make_msgid()
msg["Date"] = formatdate(localtime=True)
msg["X-Mailer"] = "ArxivDispatch/5.0"
msg["User-Agent"] = "ArxivDispatch/5.0"
if reply_to:
msg["Reply-To"] = reply_to
if custom_headers:
for k, v in custom_headers.items():
msg[k] = v
msg.attach(MIMEText(text_content, "plain", "utf-8"))
msg.attach(MIMEText(html_content, "html", "utf-8"))
if attachment_path and os.path.exists(attachment_path):
wrapper = MIMEMultipart("mixed")
wrapper.attach(MIMEText(text_content, "plain", "utf-8"))
wrapper.attach(MIMEText(html_content, "html", "utf-8"))
with open(attachment_path, "rb") as f:
part = MIMEApplication(f.read(), _subtype="pdf")
part.add_header(
"Content-Disposition", "attachment",
filename=os.path.basename(attachment_path),
)
wrapper.attach(part)
for h in ("From", "To", "Subject", "Message-ID", "Date", "Reply-To", "X-Mailer"):
if msg[h]:
wrapper[h] = msg[h]
msg = wrapper
server = self.pool.get_connection(account_idx)
if server is None:
return False, SmtpErrorClass.DNS, 0, "Connection failed", phases
pool_phases = self.pool._capabilities.get(acct.email + "_phases", {})
phases.update(pool_phases)
phases["build_msg"] = int(time.time() * 1000) - start_ms
t_send = int(time.time() * 1000)
server.sendmail(acct.email, recipient_email, msg.as_string())
phases["sendmail"] = int(time.time() * 1000) - t_send
latency = int(time.time() * 1000) - start_ms
phases["total"] = latency
return True, SmtpErrorClass.TEMPORARY, latency, "", phases
except smtplib.SMTPResponseException as e:
latency = int(time.time() * 1000) - start_ms
phases["total"] = latency
pool_phases = self.pool._capabilities.get(acct.email + "_phases", {})
phases.update(pool_phases)
self.bounce.record(recipient_email, e.smtp_code, str(e))
err_class = classify_smtp_error(e, e.smtp_code)
err_text = e.smtp_error if isinstance(e.smtp_error, str) else str(e.smtp_error)
return False, err_class, latency, f"SMTP {e.smtp_code}: {err_text}", phases
except Exception as e:
latency = int(time.time() * 1000) - start_ms
phases["total"] = latency
pool_phases = self.pool._capabilities.get(acct.email + "_phases", {})
phases.update(pool_phases)
err_class = classify_smtp_error(e)
msg_str = str(e)[:200]
return False, err_class, latency, msg_str, phases
def _body_fingerprint(self, text: str) -> str:
return hashlib.sha256(text.lower().strip().encode("utf-8")).hexdigest()[:16]
def send_with_adaptive_routing(
self,
recipient_email: str,
subject: str,
text_content: str,
html_content: str,
recipient_id: int = 0,
attachment_path: str | None = None,
reply_to: str | None = None,
custom_headers: dict[str, str] | None = None,
dns_check: bool = True,
correlation_id: str = "",
) -> bool:
t_dns_start = int(time.time() * 1000)
if dns_check:
dns_ok, dns_msg = validate_email_dns(recipient_email)
if not dns_ok:
AppLogger.warn(f"DNS validation failed for {recipient_email}: {dns_msg}")
self.db.record_bounce(recipient_email, "dns", 0, dns_msg)
return False
dns_ms = int(time.time() * 1000) - t_dns_start
if self.db.is_bounced(recipient_email):
AppLogger.warn(f"Skipping previously bounced: {recipient_email}")
return False
max_retries = 3
base_backoff = 2.0
for attempt in range(max_retries + 1):
acct = self.db.get_best_account()
if not acct:
AppLogger.error("No healthy accounts available")
return False
account_email = acct["email"]
provider = acct["provider"]
account_idx = next(
(i for i, a in enumerate(self.accounts) if a.email == account_email),
None,
)
if account_idx is None:
continue
ok, limit_msg = self.rate_limiter.check(provider)
if not ok:
AppLogger.warn(f"Rate limit: {limit_msg}")
acct_idx_alt = (account_idx + 1) % len(self.accounts)
if acct_idx_alt != account_idx:
alt = self.accounts[acct_idx_alt]
AppLogger.info(f"Failing over to {alt.email}")
account_idx = acct_idx_alt
account_email = alt.email
provider = alt.provider
else:
time.sleep(60)
continue
success, err_class, latency_ms, err_msg, phases = self.send_atomic(
account_idx=account_idx,
recipient_email=recipient_email,
subject=subject,
text_content=text_content,
html_content=html_content,
attachment_path=attachment_path,
reply_to=reply_to,
custom_headers=custom_headers,
correlation_id=correlation_id,
)
body_fp = self._body_fingerprint(text_content)
if success:
self.rate_limiter.increment(provider)
self.db.record_account_success(account_email, latency_ms)
phases["dns"] = dns_ms
self.db.record_send(
recipient_id, account_email, "success",
latency_ms=latency_ms,
latency_details=json.dumps(phases),
body_fingerprint=body_fp,
correlation_id=correlation_id,
)
AppLogger.success(
f"Sent via {account_email} to {recipient_email} ({latency_ms}ms)",
recipient=recipient_email, account=account_email,
status="SUCCESS", latency=latency_ms / 1000.0,
correlation_id=correlation_id,
)
return True
self.db.record_account_failure(account_email, err_class.value)
phases["dns"] = dns_ms
self.db.record_send(
recipient_id, account_email, "failed",
error_type=err_class.value, error_detail=err_msg,
latency_ms=latency_ms,
latency_details=json.dumps(phases),
body_fingerprint=body_fp,
correlation_id=correlation_id,
)
AppLogger.warn(
f"Fail via {account_email}: {err_class.value} ({latency_ms}ms): {err_msg[:80]}",
recipient=recipient_email, account=account_email, status="FAIL",
correlation_id=correlation_id,
)
if err_class == SmtpErrorClass.AUTHENTICATION:
self.db.record_auth_failure(account_email)
self.db.suspend_account(account_email, 12.0)
AppLogger.error(f"Auth fail on {account_email} — suspended 12h")
continue
if err_class == SmtpErrorClass.BOUNCE_HARD:
AppLogger.error(f"Hard bounce for {recipient_email} — removing")
return False
if err_class == SmtpErrorClass.PERMANENT:
AppLogger.error("Permanent failure — skipping")
return False
if err_class == SmtpErrorClass.RATE_LIMITED:
self.db.suspend_account(account_email, 6.0)
AppLogger.warn(f"Rate limited on {account_email} — suspended 6h")
continue
if err_class in RETRYABLE and attempt < max_retries:
delay = base_backoff * (2 ** attempt) * random.uniform(0.8, 1.5)
AppLogger.info(f"Retry {attempt + 1}/{max_retries} in {delay:.1f}s...")
time.sleep(delay)
AppLogger.error(
f"All attempts exhausted for {recipient_email}",
recipient=recipient_email, status="FAIL",
)
return False
def close(self) -> None:
self.pool.close_all()