Files
DadaoTask/backend/app/services/mail.py
T
Steven b5170ad1ce
CI / backend (push) Canceled after 0s
feat(auth): 邮箱验证码注册,生产重新有了自助入口
短信登录关掉后,生产没有任何注册入口——这一版补上最普通的那条路:
填邮箱、收验证码、设密码,注册完直接进首页。

先发码再注册,不是"注册时顺带发信"

  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 必需的。
2026-11-03 01:04:26 +08:00

224 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""发信(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)