Archived
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:
1 parent
e8cc7cb34a
commit
22b263f547
6 files changed
+158
-26
No files matched your search
@@ -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
@@ -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 发送计数(内存态,重启清零可接受)
|
||||
|
||||
@@ -9,6 +9,7 @@ dependencies = [
|
||||
"uvicorn[standard]>=0.30,<1.0",
|
||||
"aiohttp>=3.10,<4.0",
|
||||
"aiosqlite>=0.20,<1.0",
|
||||
"aiosmtplib>=5.1.3,<6.0",
|
||||
"itsdangerous>=2.2,<3.0",
|
||||
"cryptography>=50.0,<51.0",
|
||||
"qrcode>=7.0,<8.3",
|
||||
|
||||
@@ -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 里仍关连接
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "aiosqlite"
|
||||
version = "0.22.1"
|
||||
@@ -1512,6 +1521,7 @@ version = "3.0.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "aiosmtplib" },
|
||||
{ name = "aiosqlite" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "fastapi" },
|
||||
@@ -1536,6 +1546,7 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiohttp", specifier = ">=3.10,<4.0" },
|
||||
{ name = "aiosmtplib", specifier = ">=5.1.3,<6.0" },
|
||||
{ name = "aiosqlite", specifier = ">=0.20,<1.0" },
|
||||
{ name = "cryptography", specifier = ">=50.0,<51.0" },
|
||||
{ name = "fastapi", specifier = ">=0.115,<1.0" },
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
- POST /api/v1/auth/forgot_password ↔ Flask /auth/forgot_password
|
||||
|
||||
说明:
|
||||
- auth_system 已迁移为**原生异步**(aiosqlite),此处直接 await,不再经 asyncio.to_thread
|
||||
桥接;PBKDF2 等 CPU 密集与 smtplib 阻塞发送已在 auth_system 内部就卸载。
|
||||
- auth_system 已迁移为**原生异步**(aiosqlite / aiosmtplib),此处直接 await,不再经
|
||||
asyncio.to_thread 桥接;仅 PBKDF2 这类 CPU 密集且释放 GIL 的活仍在内部卸载到线程。
|
||||
- 发码 IP 限流复用 web.auth_state 的模块级状态 `_SEND_CODE_IP`。
|
||||
- 失败锁定/验证码消费等安全口径逐字保留。
|
||||
"""
|
||||
|
||||
Reference in new issue
Block a user