diff --git a/auth_system/__init__.py b/auth_system/__init__.py index 733ac32..2ad5de7 100644 --- a/auth_system/__init__.py +++ b/auth_system/__init__.py @@ -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', diff --git a/auth_system/mail.py b/auth_system/mail.py index d57e271..3aa7855 100644 --- a/auth_system/mail.py +++ b/auth_system/mail.py @@ -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 发送计数(内存态,重启清零可接受) diff --git a/pyproject.toml b/pyproject.toml index 82d6eea..a82b893 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/test_mail_smtp.py b/tests/test_mail_smtp.py new file mode 100644 index 0000000..e43ef8e --- /dev/null +++ b/tests/test_mail_smtp.py @@ -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 里仍关连接 diff --git a/uv.lock b/uv.lock index de702c5..7d4ff31 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, diff --git a/web/routers/register.py b/web/routers/register.py index 51e0be2..43ba76f 100644 --- a/web/routers/register.py +++ b/web/routers/register.py @@ -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`。 - 失败锁定/验证码消费等安全口径逐字保留。 """