This repository has been archived on 2026-10-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
NeteaseUrl/tests/test_mail_smtp.py
snowy-yang 22b263f547 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 残留
2026-09-29 15:28:13 +08:00

113 lines
4.3 KiB
Python

# -*- 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 里仍关连接