refactor: SMTP 发信改 aiosmtplib,最后一个阻塞网络调用从 to_thread 里取出来

为什么:smtplib 是同步栈,原先靠 asyncio.to_thread 卸载——线程池里躺着的仍是一次
完整的 TCP + TLS 握手加多轮 SMTP 交互,属于「同步网络 IO 藏在 to_thread 里」:既
占着线程,也不产生并发收益。这是本系列最后一条此类遗留(aria2p 那条已在 C2 清零)。

改动:
- auth_system/mail.py:_smtp_connect / _smtp_send 两个同步函数合成一个原生异步
  _smtp_send,底层换 aiosmtplib.SMTP。TLS 口径与原实现一一对应:465 用 use_tls
  (隐式 TLS),其余端口用 start_tls(显式 STARTTLS,先握手再 LOGIN);两条路都传
  显式构造的 ssl.create_default_context(),不依赖运行时默认校验口径。finally 里
  quit 失败只吞连接关闭错误,sendmail 的真实异常原样上抛——send_verify_code 依赖
  它决定「不写库、不烧 24h 额度」。
- auth_system/mail.py:send_email 改直接 await;移除 import asyncio / import smtplib。
- auth_system/__init__.py:撤掉 _smtp_connect 的再导出——它是同步时代的辅助函数,
  全仓无消费方,新实现里也不存在对应物。
- web/routers/register.py:更新过时的模块说明(原文仍写「smtplib 阻塞发送已卸载」)。
- pyproject.toml / uv.lock:新增 aiosmtplib>=5.1.3,<6.0。
- tests/test_mail_smtp.py(新增 5 条):SMTP 路径此前零覆盖,而「端口 → TLS 方式」
  的映射属「授权码会不会明文过网」级别的差异,一旦被改反不会有任何地方报警。用假
  SMTP 替身记录构造参数与调用序、全程不触网:587 必须 start_tls 且 LOGIN 在 connect
  之后、465 必须 use_tls 且不得再叠 STARTTLS、端口缺省回落 587、quit 失败不掩盖结果、
  sendmail 失败原样上抛且仍关连接。

验证:
- uv run ruff check .  → All checks passed!
- uv run pyright       → 0 errors, 0 warnings, 0 informations
  (aiosmtplib 自带 py.typed,故对它的调用是有效类型校验;另用 inspect 逐一核对了
   5.1.3 的 SMTP / connect / login / sendmail / quit 参数名)
- uv run pytest        → 397 项,0 失败 / 0 error / 0 skipped(C3 后 392,新增 5)
- data/site.db sha256 前后一致(a19f5dbf…6487),无 -wal/-shm 残留
This commit is contained in:
snowy-yang committed 2026-09-29 15:28:13 +08:00
1 parent e8cc7cb34a
commit 22b263f547
6 files changed
+158 -26

No files matched your search

-2
View File
@@ -97,7 +97,6 @@ from .mail import (
_EMAIL_SENT_24H,
_SMTP_ENC_PREFIX,
_code_fails_trim,
_smtp_connect,
_smtp_password_decrypt,
_smtp_password_encrypt,
check_verify_code,
@@ -176,7 +175,6 @@ __all__ = [
'_last_province',
'_normalize_email',
'_q',
'_smtp_connect',
'_smtp_password_decrypt',
'_smtp_password_encrypt',
'_user_public',
+32 -22
View File
@@ -1,15 +1,14 @@
# -*- coding: utf-8 -*-
"""SMTP 配置、发信与邮箱验证码。
(自 auth_system.py 拆出,行为与原实现一致;DB 访问已迁移为原生异步,
阻塞的 smtplib 发信经 asyncio.to_thread 卸载。)
(自 auth_system.py 拆出,行为与原实现一致;DB 访问与 SMTP 发信均已迁移为
原生异步(aiosqlite / aiosmtplib),不再有阻塞调用藏在 to_thread 里。)
"""
import asyncio
import base64
import contextlib
import re
import secrets
import smtplib
import sqlite3
import ssl
import time
@@ -18,6 +17,8 @@ from email.mime.text import MIMEText
from email.utils import formataddr
from typing import Any
import aiosmtplib
from .db import (
_q,
_x,
@@ -93,24 +94,33 @@ async def smtp_configured() -> bool:
return bool(cfg['host'] and cfg['sender'] and cfg['password'])
def _smtp_connect(cfg: dict[str, Any]) -> smtplib.SMTP:
"""连接并登录 SMTP(**阻塞网络 IO**):只应由 asyncio.to_thread 调用。"""
async def _smtp_send(cfg: dict[str, Any], sender: str, to: str, raw: str) -> None:
"""原生异步发信(aiosmtplib)。
465 走隐式 TLS(use_tls),其余端口走显式 STARTTLS(start_tls)——与原先
smtplib 的 SMTP_SSL / starttls 分支一一对应;两条路都用显式构造的 TLS 上下文
校验证书与主机名,不依赖各运行时默认值。原实现是 smtplib 同步栈再经
asyncio.to_thread 卸载,换成 aiosmtplib 后不再占用线程池。
"""
port = int(cfg['port'] or 587)
# 显式校验证书与主机名的 TLS 上下文(不依赖各运行时默认值)
ctx = ssl.create_default_context()
if port == 465:
server = smtplib.SMTP_SSL(cfg['host'], port, timeout=15, context=ctx)
else:
server = smtplib.SMTP(cfg['host'], port, timeout=15)
server.starttls(context=ctx)
server.login(cfg['sender'], cfg['password'])
return server
def _smtp_send(cfg: dict[str, Any], sender: str, to: str, raw: str) -> None:
"""同步发信(阻塞网络 IO):只应由 asyncio.to_thread 调用。"""
with _smtp_connect(cfg) as server:
server.sendmail(sender, [to], raw)
implicit_tls = port == 465
smtp = aiosmtplib.SMTP(
hostname=cfg['host'],
port=port,
timeout=15,
use_tls=implicit_tls,
start_tls=not implicit_tls,
tls_context=ctx,
)
await smtp.connect()
try:
await smtp.login(cfg['sender'], cfg['password'])
await smtp.sendmail(sender, [to], raw)
finally:
# 关连接失败不影响「信已发出」的事实,也绝不能盖掉上面的真实异常
with contextlib.suppress(Exception):
await smtp.quit()
async def send_email(to: str, subject: str, body: str) -> None:
@@ -126,8 +136,8 @@ async def send_email(to: str, subject: str, body: str) -> None:
msg['Subject'] = Header(subject, 'utf-8') # type: ignore[assignment]
msg['From'] = formataddr(('云村小助手', cfg['sender']))
msg['To'] = to
# smtplib 同步发信是阻塞网络 IO,卸载到线程(正当的阻塞网络 IO 卸载,非同步门面)
await asyncio.to_thread(_smtp_send, cfg, cfg['sender'], to, msg.as_string())
# SMTP 已是原生异步(aiosmtplib),直接 await——不再有同步网络 IO 需要卸载
await _smtp_send(cfg, cfg['sender'], to, msg.as_string())
_EMAIL_SENT_24H: dict = {} # 同邮箱 24h 发送计数(内存态,重启清零可接受)