修复高风险预警邮件通知并补充测试
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
"""风控模块共用的 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)
|
||||
Reference in New Issue
Block a user