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, _EMAIL_SENT_24H,
_SMTP_ENC_PREFIX, _SMTP_ENC_PREFIX,
_code_fails_trim, _code_fails_trim,
_smtp_connect,
_smtp_password_decrypt, _smtp_password_decrypt,
_smtp_password_encrypt, _smtp_password_encrypt,
check_verify_code, check_verify_code,
@@ -176,7 +175,6 @@ __all__ = [
'_last_province', '_last_province',
'_normalize_email', '_normalize_email',
'_q', '_q',
'_smtp_connect',
'_smtp_password_decrypt', '_smtp_password_decrypt',
'_smtp_password_encrypt', '_smtp_password_encrypt',
'_user_public', '_user_public',
+32 -22
View File
@@ -1,15 +1,14 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""SMTP 配置、发信与邮箱验证码。 """SMTP 配置、发信与邮箱验证码。
(自 auth_system.py 拆出,行为与原实现一致;DB 访问已迁移为原生异步, (自 auth_system.py 拆出,行为与原实现一致;DB 访问与 SMTP 发信均已迁移为
阻塞的 smtplib 发信经 asyncio.to_thread 卸载。) 原生异步(aiosqlite / aiosmtplib),不再有阻塞调用藏在 to_thread 里。)
""" """
import asyncio
import base64 import base64
import contextlib
import re import re
import secrets import secrets
import smtplib
import sqlite3 import sqlite3
import ssl import ssl
import time import time
@@ -18,6 +17,8 @@ from email.mime.text import MIMEText
from email.utils import formataddr from email.utils import formataddr
from typing import Any from typing import Any
import aiosmtplib
from .db import ( from .db import (
_q, _q,
_x, _x,
@@ -93,24 +94,33 @@ async def smtp_configured() -> bool:
return bool(cfg['host'] and cfg['sender'] and cfg['password']) return bool(cfg['host'] and cfg['sender'] and cfg['password'])
def _smtp_connect(cfg: dict[str, Any]) -> smtplib.SMTP: async def _smtp_send(cfg: dict[str, Any], sender: str, to: str, raw: str) -> None:
"""连接并登录 SMTP(**阻塞网络 IO**):只应由 asyncio.to_thread 调用。""" """原生异步发信(aiosmtplib)。
465 走隐式 TLS(use_tls),其余端口走显式 STARTTLS(start_tls)——与原先
smtplib 的 SMTP_SSL / starttls 分支一一对应;两条路都用显式构造的 TLS 上下文
校验证书与主机名,不依赖各运行时默认值。原实现是 smtplib 同步栈再经
asyncio.to_thread 卸载,换成 aiosmtplib 后不再占用线程池。
"""
port = int(cfg['port'] or 587) port = int(cfg['port'] or 587)
# 显式校验证书与主机名的 TLS 上下文(不依赖各运行时默认值)
ctx = ssl.create_default_context() ctx = ssl.create_default_context()
if port == 465: implicit_tls = port == 465
server = smtplib.SMTP_SSL(cfg['host'], port, timeout=15, context=ctx) smtp = aiosmtplib.SMTP(
else: hostname=cfg['host'],
server = smtplib.SMTP(cfg['host'], port, timeout=15) port=port,
server.starttls(context=ctx) timeout=15,
server.login(cfg['sender'], cfg['password']) use_tls=implicit_tls,
return server start_tls=not implicit_tls,
tls_context=ctx,
)
def _smtp_send(cfg: dict[str, Any], sender: str, to: str, raw: str) -> None: await smtp.connect()
"""同步发信(阻塞网络 IO):只应由 asyncio.to_thread 调用。""" try:
with _smtp_connect(cfg) as server: await smtp.login(cfg['sender'], cfg['password'])
server.sendmail(sender, [to], raw) 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: 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['Subject'] = Header(subject, 'utf-8') # type: ignore[assignment]
msg['From'] = formataddr(('云村小助手', cfg['sender'])) msg['From'] = formataddr(('云村小助手', cfg['sender']))
msg['To'] = to msg['To'] = to
# smtplib 同步发信是阻塞网络 IO,卸载到线程(正当的阻塞网络 IO 卸载,非同步门面) # SMTP 已是原生异步(aiosmtplib),直接 await——不再有同步网络 IO 需要卸载
await asyncio.to_thread(_smtp_send, cfg, cfg['sender'], to, msg.as_string()) await _smtp_send(cfg, cfg['sender'], to, msg.as_string())
_EMAIL_SENT_24H: dict = {} # 同邮箱 24h 发送计数(内存态,重启清零可接受) _EMAIL_SENT_24H: dict = {} # 同邮箱 24h 发送计数(内存态,重启清零可接受)
+1
View File
@@ -9,6 +9,7 @@ dependencies = [
"uvicorn[standard]>=0.30,<1.0", "uvicorn[standard]>=0.30,<1.0",
"aiohttp>=3.10,<4.0", "aiohttp>=3.10,<4.0",
"aiosqlite>=0.20,<1.0", "aiosqlite>=0.20,<1.0",
"aiosmtplib>=5.1.3,<6.0",
"itsdangerous>=2.2,<3.0", "itsdangerous>=2.2,<3.0",
"cryptography>=50.0,<51.0", "cryptography>=50.0,<51.0",
"qrcode>=7.0,<8.3", "qrcode>=7.0,<8.3",
+112
View File
@@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
"""SMTP 发送路径回归:钉住「端口 → TLS 方式」的映射。
为什么值得单独钉:587/25 上必须先 STARTTLS 再 LOGIN,否则 SMTP 授权码会明文过网;
465 则是连上即握手(隐式 TLS)。这两种映射一旦被改反,除了本文件之外没有任何地方
会报警——而它是「凭据是否明文出网」级别的差异。
本测试用假 SMTP 替身记录构造参数与调用顺序,全程不触网(也就不会因外部 SMTP
不可达而失败)。
"""
import ssl
from typing import Any
import pytest
from auth_system import mail
class _FakeSMTP:
"""只记录行为、不做任何网络动作的 aiosmtplib.SMTP 替身。"""
instances: list['_FakeSMTP'] = []
def __init__(self, **kwargs: Any) -> None:
self.kwargs: dict[str, Any] = kwargs
self.calls: list[Any] = []
_FakeSMTP.instances.append(self)
async def connect(self) -> None:
self.calls.append('connect')
async def login(self, username: str, password: str) -> None:
self.calls.append(('login', username, password))
async def sendmail(self, sender: str, recipients: list[str], message: str) -> None:
self.calls.append(('sendmail', sender, tuple(recipients), message))
async def quit(self) -> None:
self.calls.append('quit')
@pytest.fixture()
def fake_smtp(monkeypatch: pytest.MonkeyPatch) -> type[_FakeSMTP]:
_FakeSMTP.instances.clear()
monkeypatch.setattr(mail.aiosmtplib, 'SMTP', _FakeSMTP)
return _FakeSMTP
CFG: dict[str, Any] = {
'host': 'smtp.example.com', 'port': 587,
'sender': 'a@example.com', 'password': 'pwd',
}
def _step_name(call: Any) -> str:
return call if isinstance(call, str) else call[0]
async def test_port_587_uses_starttls_before_login(fake_smtp: type[_FakeSMTP]) -> None:
"""587:显式 STARTTLS,且必须握手完成之后才发 LOGIN(凭据不得先出网)。"""
await mail._smtp_send(CFG, CFG['sender'], 'to@example.com', 'Raw: x')
(s,) = fake_smtp.instances
assert s.kwargs['port'] == 587
assert s.kwargs['use_tls'] is False
assert s.kwargs['start_tls'] is True
# 显式构造的 TLS 上下文——不依赖 aiosmtplib / 运行时的默认校验口径
assert isinstance(s.kwargs['tls_context'], ssl.SSLContext)
assert s.kwargs['timeout'] == 15
assert [_step_name(c) for c in s.calls] == ['connect', 'login', 'sendmail', 'quit']
async def test_port_465_uses_implicit_tls(fake_smtp: type[_FakeSMTP]) -> None:
"""465:隐式 TLS(连上即握手),不能再叠一次 STARTTLS。"""
cfg = dict(CFG, port=465)
await mail._smtp_send(cfg, cfg['sender'], 'to@example.com', 'Raw: x')
(s,) = fake_smtp.instances
assert s.kwargs['port'] == 465
assert s.kwargs['use_tls'] is True
assert s.kwargs['start_tls'] is False
async def test_missing_port_falls_back_to_587(fake_smtp: type[_FakeSMTP]) -> None:
"""端口缺省/为空时回落到 587,并仍走 STARTTLS 分支。"""
cfg = dict(CFG, port='')
await mail._smtp_send(cfg, cfg['sender'], 'to@example.com', 'Raw: x')
(s,) = fake_smtp.instances
assert s.kwargs['port'] == 587
assert s.kwargs['start_tls'] is True
async def test_quit_failure_does_not_mask_send_result(
fake_smtp: type[_FakeSMTP], monkeypatch: pytest.MonkeyPatch) -> None:
"""关连接失败不影响「信已发出」的结论,更不得盖掉真实异常。"""
async def _boom(self: _FakeSMTP) -> None:
raise OSError('connection already closed')
monkeypatch.setattr(_FakeSMTP, 'quit', _boom)
await mail._smtp_send(CFG, CFG['sender'], 'to@example.com', 'Raw: x') # 不应抛出
async def test_send_failure_propagates_and_still_quits(
fake_smtp: type[_FakeSMTP], monkeypatch: pytest.MonkeyPatch) -> None:
"""发信失败必须原样上抛(send_verify_code 依赖它决定「不写库不入额度」)。"""
async def _boom(self: _FakeSMTP, sender: str, recipients: list[str], message: str) -> None:
raise RuntimeError('smtp rejected')
monkeypatch.setattr(_FakeSMTP, 'sendmail', _boom)
with pytest.raises(RuntimeError, match='smtp rejected'):
await mail._smtp_send(CFG, CFG['sender'], 'to@example.com', 'Raw: x')
(s,) = fake_smtp.instances
assert 'quit' in [_step_name(c) for c in s.calls] # finally 里仍关连接
Generated
+11
View File
@@ -124,6 +124,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
] ]
[[package]]
name = "aiosmtplib"
version = "5.1.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9b/5c/9cabc5db6d607616e81ba6d8f1f231cd5a75955807a308c1090a59072d6d/aiosmtplib-5.1.3.tar.gz", hash = "sha256:ac2b418d3260ba62d9cfd0fe7359726e9dc009a4e8e8d9909fdfae332f522a7c", size = 77010, upload-time = "2026-09-08T02:11:20.532Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/0a/b56ab8163d54960337fdca475d3dfd56c8badf6172e79cf2ad00d5335dc1/aiosmtplib-5.1.3-py3-none-any.whl", hash = "sha256:f7d76ce3d4995a65a178c1f11e1bd1607706b921d00cb768e7a2c7f7ef5517a8", size = 30116, upload-time = "2026-09-08T02:11:19.352Z" },
]
[[package]] [[package]]
name = "aiosqlite" name = "aiosqlite"
version = "0.22.1" version = "0.22.1"
@@ -1512,6 +1521,7 @@ version = "3.0.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
{ name = "aiosmtplib" },
{ name = "aiosqlite" }, { name = "aiosqlite" },
{ name = "cryptography" }, { name = "cryptography" },
{ name = "fastapi" }, { name = "fastapi" },
@@ -1536,6 +1546,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aiohttp", specifier = ">=3.10,<4.0" }, { name = "aiohttp", specifier = ">=3.10,<4.0" },
{ name = "aiosmtplib", specifier = ">=5.1.3,<6.0" },
{ name = "aiosqlite", specifier = ">=0.20,<1.0" }, { name = "aiosqlite", specifier = ">=0.20,<1.0" },
{ name = "cryptography", specifier = ">=50.0,<51.0" }, { name = "cryptography", specifier = ">=50.0,<51.0" },
{ name = "fastapi", specifier = ">=0.115,<1.0" }, { name = "fastapi", specifier = ">=0.115,<1.0" },
+2 -2
View File
@@ -9,8 +9,8 @@
- POST /api/v1/auth/forgot_password ↔ Flask /auth/forgot_password - POST /api/v1/auth/forgot_password ↔ Flask /auth/forgot_password
说明: 说明:
- auth_system 已迁移为**原生异步**(aiosqlite),此处直接 await,不再经 asyncio.to_thread - auth_system 已迁移为**原生异步**(aiosqlite / aiosmtplib),此处直接 await,不再经
桥接;PBKDF2 等 CPU 密集与 smtplib 阻塞发送已在 auth_system 内部就卸载。 asyncio.to_thread 桥接;仅 PBKDF2 这类 CPU 密集且释放 GIL 的活仍在内部卸载到线程。
- 发码 IP 限流复用 web.auth_state 的模块级状态 `_SEND_CODE_IP`。 - 发码 IP 限流复用 web.auth_state 的模块级状态 `_SEND_CODE_IP`。
- 失败锁定/验证码消费等安全口径逐字保留。 - 失败锁定/验证码消费等安全口径逐字保留。
""" """