"""发信(SMTP)。 三个刻意的取舍: 1. **配置以数据库里的为准**。发信账号是平台级的运维参数,改它不该要人登服务器 改 env 再重启;所以「系统设置」页写库,这里每次读库、读不到才回落到环境变量。 2. **只用标准库 smtplib**。发信不是性能路径,引一个三方 SDK 换来的只是多一条升级债; SSL / STARTTLS 两种握手都覆盖了(465 走 SSL,587 走 STARTTLS)。 3. **失败要能说清是哪种失败**。连不上、认证不过、被对方拒收,对使用者是三件不同的 事;统一报"发信失败"会让人只能去翻服务器日志。所以异常分类后带进错误码 2020。 """ from __future__ import annotations import asyncio import contextlib import smtplib import ssl from dataclasses import dataclass from email.header import Header from email.message import EmailMessage from email.utils import formataddr, formatdate from sqlalchemy.ext.asyncio import AsyncSession from ..config import get_settings from ..errors import AppError, ErrorCode from ..models import SystemSetting SMTP_KEY = "smtp" # 存库的字段名与对外字段一致,避免来回翻译出现第三个名字 SMTP_FIELDS = ("host", "port", "username", "password", "from_name", "use_ssl") @dataclass(frozen=True) class SmtpConfig: host: str port: int username: str password: str from_name: str = "达道Task" use_ssl: bool = True @property def sender(self) -> str: return formataddr((str(Header(self.from_name, "utf-8")), self.username)) @property def configured(self) -> bool: return bool(self.host and self.username and self.password) def _from_settings() -> SmtpConfig | None: s = get_settings() if not (s.smtp_host and s.smtp_username and s.smtp_password): return None return SmtpConfig( host=s.smtp_host, port=s.smtp_port, username=s.smtp_username, password=s.smtp_password, from_name=s.smtp_from_name, use_ssl=s.smtp_use_ssl, ) async def load_config(session: AsyncSession) -> SmtpConfig | None: """先看库里有没有人配过,没有再回落到环境变量。""" row = await session.get(SystemSetting, SMTP_KEY) data = dict(row.value or {}) if row else {} if not (data.get("host") and data.get("username") and data.get("password")): return _from_settings() return SmtpConfig( host=str(data["host"]), port=int(data.get("port") or 465), username=str(data["username"]), # 授权码里可能有多余空格,粘贴时最常见的就是把尾巴带上 password=str(data["password"]).strip(), from_name=str(data.get("from_name") or "达道Task"), use_ssl=bool(data.get("use_ssl", True)), ) async def save_config(session: AsyncSession, *, values: dict, updated_by) -> SmtpConfig: """写库。password 传空表示"沿用原来的",不传等于没改。""" row = await session.get(SystemSetting, SMTP_KEY) current = dict(row.value or {}) if row else {} merged = {**current, **{k: v for k, v in values.items() if v is not None}} if not values.get("password"): merged["password"] = current.get("password", "") if not (merged.get("host") and merged.get("username") and merged.get("password")): raise AppError(ErrorCode.VALIDATION_FAILED, "SMTP 服务器、账号、授权码都要填") if row is None: row = SystemSetting(key=SMTP_KEY, value=merged, updated_by=updated_by) session.add(row) else: row.value = merged row.updated_by = updated_by await session.flush() cfg = SmtpConfig( host=str(merged["host"]), port=int(merged.get("port") or 465), username=str(merged["username"]), password=str(merged["password"]).strip(), from_name=str(merged.get("from_name") or "达道Task"), use_ssl=bool(merged.get("use_ssl", True)), ) return cfg def _send_sync(cfg: SmtpConfig, to: str, subject: str, text: str, html: str | None) -> None: msg = EmailMessage() msg["Subject"] = str(Header(subject, "utf-8")) msg["From"] = cfg.sender msg["To"] = to msg["Date"] = formatdate(localtime=True) msg.set_content(text) if html: msg.add_alternative(html, subtype="html") s = get_settings() timeout = s.smtp_timeout_seconds context = ssl.create_default_context() if cfg.use_ssl: server: smtplib.SMTP = smtplib.SMTP_SSL( cfg.host, cfg.port, timeout=timeout, context=context ) else: server = smtplib.SMTP(cfg.host, cfg.port, timeout=timeout) server.starttls(context=context) try: server.login(cfg.username, cfg.password) server.send_message(msg) finally: # 信已经发出去了,QUIT 失败不该把成功回报成失败 with contextlib.suppress(Exception): server.quit() async def send_mail( session_or_cfg: AsyncSession | SmtpConfig, *, to: str, subject: str, text: str, html: str | None = None, ) -> None: """发一封纯文本(可带 HTML 替代体)的信。失败一律抛 2020,并说清是哪一步。""" cfg = ( await load_config(session_or_cfg) if isinstance(session_or_cfg, AsyncSession) else session_or_cfg ) if cfg is None or not cfg.configured: raise AppError(ErrorCode.MAIL_SEND_FAILED, "还没有配置发信邮箱,请先在系统设置里填 SMTP") try: await asyncio.to_thread(_send_sync, cfg, to, subject, text, html) except smtplib.SMTPAuthenticationError as exc: raise AppError( ErrorCode.MAIL_SEND_FAILED, "SMTP 认证失败:账号或授权码不对(126/163 要用客户端授权码,不是登录密码)", ) from exc except smtplib.SMTPRecipientsRefused as exc: raise AppError(ErrorCode.MAIL_SEND_FAILED, f"收件地址被拒绝:{to}") from exc except (smtplib.SMTPException, OSError) as exc: raise AppError(ErrorCode.MAIL_SEND_FAILED, f"发信失败:{exc}") from exc async def config_payload(session: AsyncSession) -> dict: """给设置页看的形状:授权码只回"设没设",不回原值。""" row = await session.get(SystemSetting, SMTP_KEY) data = dict(row.value or {}) if row else {} cfg = await load_config(session) if cfg is None: return { "host": data.get("host") or "", "port": int(data.get("port") or 465), "username": data.get("username") or "", "password_set": bool(data.get("password")), "from_name": data.get("from_name") or "达道Task", "use_ssl": bool(data.get("use_ssl", True)), "configured": False, "source": "none", "updated_at": row.updated_at if row else None, } return { "host": cfg.host, "port": cfg.port, "username": cfg.username, "password_set": True, "from_name": cfg.from_name, "use_ssl": cfg.use_ssl, "configured": cfg.configured, "source": "db" if row else "env", "updated_at": row.updated_at if row else None, }