用户要求:内测的固定验证码 123456 等于"知道手机号就能进任何账号",先关掉; 登录改用账号密码,并多一种"邮箱当账号名"的方式;发信邮箱要能在后台改。 短信那条路(默认关): - `SMS_LOGIN_ENABLED=false`,`/auth/sms/send` 与 `/auth/login` 统一回 2017,不是静默失败; - 生产校验改成"开着短信登录就必须配真通道",不开时 provider 是 dev 也不拦; - 新增 `GET /auth/methods`,登录页据此渲染,关着的路不出现(少一次"点进去才发现不能用")。 邮箱登录: - `app_user.email` + `uq_app_user_email`(`lower(email)` 部分索引),登录与绑定都归一成小写; - `POST /auth/login/password` 收 `phone` 或 `email`(二选一),失败统一 2013、连错 10 次锁 15 分钟; - 绑定在 `PATCH /auth/me`:格式错 2018、被占用 2019、留空串表示解绑。 系统设置(跨租户,只有平台管理员能改): - 新表 `system_setting`(不挂租户、无 RLS,写权限由 `deps.platform_admin` 把关); - `GET/PUT /settings/smtp`、`POST /settings/smtp/test`,授权码只写不读,留空表示沿用旧值; - `services/mail.py` 只用标准库 smtplib:465 走 SSL、587 走 STARTTLS,认证/拒收/网络失败都归 2020 但消息不同; - Web 新增「系统设置」页(状态卡 + 测试发信),侧栏只对平台管理员显示入口;账号页补「邮箱」。 验证:pytest 130 passed、ruff check/format 全绿、tsc 通过、vite build 通过; `web/e2e/login-email-smtp.mjs` 15 项全绿(含用 126 授权码真发一封测试邮件)。 顺带记下的两个坑:`platform_admin_phones` 是 list,pydantic-settings 会按 JSON 解析, 要用 `NoDecode` 交给 before-validator 拆逗号;迁移号写成 0009 会撞既有的 0009,一并改成 0010。
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
"""发信(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,
|
||||
}
|
||||
Reference in New Issue
Block a user