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