"""风控模块共用的 SMTP 邮件发送服务。""" from __future__ import annotations import asyncio import os import smtplib from collections.abc import Mapping from email.message import EmailMessage from email.utils import formatdate, make_msgid, parseaddr class RiskSmtpConfigurationError(ValueError): """SMTP 配置缺失或格式无效。""" class RiskSmtpSendError(RuntimeError): """SMTP 邮件发送失败。""" class RiskSmtpMailService: """使用 ``RISK_SMTP_*`` 配置发送风控邮件。""" def __init__(self, *, environment: Mapping[str, str] | None = None) -> None: self.environment = environment if environment is not None else os.environ async def send( self, recipients: list[str], subject: str, content: str, ) -> dict[str, object]: return await asyncio.to_thread( self._send_sync, recipients, subject, content, ) def _send_sync( self, recipients: list[str], subject: str, content: str, ) -> dict[str, object]: normalized = self._validated_recipients(recipients) host = self.environment.get("RISK_SMTP_HOST", "").strip() sender = self.environment.get("RISK_SMTP_SENDER", "").strip() if not host or not sender: raise RiskSmtpConfigurationError("SMTP 发件配置缺失") if not _is_email(sender): raise RiskSmtpConfigurationError("SMTP 发件地址格式无效") if not subject.strip() or not content.strip(): raise RiskSmtpConfigurationError("邮件主题和正文不能为空") if "\r" in subject or "\n" in subject: raise RiskSmtpConfigurationError("邮件主题包含非法换行") message = EmailMessage() message["From"] = sender message["To"] = ", ".join(normalized) message["Subject"] = subject message["Date"] = formatdate(localtime=True) message["Message-ID"] = make_msgid(domain="risk-alert.local") message.set_content(content) port = int(self.environment.get("RISK_SMTP_PORT", "465")) use_ssl = _enabled(self.environment, "RISK_SMTP_USE_SSL", default=True) timeout = float(self.environment.get("RISK_SMTP_TIMEOUT_SECONDS", "30")) smtp_class = smtplib.SMTP_SSL if use_ssl else smtplib.SMTP try: with smtp_class(host, port, timeout=timeout) as connection: username = self.environment.get("RISK_SMTP_USERNAME", "").strip() password = self.environment.get("RISK_SMTP_PASSWORD", "") if username: connection.login(username, password) connection.send_message(message) except smtplib.SMTPAuthenticationError as error: raise RiskSmtpSendError("SMTP 身份验证失败") from error except smtplib.SMTPRecipientsRefused as error: raise RiskSmtpSendError("收件人被邮件服务器拒绝") from error except smtplib.SMTPSenderRefused as error: raise RiskSmtpSendError("发件人被邮件服务器拒绝") from error except TimeoutError as error: raise RiskSmtpSendError("SMTP 连接超时") from error except OSError as error: raise RiskSmtpSendError(f"SMTP 连接失败:{error}") from error except smtplib.SMTPException as error: raise RiskSmtpSendError("SMTP 邮件发送失败") from error return {"status": "sent", "recipient_count": len(normalized)} @staticmethod def _validated_recipients(recipients: list[str]) -> list[str]: normalized: list[str] = [] seen: set[str] = set() for value in recipients: recipient = value.strip() if not _is_email(recipient): raise RiskSmtpConfigurationError("收件人地址格式无效") lowered = recipient.lower() if lowered not in seen: normalized.append(recipient) seen.add(lowered) if not normalized: raise RiskSmtpConfigurationError("收件人不能为空") return normalized def _enabled(environment: Mapping[str, str], name: str, *, default: bool = False) -> bool: value = environment.get(name) if value is None: return default return value.strip().lower() in {"1", "true", "yes", "on"} def _is_email(value: str) -> bool: _, parsed = parseaddr(value) return bool(value and parsed == value and "@" in value and len(value) <= 254)