feat(auth): 短信登录默认关,换成手机号/邮箱 + 密码,发信邮箱进后台
CI / backend (push) Canceled after 0s

用户要求:内测的固定验证码 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:
Steven
2026-11-03 00:00:11 +08:00
parent 23687d6e37
commit 7558637567
29 changed files with 1702 additions and 93 deletions
+94 -18
View File
@@ -1,17 +1,20 @@
"""认证:手机号 + 验证码,可选登录密码。
"""认证:手机号 / 邮箱 + 密码,手机验证码可选。
M0 只有验证码登录;密码是**可选**的第二条路:用户自己在账号设置里设一次,
之后登录页可以选「密码」;没设过密码的人只有验证码这一条,不受影响。
当前**默认关掉短信验证码**(`SMS_LOGIN_ENABLED=false`):内测阶段的固定验证码
等于"知道手机号就能进任意账号"。关掉后 `/auth/sms/send` 与 `/auth/login` 直接
回 2017,登录走「手机号 或 邮箱 + 密码」。登录页先问 `/auth/methods`,只显示
服务端真开着的登录方式,免得点进去才报错。
"""
from __future__ import annotations
import hmac
import re
import secrets
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import select
from pydantic import BaseModel, Field, model_validator
from sqlalchemy import func, select
from ..config import get_settings
from ..deps import CurrentUser, SessionDep
@@ -31,6 +34,8 @@ router = APIRouter(prefix="/auth", tags=["认证"])
settings = get_settings()
PHONE_PATTERN = r"^1[3-9]\d{9}$"
# 邮箱只做形状校验:真正的可达性靠发信验证,正则再严也证明不了地址存在
EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s]{1,190}\.[A-Za-z]{2,}$")
# 密码登录连错这么多次就先锁一会儿:暴力猜密码的成本必须比猜验证码高
PASSWORD_FAIL_LIMIT = 10
@@ -41,14 +46,24 @@ def mask_phone(phone: str) -> str:
return f"{phone[:3]}****{phone[-4:]}" if len(phone) == 11 else "***"
def normalize_email(email: str) -> str:
"""邮箱大小写不敏感:统一小写存,登录时也按小写比。"""
return email.strip().lower()
def _user_out(user: AppUser) -> dict:
"""对外只给脱敏手机号;密码本身一个字节都不出去,只告诉客户端「设没设」。"""
"""对外只给脱敏手机号;密码本身一个字节都不出去,只告诉客户端「设没设」。
邮箱是用户自己的账号名,给全值(账号页要显示、要能改)。
"""
return {
"id": str(user.id),
"phone": mask_phone(user.phone),
"email": user.email,
"nickname": user.nickname,
"avatar_url": user.avatar_url,
"has_password": bool(user.password_hash),
"platform_admin": user.phone in settings.platform_admin_phones,
"created_at": user.created_at,
}
@@ -72,12 +87,23 @@ class LoginIn(BaseModel):
class PasswordLoginIn(BaseModel):
phone: str = Field(pattern=PHONE_PATTERN)
"""手机号或邮箱 + 密码。两个都给也行,手机号优先(老客户端只发 phone)。"""
phone: str | None = Field(default=None, pattern=PHONE_PATTERN)
email: str | None = Field(default=None, max_length=255)
password: str = Field(min_length=1, max_length=64)
@model_validator(mode="after")
def _one_account(self) -> PasswordLoginIn:
if not self.phone and not (self.email and self.email.strip()):
raise ValueError("手机号或邮箱填一个")
return self
class ProfilePatch(BaseModel):
nickname: str | None = Field(default=None, min_length=1, max_length=32)
# 传空串表示解绑;不传表示不动
email: str | None = Field(default=None, max_length=255)
class PasswordIn(BaseModel):
@@ -91,8 +117,25 @@ class RefreshIn(BaseModel):
refresh_token: str = Field(min_length=10)
@router.get("/methods", summary="这台服务现在开放哪些登录方式")
async def methods() -> dict:
"""登录页据此渲染,别让用户点进去才发现这条路关着。"""
return ok(
{
"password": True,
"email_password": True,
"sms_code": settings.sms_login_enabled,
}
)
@router.post("/sms/send", summary="发送登录验证码")
async def send_code(body: SendCodeIn) -> dict:
if not settings.sms_login_enabled:
raise AppError(
ErrorCode.LOGIN_METHOD_DISABLED,
"短信验证码登录暂未开放,请用手机号或邮箱 + 密码登录",
)
store = get_store()
if settings.sms_throttle_seconds > 0:
hits = await store.incr(sms_throttle_key(body.phone), settings.sms_throttle_seconds)
@@ -115,6 +158,11 @@ async def send_code(body: SendCodeIn) -> dict:
@router.post("/login", summary="验证码登录(不存在则自动注册)")
async def login(body: LoginIn, session: SessionDep) -> dict:
if not settings.sms_login_enabled:
raise AppError(
ErrorCode.LOGIN_METHOD_DISABLED,
"短信验证码登录暂未开放,请用手机号或邮箱 + 密码登录",
)
store = get_store()
stored = await store.get(sms_code_key(body.phone))
if stored is None:
@@ -135,28 +183,36 @@ async def login(body: LoginIn, session: SessionDep) -> dict:
return ok({"user": _user_out(user), "tokens": _tokens_out(tokens)})
@router.post("/login/password", summary="密码登录")
@router.post("/login/password", summary="手机号 / 邮箱 + 密码登录")
async def login_password(body: PasswordLoginIn, session: SessionDep) -> dict:
"""手机号 + 密码登录。
"""手机号或邮箱 + 密码登录。
两个细节:
- **不区分"没这个人"和"密码错"**,同一个错误码,别让登录接口变成手机号探测工具。
三个细节:
- **不区分"没这个人"和"密码错"**,同一个错误码,别让登录接口变成账号探测工具。
- 连错 PASSWORD_FAIL_LIMIT 次锁 PASSWORD_FAIL_TTL 秒;成功即清零,正常用户不会感知。
失败计数按"填的那个账号"记,换手机号/邮箱轮着试也照样累计。
- 邮箱大小写不敏感(存的时候已经统一小写,这里再 lower 一次兜住历史数据)。
"""
store = get_store()
fail_key = f"pwd_fail:{body.phone}"
account_key = body.phone or normalize_email(body.email or "")
fail_key = f"pwd_fail:{account_key}"
if int(await store.get(fail_key) or 0) >= PASSWORD_FAIL_LIMIT:
raise AppError(
ErrorCode.LOGIN_TOO_MANY,
f"密码错误次数过多,请 {PASSWORD_FAIL_TTL // 60} 分钟后再试,或用验证码登录",
f"密码错误次数过多,请 {PASSWORD_FAIL_TTL // 60} 分钟后再试",
)
user = (
await session.execute(select(AppUser).where(AppUser.phone == body.phone))
).scalar_one_or_none()
if body.phone:
stmt = select(AppUser).where(AppUser.phone == body.phone)
hint = "手机号或密码不对"
else:
stmt = select(AppUser).where(func.lower(AppUser.email) == account_key)
hint = "邮箱或密码不对"
user = (await session.execute(stmt)).scalar_one_or_none()
if user is None or not verify_password(body.password, user.password_hash):
await store.incr(fail_key, PASSWORD_FAIL_TTL)
raise AppError(ErrorCode.PASSWORD_INVALID, "手机号或密码不对")
raise AppError(ErrorCode.PASSWORD_INVALID, hint)
if user.status != "active":
raise AppError(ErrorCode.ACCOUNT_DISABLED, "账号已停用")
@@ -207,7 +263,7 @@ async def me(user: CurrentUser, session: SessionDep) -> dict:
)
@router.patch("/me", summary="改自己的昵称")
@router.patch("/me", summary="改自己的昵称与邮箱")
async def update_me(body: ProfilePatch, user: CurrentUser, session: SessionDep) -> dict:
row = await session.get(AppUser, user.id)
if row is None:
@@ -219,6 +275,26 @@ async def update_me(body: ProfilePatch, user: CurrentUser, session: SessionDep)
raise AppError(ErrorCode.VALIDATION_FAILED, "昵称不能为空")
row.nickname = nickname
if body.email is not None:
raw = body.email.strip()
if not raw:
# 解绑:邮箱只是"多一条登录方式",不是必填项
row.email = None
else:
email = normalize_email(raw)
if not EMAIL_RE.match(email):
raise AppError(ErrorCode.EMAIL_INVALID, "邮箱格式不对")
taken = (
await session.execute(
select(AppUser.id).where(
func.lower(AppUser.email) == email, AppUser.id != user.id
)
)
).scalar_one_or_none()
if taken is not None:
raise AppError(ErrorCode.EMAIL_TAKEN, "这个邮箱已经绑在别的账号上了")
row.email = email
await session.flush()
return ok({"user": _user_out(row)})
+83
View File
@@ -0,0 +1,83 @@
"""系统设置:平台级的运维参数(目前只有 SMTP)。
为什么单独一个路由而不是塞进 auth:这些设置**不属于任何工作区**,
权限也不是租户角色,而是「平台管理员」白名单(`deps.platform_admin`)。
把它和租户接口分开,读代码的人一眼就知道这里改的是整台服务的配置。
"""
from __future__ import annotations
from fastapi import APIRouter
from pydantic import BaseModel, Field
from ..deps import PlatformAdminDep, SessionDep
from ..errors import AppError, ErrorCode
from ..models import AppUser
from ..schemas import ok
from ..services import mail
router = APIRouter(prefix="/settings", tags=["系统设置"])
class SmtpIn(BaseModel):
"""授权码留空表示"沿用原来的",不传等于没改——避免每次保存都要重新粘贴一遍。"""
host: str = Field(min_length=1, max_length=255)
port: int = Field(default=465, ge=1, le=65535)
username: str = Field(min_length=1, max_length=255)
password: str | None = Field(default=None, max_length=255)
from_name: str = Field(default="达道Task", max_length=64)
use_ssl: bool = True
class TestMailIn(BaseModel):
# 不填就发给自己(账号里绑的邮箱),方便先确认通道通不通
to: str | None = Field(default=None, max_length=255)
@router.get("/smtp", summary="读发信配置(授权码只回是否已设)")
async def get_smtp(admin: PlatformAdminDep, session: SessionDep) -> dict:
return ok(await mail.config_payload(session))
@router.put("/smtp", summary="改发信配置,存库即时生效")
async def put_smtp(body: SmtpIn, admin: PlatformAdminDep, session: SessionDep) -> dict:
await mail.save_config(
session,
values={
"host": body.host.strip(),
"port": body.port,
"username": body.username.strip(),
"password": (body.password or "").strip() or None,
"from_name": body.from_name.strip() or "达道Task",
"use_ssl": body.use_ssl,
},
updated_by=admin.id,
)
return ok(await mail.config_payload(session))
@router.post("/smtp/test", summary="发一封测试邮件")
async def test_smtp(body: TestMailIn, admin: PlatformAdminDep, session: SessionDep) -> dict:
"""真发一封。配错授权码、端口不通这类问题,只有真连一次才知道。"""
to = (body.to or "").strip()
if not to:
row = await session.get(AppUser, admin.id)
to = (row.email if row else None) or ""
if not to:
raise AppError(ErrorCode.VALIDATION_FAILED, "填一个收件邮箱,或先在自己的账号里绑一个")
cfg = await mail.load_config(session)
if cfg is None:
raise AppError(ErrorCode.MAIL_SEND_FAILED, "还没有配置发信邮箱,请先填上面的 SMTP")
await mail.send_mail(
cfg,
to=to,
subject="达道Task 测试邮件",
text=(
"这封信是从达道Task的「系统设置」里发出来的。\n\n"
"收到它说明 SMTP 配置可用:以后的找回密码、任务提醒都会走这条通道。\n"
),
)
return ok({"sent": True, "to": to})
+31 -7
View File
@@ -4,10 +4,10 @@ from __future__ import annotations
import os
from functools import lru_cache
from typing import Literal
from typing import Annotated, Literal
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
DEV_JWT_SECRET = "dev-only-change-me" # noqa: S105 - 仅开发默认值,生产启动时会被校验拦下
@@ -60,6 +60,30 @@ class Settings(BaseSettings):
# 内测开关:生产默认禁止固定验证码。确实要在内网试用时显式打开,
# 启动会打 WARNING(见 main.py),其余生产校验照旧——绝不能配合公网暴露。
internal_test_login: bool = False
# 登录方式开关:手机验证码这条**默认关掉**。内测期的固定验证码等于"知道手机号就能进任意账号",
# 关掉后 /auth/sms/send 与 /auth/login 统一回 2017,登录走「手机号 / 邮箱 + 密码」。
# 将来接上真短信通道把它打开即可,登录页会按 /auth/methods 自动列出可用的方式。
sms_login_enabled: bool = False
# 平台管理员:能改系统设置(SMTP 等)。按手机号白名单,不挂工作区角色——
# 系统设置是**跨租户**的,某个工作区的 owner 也不该看到平台的发信配置。
# `NoDecode` + 下面的 before-validator:这个值环境变量里是逗号分隔的一串,
# 不让 pydantic-settings 先按 JSON 解析(那样 `1360...,1390...` 会直接启动失败)。
platform_admin_phones: Annotated[list[str], NoDecode] = Field(
default_factory=lambda: ["13605513683"]
)
# ── 邮件(SMTP)────────────────────────────────────────
# 以「系统设置」页里存的值为准(DB),这里只是首次启动前的兜底。
# 改 SMTP 不该要人登服务器改 env 再重启——所以真配置在 DB 里(见 services/mail.py)。
smtp_host: str | None = None
smtp_port: int = 465
smtp_username: str | None = None
smtp_password: str | None = None
smtp_from_name: str = "达道Task"
# 465 走 SSL,587 走 STARTTLS;126/163 这类用 465 + SSL
smtp_use_ssl: bool = True
smtp_timeout_seconds: int = 10
# ── 幂等 ──────────────────────────────────────────────
idempotency_enabled: bool = True
@@ -117,10 +141,10 @@ class Settings(BaseSettings):
build_commit: str = "unknown"
build_time: str = "unknown"
@field_validator("cors_origins", mode="before")
@field_validator("cors_origins", "platform_admin_phones", mode="before")
@classmethod
def _split_origins(cls, value: object) -> object:
"""支持 CORS_ORIGINS="a,b" 这种写法。"""
"""支持 CORS_ORIGINS="a,b" 这种写法(平台管理员白名单同理)。"""
if isinstance(value, str) and not value.startswith("["):
return [item.strip() for item in value.split(",") if item.strip()]
return value
@@ -134,10 +158,10 @@ class Settings(BaseSettings):
problems.append("JWT_SECRET 仍是开发默认值或长度不足 32")
if "dadao_dev_pw" in self.database_url:
problems.append("DATABASE_URL 仍在用开发默认口令")
if self.sms_provider == "dev" and not self.internal_test_login:
if self.sms_login_enabled and self.sms_provider == "dev":
problems.append(
"生产环境不允许 SMS_PROVIDER=dev(任意验证码可登录);"
"内网试用确需固定验证码时显式设置 INTERNAL_TEST_LOGIN=true"
"短信验证码登录开着(SMS_LOGIN_ENABLED=true),但 SMS_PROVIDER=dev 只会发固定验证码"
"(等于知道手机号就能进);接真短信通道之前请把 SMS_LOGIN_ENABLED 关掉"
)
if problems:
raise RuntimeError("配置校验失败:" + ";".join(problems))
+16
View File
@@ -14,6 +14,7 @@ from fastapi import Depends, Header, Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from .config import get_settings
from .db import bind_tenant, bind_user, get_session
from .errors import AppError, ErrorCode
from .logging_setup import user_id_ctx, workspace_id_ctx
@@ -112,3 +113,18 @@ def require_role(minimum: str):
OwnerDep = Annotated[TenantCtx, Depends(require_role("owner"))]
AdminDep = Annotated[TenantCtx, Depends(require_role("admin"))]
async def platform_admin(user: CurrentUser) -> AuthUser:
"""平台管理员:能改 SMTP 这类**跨租户**的系统设置。
刻意不复用工作区角色:某个工作区的 owner 只说明他管得了自己的工作区,
不代表他能看到整台服务的发信账号(那里面存着别人的授权码)。
名单来自配置 `PLATFORM_ADMIN_PHONES`,换人不用发版。
"""
if user.phone not in get_settings().platform_admin_phones:
raise AppError(ErrorCode.INSUFFICIENT_ROLE, "只有平台管理员能改系统设置")
return user
PlatformAdminDep = Annotated[AuthUser, Depends(platform_admin)]
+8
View File
@@ -29,6 +29,10 @@ class ErrorCode(IntEnum):
PASSWORD_NOT_SET = 2014
PASSWORD_WEAK = 2015
LOGIN_TOO_MANY = 2016
LOGIN_METHOD_DISABLED = 2017
EMAIL_INVALID = 2018
EMAIL_TAKEN = 2019
MAIL_SEND_FAILED = 2020
# 3xxx 租户 / 权限
WORKSPACE_NOT_FOUND = 3001
@@ -94,6 +98,10 @@ _HTTP_STATUS: dict[ErrorCode, int] = {
ErrorCode.PASSWORD_NOT_SET: 400,
ErrorCode.PASSWORD_WEAK: 422,
ErrorCode.LOGIN_TOO_MANY: 429,
ErrorCode.LOGIN_METHOD_DISABLED: 403,
ErrorCode.EMAIL_INVALID: 422,
ErrorCode.EMAIL_TAKEN: 409,
ErrorCode.MAIL_SEND_FAILED: 502,
ErrorCode.WORKSPACE_NOT_FOUND: 404,
ErrorCode.NOT_A_MEMBER: 403,
ErrorCode.INSUFFICIENT_ROLE: 403,
+4 -2
View File
@@ -17,6 +17,7 @@ from starlette.staticfiles import StaticFiles
from starlette.types import Scope
from .api import app_release, attachments, auth, items, knowledge, tickets, workspaces
from .api import settings as settings_api
from .config import get_settings
from .db import SessionFactory, current_app_role, engine
from .errors import AppError, ErrorCode, http_status_for
@@ -31,9 +32,9 @@ logger = get_logger("app")
@asynccontextmanager
async def lifespan(app: FastAPI):
settings.validate_runtime()
if settings.env == "prod" and settings.internal_test_login:
if settings.env == "prod" and settings.sms_login_enabled:
logger.warning(
"内测登录已开启:任意手机号可用固定验证码登录,只能在内网访问,切勿暴露公网",
"短信验证码登录开着,只能配真通道;用 dev 固定码等于知道手机号就能进任何账号",
extra={"extra_fields": {"sms_provider": settings.sms_provider}},
)
logger.info(
@@ -116,6 +117,7 @@ app.include_router(tickets.notify_router, prefix=settings.api_prefix)
app.include_router(knowledge.router, prefix=settings.api_prefix)
app.include_router(attachments.router, prefix=settings.api_prefix)
app.include_router(app_release.router, prefix=settings.api_prefix)
app.include_router(settings_api.router, prefix=settings.api_prefix)
@app.get("/health", tags=["运维"], summary="存活探针")
+19
View File
@@ -47,6 +47,25 @@ class AppUser(Base, Timestamps):
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
# 登录密码(scrypt 串,见 security.hash_password)。为空表示只用验证码登录。
password_hash: Mapped[str | None] = mapped_column(String(255))
# 邮箱:既是「邮箱 + 密码」登录的账号名,也是以后找回密码/发通知的去处。
# 可空(老用户还没有),唯一性由迁移里的 lower(email) 部分唯一索引保证。
email: Mapped[str | None] = mapped_column(String(255))
class SystemSetting(Base, Timestamps):
"""平台级键值设置(SMTP 等),**不挂租户、没有 RLS**。
刻意不做成租户表:发信账号是整台服务共用的,某个工作区的 owner 不该看到
平台的 SMTP 授权码。写权限由 API 层的「平台管理员」依赖把关(见 deps.py)。
"""
__tablename__ = "system_setting"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
updated_by: Mapped[uuid.UUID | None] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL")
)
class Workspace(Base, Timestamps):
+202
View File
@@ -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,
}