Files

206 lines
7.3 KiB
Python
Raw Permalink Normal View History

"""场外基金邮件回复 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],
}