CI / backend (push) Canceled after 0s
短信登录关掉后,生产没有任何注册入口——这一版补上最普通的那条路:
填邮箱、收验证码、设密码,注册完直接进首页。
先发码再注册,不是"注册时顺带发信"
POST /auth/email/code 只做三件事:查重、限流、发码。
填完密码才被告知"这个邮箱注册过了"最气人,所以占用(2019)在发码前就回。
邮箱既是账号名也是唯一联系方式,所以必须真验一次码
验证通过时间落 app_user.email_verified_at,/auth/me 回 email_verified。
"随手填一个"会让账号变成没人能找回的孤岛。
app_user.phone 改为可空(迁移 0011)
邮箱注册的账号本来就没有手机号。user.phone 对这类账号回空串,
成员列表同理,不再伪装成一串星号。
防刷
同邮箱 60 秒一次(2022)、验证码 10 分钟有效、错 5 次即作废(2021)。
6 位数可以被刮,不设上限等于把注册口子交给脚本。
本机联调开关 EMAIL_CODE_DEV_ECHO
为 true 时把码回给调用方且不发真信——否则每跑一次 e2e 就往真实收件箱塞一封。
生产打开会让 validate_runtime() 拒绝启动。
前端
新增 /register(邮箱、验证码、密码、确认、昵称;60 秒倒计时;
服务端关着注册时整页只说"联系管理员开通"),登录页按 /auth/methods
决定要不要显示「还没有账号?用邮箱注册」。
验证
pytest 139 passed、ruff check / format 全绿
tsc --noEmit 0、vite build 通过
e2e register.mjs 7 项:登录页入口 → 拿码 → 注册落进首页 → 退出用邮箱+密码登回来
→ 同一邮箱再发码回 2019 → 控制台零报错
e2e login-email-smtp.mjs 15 项回归通过(含真发一封测试信)
e2e account.mjs 14 项、knowledge.mjs 全绿
顺带修的两处测试
test_account 的字段白名单补 email_verified(接口确实多了这个字段);
test_config_prod 用 Settings(_env_file=None, ...) 构造,别被本机 .env 里的
EMAIL_CODE_DEV_ECHO=true 带偏——那个开关在生产是硬错误,本机却是跑 e2e 必需的。
224 lines
8.2 KiB
Python
224 lines
8.2 KiB
Python
"""发信(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,
|
||
}
|
||
|
||
|
||
async def send_verify_code(session: AsyncSession, *, to: str, code: str, minutes: int) -> None:
|
||
"""发注册/验证用的数字验证码。
|
||
|
||
模板写得朴素但说清楚了"谁在什么地方注册"和"不是你就算了"——收件箱里躺着一条
|
||
没头没尾的六位数,用户只会当成钓鱼。
|
||
"""
|
||
text = (
|
||
f"你的达道Task验证码是 {code}\n\n"
|
||
f"{minutes} 分钟内有效。如果不是本人操作,忽略这封邮件即可,账号不会有任何变化。"
|
||
)
|
||
html = (
|
||
'<div style="font:14px/1.7 -apple-system,Segoe UI,sans-serif;color:#1f2a28">'
|
||
"<p>你的达道Task验证码是:</p>"
|
||
f'<p style="font:600 28px/1.4 ui-monospace,Menlo,Consolas,monospace;'
|
||
f'letter-spacing:6px;color:#2f6054">{code}</p>'
|
||
f"<p>{minutes} 分钟内有效。如果不是本人操作,忽略这封邮件即可,账号不会有任何变化。</p>"
|
||
"</div>"
|
||
)
|
||
await send_mail(session, to=to, subject="达道Task 验证码", text=text, html=html)
|