更新
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
"""发送邮箱验证邮件(标准库 SMTP)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import smtplib
|
||||
import ssl
|
||||
from dataclasses import asdict
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Any
|
||||
|
||||
from .email_templates import build_password_reset_content, build_test_email_content, build_verify_email_content
|
||||
from .system_settings import SystemSettingsData
|
||||
|
||||
logger = logging.getLogger("auth.email")
|
||||
|
||||
SMTP_TIMEOUT_SECONDS = 45
|
||||
# 阿里企业邮自定义域名 SMTP(如 smtp.xxiaw.com)常出现 535,官方地址可正常认证
|
||||
ALIBABA_SMTP_OFFICIAL = "smtp.mxhichina.com"
|
||||
_SSL_CONTEXT = ssl.create_default_context()
|
||||
|
||||
|
||||
def _ssl_context(*, insecure: bool = False) -> ssl.SSLContext:
|
||||
if not insecure:
|
||||
return _SSL_CONTEXT
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return ctx
|
||||
|
||||
|
||||
def _connect_ssl(host: str, port: int) -> smtplib.SMTP:
|
||||
try:
|
||||
return smtplib.SMTP_SSL(
|
||||
host,
|
||||
port,
|
||||
timeout=SMTP_TIMEOUT_SECONDS,
|
||||
context=_ssl_context(insecure=False),
|
||||
)
|
||||
except ssl.SSLCertVerificationError:
|
||||
logger.warning(
|
||||
"SMTP SSL certificate hostname mismatch for %s, retry without verify",
|
||||
host,
|
||||
)
|
||||
return smtplib.SMTP_SSL(
|
||||
host,
|
||||
port,
|
||||
timeout=SMTP_TIMEOUT_SECONDS,
|
||||
context=_ssl_context(insecure=True),
|
||||
)
|
||||
|
||||
|
||||
def _starttls(server: smtplib.SMTP, host: str) -> None:
|
||||
try:
|
||||
server.starttls(context=_ssl_context(insecure=False))
|
||||
except ssl.SSLCertVerificationError:
|
||||
logger.warning(
|
||||
"SMTP STARTTLS certificate hostname mismatch for %s, retry without verify",
|
||||
host,
|
||||
)
|
||||
server.starttls(context=_ssl_context(insecure=True))
|
||||
|
||||
|
||||
def build_verification_link(token: str, settings: SystemSettingsData) -> str:
|
||||
return f"{settings.app_url_normalized()}/#/login?verify_token={token}"
|
||||
|
||||
|
||||
def build_password_reset_link(token: str, settings: SystemSettingsData) -> str:
|
||||
return f"{settings.app_url_normalized()}/#/login?reset_token={token}"
|
||||
|
||||
|
||||
def resolve_smtp_mode(settings: SystemSettingsData) -> tuple[int, bool, bool]:
|
||||
"""返回 (port, use_ssl, use_starttls)。"""
|
||||
port = int(settings.smtp_port or 587)
|
||||
use_ssl = bool(settings.smtp_use_ssl) or port == 465
|
||||
# 465 只能 implicit SSL,绝不能 plain + STARTTLS
|
||||
if port == 465:
|
||||
use_ssl = True
|
||||
use_starttls = False
|
||||
elif port == 587:
|
||||
use_ssl = False
|
||||
use_starttls = bool(settings.smtp_use_tls)
|
||||
else:
|
||||
use_ssl = bool(settings.smtp_use_ssl)
|
||||
use_starttls = bool(settings.smtp_use_tls) and not use_ssl
|
||||
return port, use_ssl, use_starttls
|
||||
|
||||
|
||||
def _open_smtp(settings: SystemSettingsData) -> smtplib.SMTP:
|
||||
host = settings.smtp_host.strip()
|
||||
port, use_ssl, use_starttls = resolve_smtp_mode(settings)
|
||||
logger.info(
|
||||
"SMTP connect host=%s port=%s ssl=%s starttls=%s user=%s",
|
||||
host,
|
||||
port,
|
||||
use_ssl,
|
||||
use_starttls,
|
||||
settings.smtp_user or "",
|
||||
)
|
||||
|
||||
if use_ssl:
|
||||
server = _connect_ssl(host, port)
|
||||
server.ehlo()
|
||||
return server
|
||||
|
||||
server = smtplib.SMTP(host, port, timeout=SMTP_TIMEOUT_SECONDS)
|
||||
server.ehlo()
|
||||
if use_starttls:
|
||||
_starttls(server, host)
|
||||
server.ehlo()
|
||||
return server
|
||||
|
||||
|
||||
def _close_smtp(server: smtplib.SMTP) -> None:
|
||||
try:
|
||||
server.quit()
|
||||
except Exception:
|
||||
try:
|
||||
server.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _login_smtp(server: smtplib.SMTP, settings: SystemSettingsData) -> tuple[smtplib.SMTP, bool]:
|
||||
"""登录 SMTP,返回 (server, 是否使用了阿里官方地址回退)。"""
|
||||
user = (settings.smtp_user or "").strip()
|
||||
pwd = settings.smtp_password
|
||||
if not user:
|
||||
return server, False
|
||||
if not pwd:
|
||||
raise RuntimeError("未填写 SMTP 密码,请在设置中填写登录密码或客户端安全密码后重试")
|
||||
try:
|
||||
server.login(user, pwd)
|
||||
return server, False
|
||||
except smtplib.SMTPAuthenticationError as exc:
|
||||
host = (settings.smtp_host or "").strip().lower()
|
||||
if host == ALIBABA_SMTP_OFFICIAL:
|
||||
raise RuntimeError(_format_login_error(exc, settings)) from exc
|
||||
logger.warning(
|
||||
"SMTP auth failed on %s (%s), retrying via %s",
|
||||
host,
|
||||
exc,
|
||||
ALIBABA_SMTP_OFFICIAL,
|
||||
)
|
||||
_close_smtp(server)
|
||||
fallback = SystemSettingsData(**{**asdict(settings), "smtp_host": ALIBABA_SMTP_OFFICIAL})
|
||||
fallback_server = _open_smtp(fallback)
|
||||
try:
|
||||
fallback_server.login(user, pwd)
|
||||
except Exception as retry_exc:
|
||||
_close_smtp(fallback_server)
|
||||
raise RuntimeError(_format_login_error(exc, settings)) from retry_exc
|
||||
return fallback_server, True
|
||||
|
||||
|
||||
def _format_login_error(exc: Exception, settings: SystemSettingsData) -> str:
|
||||
if isinstance(exc, smtplib.SMTPAuthenticationError):
|
||||
return _format_smtp_error(exc, settings)
|
||||
if isinstance(exc, smtplib.SMTPServerDisconnected):
|
||||
return (
|
||||
"SMTP 服务器在登录时断开连接,通常不是加密方式问题,而是密码错误或账号未授权 SMTP。"
|
||||
f"请确认用户名 {settings.smtp_user or ''} 与密码/客户端安全密码正确,"
|
||||
"并在阿里企业邮后台开启「允许使用第三方客户端」。"
|
||||
"若使用自定义域名 SMTP(如 smtp.xxiaw.com)仍失败,可改用 smtp.mxhichina.com 后重试。"
|
||||
)
|
||||
return _format_smtp_error(exc, settings)
|
||||
|
||||
|
||||
def _format_smtp_error(exc: Exception, settings: SystemSettingsData) -> str:
|
||||
msg = str(exc).strip() or exc.__class__.__name__
|
||||
port, use_ssl, use_starttls = resolve_smtp_mode(settings)
|
||||
hints: list[str] = []
|
||||
|
||||
if isinstance(exc, ssl.SSLCertVerificationError):
|
||||
hints.append(
|
||||
f"SSL 证书域名与 {settings.smtp_host} 不匹配(阿里企业邮自定义域名常见),"
|
||||
"系统已尝试跳过证书校验;若仍失败请改用 smtp.mxhichina.com"
|
||||
)
|
||||
return f"{msg}。{' '.join(hints)}"
|
||||
|
||||
if isinstance(exc, smtplib.SMTPAuthenticationError):
|
||||
host = (settings.smtp_host or "").strip().lower()
|
||||
if host != ALIBABA_SMTP_OFFICIAL and host.startswith("smtp."):
|
||||
hints.append(
|
||||
f"若使用自定义域名 SMTP({settings.smtp_host}),阿里企业邮常返回 535;"
|
||||
f"请将 SMTP 服务器改为 {ALIBABA_SMTP_OFFICIAL} 后重试"
|
||||
)
|
||||
hints.append(
|
||||
"SMTP 登录失败:请确认登录用户名为完整邮箱地址,"
|
||||
"密码为邮箱登录密码或阿里企业邮「客户端安全密码」(非网页登录密码时需在邮箱设置中单独生成)"
|
||||
)
|
||||
hints.append("请在阿里企业邮管理后台确认已开启「允许使用第三方客户端」")
|
||||
return f"{msg}。{' '.join(hints)}"
|
||||
|
||||
lower = msg.lower()
|
||||
if "connection unexpectedly closed" in lower and port == 465 and not use_ssl:
|
||||
hints.append("465 端口必须使用 SSL 加密,不能勾选 STARTTLS")
|
||||
elif "connection unexpectedly closed" in lower and port == 465:
|
||||
if use_ssl:
|
||||
hints.append(
|
||||
"连接已建立但在后续步骤失败:请重点检查 SMTP 密码/客户端安全密码,"
|
||||
"或尝试将服务器改为 smtp.mxhichina.com"
|
||||
)
|
||||
else:
|
||||
hints.append("465 端口必须使用 SSL 加密,请选择「SSL / TLS(端口 465)」")
|
||||
if "timed out" in lower or "timeout" in lower or "10060" in lower:
|
||||
if port == 587:
|
||||
hints.append(
|
||||
f"587 端口 STARTTLS 连接 {settings.smtp_host} 超时:"
|
||||
"该服务器可能仅支持 465 SSL,请将端口改为 465 并选择 SSL 加密"
|
||||
)
|
||||
else:
|
||||
hints.append(
|
||||
f"无法在 {SMTP_TIMEOUT_SECONDS}s 内连接 {settings.smtp_host}:{port},"
|
||||
"请检查服务器地址、端口、防火墙/安全组是否放行出站 SMTP"
|
||||
)
|
||||
if "authentication" in lower or "535" in lower:
|
||||
hints.append("认证失败:请检查 SMTP 密码/客户端安全密码是否正确")
|
||||
|
||||
if hints:
|
||||
return f"{msg}。{' '.join(hints)}"
|
||||
return msg
|
||||
|
||||
|
||||
def diagnose_smtp(settings: SystemSettingsData) -> dict[str, Any]:
|
||||
"""分步检测 SMTP 连接,便于定位配置问题。"""
|
||||
host = (settings.smtp_host or "").strip()
|
||||
port, use_ssl, use_starttls = resolve_smtp_mode(settings)
|
||||
result: dict[str, Any] = {
|
||||
"host": host,
|
||||
"port": port,
|
||||
"mode": "ssl" if use_ssl else ("starttls" if use_starttls else "plain"),
|
||||
"steps": [],
|
||||
"ok": False,
|
||||
}
|
||||
if not host:
|
||||
result["steps"].append({"step": "config", "ok": False, "message": "未填写 SMTP 服务器"})
|
||||
return result
|
||||
|
||||
server: smtplib.SMTP | None = None
|
||||
try:
|
||||
server = _open_smtp(settings)
|
||||
result["steps"].append({"step": "connect", "ok": True, "message": "连接成功"})
|
||||
except Exception as exc:
|
||||
result["steps"].append(
|
||||
{"step": "connect", "ok": False, "message": _format_smtp_error(exc, settings)}
|
||||
)
|
||||
return result
|
||||
|
||||
try:
|
||||
if settings.smtp_user:
|
||||
if not settings.smtp_password:
|
||||
raise RuntimeError("未填写 SMTP 密码,请在设置中填写登录密码或客户端安全密码后重试")
|
||||
host_before = (settings.smtp_host or "").strip().lower()
|
||||
server, used_fallback = _login_smtp(server, settings)
|
||||
login_msg = "登录成功"
|
||||
if used_fallback and host_before != ALIBABA_SMTP_OFFICIAL:
|
||||
login_msg = (
|
||||
f"登录成功(已通过 {ALIBABA_SMTP_OFFICIAL} 认证,"
|
||||
f"建议将 SMTP 服务器改为 {ALIBABA_SMTP_OFFICIAL})"
|
||||
)
|
||||
result["steps"].append({"step": "login", "ok": True, "message": login_msg})
|
||||
else:
|
||||
result["steps"].append({"step": "login", "ok": True, "message": "未配置用户名,跳过登录"})
|
||||
result["ok"] = True
|
||||
except Exception as exc:
|
||||
result["steps"].append(
|
||||
{"step": "login", "ok": False, "message": _format_login_error(exc, settings)}
|
||||
)
|
||||
finally:
|
||||
if server:
|
||||
try:
|
||||
server.quit()
|
||||
except Exception:
|
||||
server.close()
|
||||
return result
|
||||
|
||||
|
||||
def _send_sync(
|
||||
settings: SystemSettingsData,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
html_body: str,
|
||||
text_body: str,
|
||||
) -> None:
|
||||
if not settings.smtp_configured():
|
||||
raise RuntimeError("SMTP 未配置,无法发送邮件")
|
||||
|
||||
smtp_from = (settings.smtp_from or settings.smtp_user or "").strip()
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = smtp_from
|
||||
msg["To"] = to_email
|
||||
msg.attach(MIMEText(text_body, "plain", "utf-8"))
|
||||
msg.attach(MIMEText(html_body, "html", "utf-8"))
|
||||
|
||||
server = _open_smtp(settings)
|
||||
try:
|
||||
if settings.smtp_user:
|
||||
try:
|
||||
server, _ = _login_smtp(server, settings)
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise RuntimeError(_format_login_error(exc, settings)) from exc
|
||||
server.sendmail(smtp_from, [to_email], msg.as_string())
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise RuntimeError(_format_smtp_error(exc, settings)) from exc
|
||||
finally:
|
||||
try:
|
||||
server.quit()
|
||||
except Exception:
|
||||
server.close()
|
||||
|
||||
|
||||
async def send_password_reset_email(
|
||||
to_email: str,
|
||||
username: str,
|
||||
token: str,
|
||||
settings: SystemSettingsData,
|
||||
) -> str:
|
||||
"""发送密码重置邮件,返回重置链接。"""
|
||||
link = build_password_reset_link(token, settings)
|
||||
expire_hours = int(settings.email_verify_token_hours or 24)
|
||||
subject, text_body, html_body = build_password_reset_content(
|
||||
settings, username, to_email, link, expire_hours
|
||||
)
|
||||
|
||||
if settings.smtp_configured():
|
||||
await asyncio.to_thread(_send_sync, settings, to_email, subject, html_body, text_body)
|
||||
logger.info("Password reset email sent to %s", to_email)
|
||||
else:
|
||||
logger.warning(
|
||||
"SMTP 未配置,密码重置链接: user=%s email=%s link=%s",
|
||||
username,
|
||||
to_email,
|
||||
link,
|
||||
)
|
||||
return link
|
||||
|
||||
|
||||
async def send_verification_email(
|
||||
to_email: str,
|
||||
username: str,
|
||||
token: str,
|
||||
settings: SystemSettingsData,
|
||||
) -> str:
|
||||
"""发送验证邮件,返回验证链接。"""
|
||||
link = build_verification_link(token, settings)
|
||||
subject, text_body, html_body = build_verify_email_content(
|
||||
settings, username, to_email, link
|
||||
)
|
||||
|
||||
if settings.smtp_configured():
|
||||
await asyncio.to_thread(_send_sync, settings, to_email, subject, html_body, text_body)
|
||||
logger.info("Verification email sent to %s", to_email)
|
||||
else:
|
||||
logger.warning(
|
||||
"SMTP 未配置,验证链接: user=%s email=%s link=%s",
|
||||
username,
|
||||
to_email,
|
||||
link,
|
||||
)
|
||||
return link
|
||||
|
||||
|
||||
async def send_test_email(to_email: str, settings: SystemSettingsData) -> None:
|
||||
diagnosis = await asyncio.to_thread(diagnose_smtp, settings)
|
||||
if not diagnosis.get("ok"):
|
||||
failed = next((s for s in diagnosis.get("steps", []) if not s.get("ok")), None)
|
||||
raise RuntimeError(failed["message"] if failed else "SMTP 连接检测失败")
|
||||
|
||||
subject, text_body, html_body = build_test_email_content(settings, to_email)
|
||||
await asyncio.to_thread(_send_sync, settings, to_email, subject, html_body, text_body)
|
||||
Reference in New Issue
Block a user