"""场外基金邮件回复 SMTP 适配层。""" from __future__ import annotations import mimetypes import smtplib from dataclasses import dataclass from email.message import EmailMessage from email.utils import formatdate, make_msgid from pathlib import Path from typing import Protocol, cast from app.core.config import Settings from app.core.offsite_fund_contracts import SendStatus @dataclass(frozen=True) class SmtpAttachment: filename: str media_type: str payload: bytes @dataclass(frozen=True) class OffsiteMailReplyRequest: to_address: str subject: str body: str operator_id: str operator_confirmed: bool reply_to_message_id: str | None = None attachments: tuple[SmtpAttachment, ...] = () retry_count: int = 0 @dataclass(frozen=True) class SmtpSendResult: status: SendStatus dry_run: bool provider_message_id: str | None failure_reason: str | None retry_count: int request_summary: dict[str, object] class SmtpConnection(Protocol): def login(self, user: str, password: str) -> object: ... def send_message(self, msg: EmailMessage) -> object: ... def quit(self) -> object: ... class OffsiteSmtpSender: """默认 dry-run,真实发送必须显式启用并经过运营确认。""" def __init__(self, settings: Settings, connection: SmtpConnection | None = None) -> None: self.settings = settings self._connection = connection def health_check(self) -> dict[str, object]: if not self.settings.offsite_smtp_enabled: return {"status": "disabled", "message": "场外 SMTP 未启用"} if self.settings.offsite_smtp_dry_run: return {"status": "dry_run", "message": "场外 SMTP 处于 dry-run 模式"} missing = self._missing_config() if missing: return {"status": "misconfigured", "missing": missing} return { "status": "ready", "host": self.settings.offsite_smtp_host, "sender": self.settings.offsite_smtp_sender, } def send_reply(self, request: OffsiteMailReplyRequest) -> SmtpSendResult: message_id = make_msgid(domain="offsite-fund.local") summary = self._summary(request) if not request.operator_confirmed: return SmtpSendResult( status="发送失败", dry_run=self.settings.offsite_smtp_dry_run, provider_message_id=None, failure_reason="邮件发送前必须完成运营确认", retry_count=request.retry_count, request_summary=summary, ) if not self.settings.offsite_smtp_enabled or self.settings.offsite_smtp_dry_run: return SmtpSendResult( status="待发送", dry_run=True, provider_message_id=None, failure_reason=None, retry_count=request.retry_count, request_summary=summary, ) missing = self._missing_config() if missing: return SmtpSendResult( status="发送失败", dry_run=False, provider_message_id=None, failure_reason=f"SMTP配置缺失:{', '.join(missing)}", retry_count=request.retry_count + 1, request_summary=summary, ) try: message = self._build_message(request, message_id) connection = self._ensure_connection() connection.send_message(message) return SmtpSendResult( status="发送成功", dry_run=False, provider_message_id=message_id, failure_reason=None, retry_count=request.retry_count, request_summary=summary, ) except (OSError, smtplib.SMTPException) as exc: self.close() return SmtpSendResult( status="发送失败", dry_run=False, provider_message_id=None, failure_reason=type(exc).__name__, retry_count=request.retry_count + 1, request_summary=summary, ) def close(self) -> None: if self._connection is None: return try: self._connection.quit() finally: self._connection = None def _ensure_connection(self) -> SmtpConnection: if self._connection is not None: return self._connection smtp_cls = smtplib.SMTP_SSL if self.settings.offsite_smtp_use_ssl else smtplib.SMTP connection = cast( SmtpConnection, smtp_cls( self.settings.offsite_smtp_host, self.settings.offsite_smtp_port, timeout=self.settings.offsite_smtp_timeout_seconds, ), ) connection.login(self.settings.offsite_smtp_username, self.settings.offsite_smtp_password) self._connection = connection return connection def _build_message(self, request: OffsiteMailReplyRequest, message_id: str) -> EmailMessage: message = EmailMessage() message["From"] = self.settings.offsite_smtp_sender message["To"] = request.to_address message["Subject"] = request.subject message["Date"] = formatdate(localtime=True) message["Message-ID"] = message_id if request.reply_to_message_id: message["In-Reply-To"] = request.reply_to_message_id message["References"] = request.reply_to_message_id message.set_content(request.body) for attachment in request.attachments: maintype, subtype = self._media_type(attachment) message.add_attachment( attachment.payload, maintype=maintype, subtype=subtype, filename=attachment.filename, ) return message def _missing_config(self) -> list[str]: missing = [] if not self.settings.offsite_smtp_host: missing.append("OFFSITE_SMTP_HOST") if not self.settings.offsite_smtp_username: missing.append("OFFSITE_SMTP_USERNAME") if not self.settings.offsite_smtp_password: missing.append("OFFSITE_SMTP_PASSWORD") if not self.settings.offsite_smtp_sender: missing.append("OFFSITE_SMTP_SENDER") return missing @staticmethod def _media_type(attachment: SmtpAttachment) -> tuple[str, str]: media_type = attachment.media_type if not media_type or "/" not in media_type: guessed, _ = mimetypes.guess_type(attachment.filename) media_type = guessed or "application/octet-stream" maintype, subtype = media_type.split("/", 1) return maintype, subtype @staticmethod def _summary(request: OffsiteMailReplyRequest) -> dict[str, object]: return { "to_address": request.to_address, "subject": request.subject, "operator_id": request.operator_id, "operator_confirmed": request.operator_confirmed, "reply_to_message_id": request.reply_to_message_id, "attachment_count": len(request.attachments), "attachment_names": [Path(item.filename).name for item in request.attachments], }