mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-12 04:28:28 +08:00
refactor: 重构邮箱验证模块并修复代码审查问题
- 重构: 将 verification 模块重命名为 email,目录结构更清晰 - 新增: 独立的邮件配置管理页面 (EmailSettings.vue) - 新增: 邮件模板管理功能(支持自定义 HTML 模板和预览) - 新增: 查询验证状态 API,支持页面刷新后恢复验证流程 - 新增: 注册邮箱后缀白名单/黑名单限制功能 - 修复: 统一密码最小长度为 6 位(前后端一致) - 修复: SMTP 连接添加 30 秒超时配置,防止 worker 挂起 - 修复: 邮件模板变量添加 HTML 转义,防止 XSS - 修复: 验证状态清除改为 db.commit 后执行,避免竞态条件 - 优化: RegisterDialog 重写验证码输入组件,提升用户体验 - 优化: Input 组件支持 disableAutofill 属性
This commit is contained in:
9
src/services/email/__init__.py
Normal file
9
src/services/email/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
邮箱验证服务模块
|
||||
"""
|
||||
|
||||
from .email_sender import EmailSenderService
|
||||
from .email_template import EmailTemplate
|
||||
from .email_verification import EmailVerificationService
|
||||
|
||||
__all__ = ["EmailVerificationService", "EmailSenderService", "EmailTemplate"]
|
||||
473
src/services/email/email_sender.py
Normal file
473
src/services/email/email_sender.py
Normal file
@@ -0,0 +1,473 @@
|
||||
"""
|
||||
邮件发送服务
|
||||
提供 SMTP 邮件发送功能
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Optional, Tuple
|
||||
|
||||
try:
|
||||
import aiosmtplib
|
||||
|
||||
AIOSMTPLIB_AVAILABLE = True
|
||||
except ImportError:
|
||||
AIOSMTPLIB_AVAILABLE = False
|
||||
aiosmtplib = None
|
||||
|
||||
|
||||
def _create_ssl_context() -> ssl.SSLContext:
|
||||
"""创建 SSL 上下文,使用 certifi 证书或系统默认证书"""
|
||||
try:
|
||||
import certifi
|
||||
|
||||
context = ssl.create_default_context(cafile=certifi.where())
|
||||
except ImportError:
|
||||
context = ssl.create_default_context()
|
||||
return context
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.core.crypto import crypto_service
|
||||
from src.core.logger import logger
|
||||
from src.services.system.config import SystemConfigService
|
||||
|
||||
from .email_template import EmailTemplate
|
||||
|
||||
|
||||
class EmailSenderService:
|
||||
"""邮件发送服务"""
|
||||
|
||||
# SMTP 超时配置(秒)
|
||||
SMTP_TIMEOUT = 30
|
||||
|
||||
@staticmethod
|
||||
def _get_smtp_config(db: Session) -> dict:
|
||||
"""
|
||||
从数据库获取 SMTP 配置
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
SMTP 配置字典
|
||||
"""
|
||||
# 获取加密的密码并解密
|
||||
encrypted_password = SystemConfigService.get_config(db, "smtp_password")
|
||||
smtp_password = None
|
||||
if encrypted_password:
|
||||
try:
|
||||
smtp_password = crypto_service.decrypt(encrypted_password, silent=True)
|
||||
except Exception:
|
||||
# 解密失败,可能是旧的未加密密码,直接使用
|
||||
smtp_password = encrypted_password
|
||||
|
||||
config = {
|
||||
"smtp_host": SystemConfigService.get_config(db, "smtp_host"),
|
||||
"smtp_port": SystemConfigService.get_config(db, "smtp_port", default=587),
|
||||
"smtp_user": SystemConfigService.get_config(db, "smtp_user"),
|
||||
"smtp_password": smtp_password,
|
||||
"smtp_use_tls": SystemConfigService.get_config(db, "smtp_use_tls", default=True),
|
||||
"smtp_use_ssl": SystemConfigService.get_config(db, "smtp_use_ssl", default=False),
|
||||
"smtp_from_email": SystemConfigService.get_config(db, "smtp_from_email"),
|
||||
"smtp_from_name": SystemConfigService.get_config(db, "smtp_from_name", default="Aether"),
|
||||
}
|
||||
return config
|
||||
|
||||
@staticmethod
|
||||
def _validate_smtp_config(config: dict) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
验证 SMTP 配置
|
||||
|
||||
Args:
|
||||
config: SMTP 配置字典
|
||||
|
||||
Returns:
|
||||
(是否有效, 错误信息)
|
||||
"""
|
||||
required_fields = ["smtp_host", "smtp_from_email"]
|
||||
|
||||
for field in required_fields:
|
||||
if not config.get(field):
|
||||
return False, f"缺少必要的 SMTP 配置: {field}"
|
||||
|
||||
return True, None
|
||||
|
||||
@staticmethod
|
||||
async def send_verification_code(
|
||||
db: Session, to_email: str, code: str, expire_minutes: int = 30
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
发送验证码邮件
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
to_email: 收件人邮箱
|
||||
code: 验证码
|
||||
expire_minutes: 过期时间(分钟)
|
||||
|
||||
Returns:
|
||||
(是否发送成功, 错误信息)
|
||||
"""
|
||||
# 获取 SMTP 配置
|
||||
config = EmailSenderService._get_smtp_config(db)
|
||||
|
||||
# 验证配置
|
||||
valid, error = EmailSenderService._validate_smtp_config(config)
|
||||
if not valid:
|
||||
logger.error(f"SMTP 配置无效: {error}")
|
||||
return False, error
|
||||
|
||||
# 生成邮件内容
|
||||
# 优先使用 email_app_name,否则回退到 smtp_from_name
|
||||
app_name = SystemConfigService.get_config(db, "email_app_name", default=None)
|
||||
if not app_name:
|
||||
app_name = SystemConfigService.get_config(db, "smtp_from_name", default="Aether")
|
||||
|
||||
html_body = EmailTemplate.get_verification_code_html(
|
||||
code=code, expire_minutes=expire_minutes, db=db, app_name=app_name, email=to_email
|
||||
)
|
||||
text_body = EmailTemplate.get_verification_code_text(
|
||||
code=code, expire_minutes=expire_minutes, db=db, app_name=app_name, email=to_email
|
||||
)
|
||||
subject = EmailTemplate.get_subject("verification", db=db)
|
||||
|
||||
# 发送邮件
|
||||
return await EmailSenderService._send_email(
|
||||
config=config, to_email=to_email, subject=subject, html_body=html_body, text_body=text_body
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _send_email(
|
||||
config: dict,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
html_body: Optional[str] = None,
|
||||
text_body: Optional[str] = None,
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
发送邮件(内部方法)
|
||||
|
||||
Args:
|
||||
config: SMTP 配置
|
||||
to_email: 收件人邮箱
|
||||
subject: 邮件主题
|
||||
html_body: HTML 邮件内容
|
||||
text_body: 纯文本邮件内容
|
||||
|
||||
Returns:
|
||||
(是否发送成功, 错误信息)
|
||||
"""
|
||||
if AIOSMTPLIB_AVAILABLE:
|
||||
return await EmailSenderService._send_email_async(
|
||||
config, to_email, subject, html_body, text_body
|
||||
)
|
||||
else:
|
||||
return await EmailSenderService._send_email_sync_wrapper(
|
||||
config, to_email, subject, html_body, text_body
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _send_email_async(
|
||||
config: dict,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
html_body: Optional[str] = None,
|
||||
text_body: Optional[str] = None,
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
异步发送邮件(使用 aiosmtplib)
|
||||
|
||||
Args:
|
||||
config: SMTP 配置
|
||||
to_email: 收件人邮箱
|
||||
subject: 邮件主题
|
||||
html_body: HTML 邮件内容
|
||||
text_body: 纯文本邮件内容
|
||||
|
||||
Returns:
|
||||
(是否发送成功, 错误信息)
|
||||
"""
|
||||
try:
|
||||
# 构建邮件
|
||||
message = MIMEMultipart("alternative")
|
||||
message["Subject"] = subject
|
||||
message["From"] = f"{config['smtp_from_name']} <{config['smtp_from_email']}>"
|
||||
message["To"] = to_email
|
||||
|
||||
# 添加纯文本部分
|
||||
if text_body:
|
||||
message.attach(MIMEText(text_body, "plain", "utf-8"))
|
||||
|
||||
# 添加 HTML 部分
|
||||
if html_body:
|
||||
message.attach(MIMEText(html_body, "html", "utf-8"))
|
||||
|
||||
# 发送邮件
|
||||
ssl_context = _create_ssl_context()
|
||||
if config["smtp_use_ssl"]:
|
||||
await aiosmtplib.send(
|
||||
message,
|
||||
hostname=config["smtp_host"],
|
||||
port=config["smtp_port"],
|
||||
use_tls=True,
|
||||
tls_context=ssl_context,
|
||||
username=config["smtp_user"],
|
||||
password=config["smtp_password"],
|
||||
timeout=EmailSenderService.SMTP_TIMEOUT,
|
||||
)
|
||||
else:
|
||||
await aiosmtplib.send(
|
||||
message,
|
||||
hostname=config["smtp_host"],
|
||||
port=config["smtp_port"],
|
||||
start_tls=config["smtp_use_tls"],
|
||||
tls_context=ssl_context if config["smtp_use_tls"] else None,
|
||||
username=config["smtp_user"],
|
||||
password=config["smtp_password"],
|
||||
timeout=EmailSenderService.SMTP_TIMEOUT,
|
||||
)
|
||||
|
||||
logger.info(f"验证码邮件发送成功: {to_email}")
|
||||
return True, None
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"发送邮件失败: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
@staticmethod
|
||||
async def _send_email_sync_wrapper(
|
||||
config: dict,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
html_body: Optional[str] = None,
|
||||
text_body: Optional[str] = None,
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
同步邮件发送的异步包装器
|
||||
|
||||
Args:
|
||||
config: SMTP 配置
|
||||
to_email: 收件人邮箱
|
||||
subject: 邮件主题
|
||||
html_body: HTML 邮件内容
|
||||
text_body: 纯文本邮件内容
|
||||
|
||||
Returns:
|
||||
(是否发送成功, 错误信息)
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None, EmailSenderService._send_email_sync, config, to_email, subject, html_body, text_body
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _send_email_sync(
|
||||
config: dict,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
html_body: Optional[str] = None,
|
||||
text_body: Optional[str] = None,
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
同步发送邮件(使用标准库 smtplib)
|
||||
|
||||
Args:
|
||||
config: SMTP 配置
|
||||
to_email: 收件人邮箱
|
||||
subject: 邮件主题
|
||||
html_body: HTML 邮件内容
|
||||
text_body: 纯文本邮件内容
|
||||
|
||||
Returns:
|
||||
(是否发送成功, 错误信息)
|
||||
"""
|
||||
try:
|
||||
# 构建邮件
|
||||
message = MIMEMultipart("alternative")
|
||||
message["Subject"] = subject
|
||||
message["From"] = f"{config['smtp_from_name']} <{config['smtp_from_email']}>"
|
||||
message["To"] = to_email
|
||||
|
||||
# 添加纯文本部分
|
||||
if text_body:
|
||||
message.attach(MIMEText(text_body, "plain", "utf-8"))
|
||||
|
||||
# 添加 HTML 部分
|
||||
if html_body:
|
||||
message.attach(MIMEText(html_body, "html", "utf-8"))
|
||||
|
||||
# 连接 SMTP 服务器
|
||||
server = None
|
||||
ssl_context = _create_ssl_context()
|
||||
try:
|
||||
if config["smtp_use_ssl"]:
|
||||
server = smtplib.SMTP_SSL(
|
||||
config["smtp_host"],
|
||||
config["smtp_port"],
|
||||
context=ssl_context,
|
||||
timeout=EmailSenderService.SMTP_TIMEOUT,
|
||||
)
|
||||
else:
|
||||
server = smtplib.SMTP(
|
||||
config["smtp_host"],
|
||||
config["smtp_port"],
|
||||
timeout=EmailSenderService.SMTP_TIMEOUT,
|
||||
)
|
||||
if config["smtp_use_tls"]:
|
||||
server.starttls(context=ssl_context)
|
||||
|
||||
# 登录
|
||||
if config["smtp_user"] and config["smtp_password"]:
|
||||
server.login(config["smtp_user"], config["smtp_password"])
|
||||
|
||||
# 发送邮件
|
||||
server.send_message(message)
|
||||
|
||||
logger.info(f"验证码邮件发送成功(同步方式): {to_email}")
|
||||
return True, None
|
||||
finally:
|
||||
# 确保服务器连接被关闭
|
||||
if server is not None:
|
||||
try:
|
||||
server.quit()
|
||||
except Exception as quit_error:
|
||||
logger.warning(f"关闭 SMTP 连接时出错: {quit_error}")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"发送邮件失败: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
@staticmethod
|
||||
async def test_smtp_connection(
|
||||
db: Session, override_config: Optional[dict] = None
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
测试 SMTP 连接
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
override_config: 可选的覆盖配置(通常来自未保存的前端表单)
|
||||
|
||||
Returns:
|
||||
(是否连接成功, 错误信息)
|
||||
"""
|
||||
config = EmailSenderService._get_smtp_config(db)
|
||||
|
||||
# 用外部传入的配置覆盖(仅覆盖提供的字段)
|
||||
if override_config:
|
||||
config.update({k: v for k, v in override_config.items() if v is not None})
|
||||
|
||||
# 验证配置
|
||||
valid, error = EmailSenderService._validate_smtp_config(config)
|
||||
if not valid:
|
||||
return False, error
|
||||
|
||||
try:
|
||||
ssl_context = _create_ssl_context()
|
||||
if AIOSMTPLIB_AVAILABLE:
|
||||
# 使用异步方式测试
|
||||
# 注意: use_tls=True 表示隐式 SSL (端口 465)
|
||||
# start_tls=True 表示 STARTTLS (端口 587)
|
||||
use_ssl = config["smtp_use_ssl"]
|
||||
use_starttls = config["smtp_use_tls"] and not use_ssl
|
||||
|
||||
smtp = aiosmtplib.SMTP(
|
||||
hostname=config["smtp_host"],
|
||||
port=config["smtp_port"],
|
||||
use_tls=use_ssl,
|
||||
start_tls=use_starttls,
|
||||
tls_context=ssl_context if (use_ssl or use_starttls) else None,
|
||||
timeout=EmailSenderService.SMTP_TIMEOUT,
|
||||
)
|
||||
await smtp.connect()
|
||||
|
||||
if config["smtp_user"] and config["smtp_password"]:
|
||||
await smtp.login(config["smtp_user"], config["smtp_password"])
|
||||
|
||||
await smtp.quit()
|
||||
else:
|
||||
# 使用同步方式测试
|
||||
if config["smtp_use_ssl"]:
|
||||
server = smtplib.SMTP_SSL(
|
||||
config["smtp_host"],
|
||||
config["smtp_port"],
|
||||
context=ssl_context,
|
||||
timeout=EmailSenderService.SMTP_TIMEOUT,
|
||||
)
|
||||
else:
|
||||
server = smtplib.SMTP(
|
||||
config["smtp_host"],
|
||||
config["smtp_port"],
|
||||
timeout=EmailSenderService.SMTP_TIMEOUT,
|
||||
)
|
||||
if config["smtp_use_tls"]:
|
||||
server.starttls(context=ssl_context)
|
||||
|
||||
if config["smtp_user"] and config["smtp_password"]:
|
||||
server.login(config["smtp_user"], config["smtp_password"])
|
||||
|
||||
server.quit()
|
||||
|
||||
logger.info("SMTP 连接测试成功")
|
||||
return True, None
|
||||
|
||||
except Exception as e:
|
||||
error_msg = _translate_smtp_error(str(e))
|
||||
logger.error(f"SMTP 连接测试失败: {error_msg}")
|
||||
return False, error_msg
|
||||
|
||||
|
||||
def _translate_smtp_error(error: str) -> str:
|
||||
"""将 SMTP 错误信息转换为用户友好的中文提示"""
|
||||
error_lower = error.lower()
|
||||
|
||||
# 认证相关错误
|
||||
if "username and password not accepted" in error_lower:
|
||||
return "用户名或密码错误,请检查 SMTP 凭据"
|
||||
if "authentication failed" in error_lower:
|
||||
return "认证失败,请检查用户名和密码"
|
||||
if "invalid credentials" in error_lower or "badcredentials" in error_lower:
|
||||
return "凭据无效,请检查用户名和密码"
|
||||
if "smtp auth extension is not supported" in error_lower:
|
||||
return "服务器不支持认证,请尝试使用 TLS 或 SSL 加密"
|
||||
|
||||
# 连接相关错误
|
||||
if "connection refused" in error_lower:
|
||||
return "连接被拒绝,请检查服务器地址和端口"
|
||||
if "connection timed out" in error_lower or "timed out" in error_lower:
|
||||
return "连接超时,请检查网络或服务器地址"
|
||||
if "name or service not known" in error_lower or "getaddrinfo failed" in error_lower:
|
||||
return "无法解析服务器地址,请检查 SMTP 服务器地址"
|
||||
if "network is unreachable" in error_lower:
|
||||
return "网络不可达,请检查网络连接"
|
||||
|
||||
# SSL/TLS 相关错误
|
||||
if "certificate verify failed" in error_lower:
|
||||
return "SSL 证书验证失败,请检查服务器证书或尝试其他加密方式"
|
||||
if "ssl" in error_lower and "wrong version" in error_lower:
|
||||
return "SSL 版本不匹配,请尝试其他加密方式"
|
||||
if "starttls" in error_lower:
|
||||
return "STARTTLS 握手失败,请检查加密设置"
|
||||
|
||||
# 其他常见错误
|
||||
if "sender address rejected" in error_lower:
|
||||
return "发件人地址被拒绝,请检查发件人邮箱设置"
|
||||
if "relay access denied" in error_lower:
|
||||
return "中继访问被拒绝,请检查 SMTP 服务器配置"
|
||||
|
||||
# 返回原始错误(简化格式)
|
||||
# 去掉错误码前缀,如 "(535, '5.7.8 ..."
|
||||
if error.startswith("(") and "'" in error:
|
||||
# 提取引号内的内容
|
||||
start = error.find("'") + 1
|
||||
end = error.rfind("'")
|
||||
if start > 0 and end > start:
|
||||
return error[start:end].replace("\\n", " ").strip()
|
||||
|
||||
return error
|
||||
442
src/services/email/email_template.py
Normal file
442
src/services/email/email_template.py
Normal file
@@ -0,0 +1,442 @@
|
||||
"""
|
||||
邮件模板
|
||||
提供验证码邮件的 HTML 和纯文本模板,支持从数据库加载自定义模板
|
||||
"""
|
||||
|
||||
import html
|
||||
import re
|
||||
from html.parser import HTMLParser
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.services.system.config import SystemConfigService
|
||||
|
||||
|
||||
class HTMLToTextParser(HTMLParser):
|
||||
"""HTML 转纯文本解析器"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.text_parts = []
|
||||
self.skip_data = False
|
||||
|
||||
def handle_starttag(self, tag, attrs): # noqa: ARG002
|
||||
if tag in ("script", "style", "head"):
|
||||
self.skip_data = True
|
||||
elif tag == "br":
|
||||
self.text_parts.append("\n")
|
||||
elif tag in ("p", "div", "tr", "h1", "h2", "h3", "h4", "h5", "h6"):
|
||||
self.text_parts.append("\n")
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag in ("script", "style", "head"):
|
||||
self.skip_data = False
|
||||
elif tag in ("p", "div", "tr", "h1", "h2", "h3", "h4", "h5", "h6", "td"):
|
||||
self.text_parts.append("\n")
|
||||
|
||||
def handle_data(self, data):
|
||||
if not self.skip_data:
|
||||
text = data.strip()
|
||||
if text:
|
||||
self.text_parts.append(text)
|
||||
|
||||
|
||||
class EmailTemplate:
|
||||
"""邮件模板类"""
|
||||
|
||||
# 模板类型定义
|
||||
TEMPLATE_VERIFICATION = "verification"
|
||||
TEMPLATE_PASSWORD_RESET = "password_reset"
|
||||
|
||||
# 支持的模板类型及其变量
|
||||
TEMPLATE_TYPES = {
|
||||
TEMPLATE_VERIFICATION: {
|
||||
"name": "注册验证码",
|
||||
"variables": ["app_name", "code", "expire_minutes", "email"],
|
||||
"default_subject": "验证码",
|
||||
},
|
||||
TEMPLATE_PASSWORD_RESET: {
|
||||
"name": "找回密码",
|
||||
"variables": ["app_name", "reset_link", "expire_minutes", "email"],
|
||||
"default_subject": "密码重置",
|
||||
},
|
||||
}
|
||||
|
||||
# Literary Tech 主题色 - 与网页保持一致
|
||||
PRIMARY_COLOR = "#c96442" # book-cloth
|
||||
PRIMARY_LIGHT = "#e4b2a0" # kraft
|
||||
BG_WARM = "#faf9f5" # ivory-light
|
||||
BG_MEDIUM = "#e9e6dc" # ivory-medium / cloud-medium
|
||||
TEXT_DARK = "#3d3929" # slate-dark
|
||||
TEXT_MUTED = "#6c695c" # slate-medium
|
||||
BORDER_COLOR = "rgba(61, 57, 41, 0.12)"
|
||||
|
||||
@staticmethod
|
||||
def get_default_verification_html() -> str:
|
||||
"""获取默认的验证码邮件 HTML 模板 - Literary Tech 风格"""
|
||||
return """<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>验证码</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #faf9f5; font-family: Georgia, 'Times New Roman', 'Songti SC', 'STSong', serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #faf9f5; padding: 40px 20px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="max-width: 480px;">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="padding: 0 0 32px; text-align: center;">
|
||||
<div style="font-size: 13px; font-family: 'SF Mono', Monaco, 'Courier New', monospace; color: #6c695c; letter-spacing: 0.15em; text-transform: uppercase;">
|
||||
{{app_name}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Main Card -->
|
||||
<tr>
|
||||
<td>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border: 1px solid rgba(61, 57, 41, 0.1); border-radius: 6px;">
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 48px 40px;">
|
||||
<h1 style="margin: 0 0 24px; font-size: 24px; font-weight: 500; color: #3d3929; text-align: center; letter-spacing: -0.02em;">
|
||||
验证码
|
||||
</h1>
|
||||
|
||||
<p style="margin: 0 0 32px; font-size: 15px; color: #6c695c; line-height: 1.7; text-align: center;">
|
||||
您正在注册账户,请使用以下验证码完成验证。
|
||||
</p>
|
||||
|
||||
<!-- Code Box -->
|
||||
<div style="background-color: #faf9f5; border: 1px solid rgba(61, 57, 41, 0.08); border-radius: 4px; padding: 32px 20px; text-align: center; margin-bottom: 32px;">
|
||||
<div style="font-size: 40px; font-weight: 500; color: #c96442; letter-spacing: 12px; font-family: 'SF Mono', Monaco, 'Courier New', monospace;">
|
||||
{{code}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style="margin: 0; font-size: 14px; color: #6c695c; line-height: 1.6; text-align: center;">
|
||||
验证码将在 <span style="color: #3d3929; font-weight: 500;">{{expire_minutes}} 分钟</span>后失效
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="padding: 32px 0 0; text-align: center;">
|
||||
<p style="margin: 0 0 8px; font-size: 12px; color: #6c695c;">
|
||||
如果这不是您的操作,请忽略此邮件。
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 11px; color: rgba(108, 105, 92, 0.6);">
|
||||
此邮件由系统自动发送,请勿回复
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
@staticmethod
|
||||
def get_default_password_reset_html() -> str:
|
||||
"""获取默认的密码重置邮件 HTML 模板 - Literary Tech 风格"""
|
||||
return """<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>密码重置</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #faf9f5; font-family: Georgia, 'Times New Roman', 'Songti SC', 'STSong', serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #faf9f5; padding: 40px 20px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="max-width: 480px;">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="padding: 0 0 32px; text-align: center;">
|
||||
<div style="font-size: 13px; font-family: 'SF Mono', Monaco, 'Courier New', monospace; color: #6c695c; letter-spacing: 0.15em; text-transform: uppercase;">
|
||||
{{app_name}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Main Card -->
|
||||
<tr>
|
||||
<td>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border: 1px solid rgba(61, 57, 41, 0.1); border-radius: 6px;">
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 48px 40px;">
|
||||
<h1 style="margin: 0 0 24px; font-size: 24px; font-weight: 500; color: #3d3929; text-align: center; letter-spacing: -0.02em;">
|
||||
重置密码
|
||||
</h1>
|
||||
|
||||
<p style="margin: 0 0 32px; font-size: 15px; color: #6c695c; line-height: 1.7; text-align: center;">
|
||||
您正在重置账户密码,请点击下方按钮完成操作。
|
||||
</p>
|
||||
|
||||
<!-- Button -->
|
||||
<div style="text-align: center; margin-bottom: 32px;">
|
||||
<a href="{{reset_link}}" style="display: inline-block; padding: 14px 36px; background-color: #c96442; color: #ffffff; text-decoration: none; border-radius: 4px; font-size: 15px; font-weight: 500; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;">
|
||||
重置密码
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p style="margin: 0; font-size: 14px; color: #6c695c; line-height: 1.6; text-align: center;">
|
||||
链接将在 <span style="color: #3d3929; font-weight: 500;">{{expire_minutes}} 分钟</span>后失效
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="padding: 32px 0 0; text-align: center;">
|
||||
<p style="margin: 0 0 8px; font-size: 12px; color: #6c695c;">
|
||||
如果您没有请求重置密码,请忽略此邮件。
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 11px; color: rgba(108, 105, 92, 0.6);">
|
||||
此邮件由系统自动发送,请勿回复
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
@staticmethod
|
||||
def get_default_template(template_type: str) -> Dict[str, str]:
|
||||
"""
|
||||
获取默认模板
|
||||
|
||||
Args:
|
||||
template_type: 模板类型
|
||||
|
||||
Returns:
|
||||
包含 subject 和 html 的字典
|
||||
"""
|
||||
if template_type == EmailTemplate.TEMPLATE_VERIFICATION:
|
||||
return {
|
||||
"subject": "验证码",
|
||||
"html": EmailTemplate.get_default_verification_html(),
|
||||
}
|
||||
elif template_type == EmailTemplate.TEMPLATE_PASSWORD_RESET:
|
||||
return {
|
||||
"subject": "密码重置",
|
||||
"html": EmailTemplate.get_default_password_reset_html(),
|
||||
}
|
||||
else:
|
||||
return {"subject": "通知", "html": ""}
|
||||
|
||||
@staticmethod
|
||||
def get_template(db: Session, template_type: str) -> Dict[str, str]:
|
||||
"""
|
||||
从数据库获取模板,如果不存在则返回默认模板
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
template_type: 模板类型
|
||||
|
||||
Returns:
|
||||
包含 subject 和 html 的字典
|
||||
"""
|
||||
default = EmailTemplate.get_default_template(template_type)
|
||||
|
||||
# 从数据库获取自定义模板
|
||||
subject_key = f"email_template_{template_type}_subject"
|
||||
html_key = f"email_template_{template_type}_html"
|
||||
|
||||
custom_subject = SystemConfigService.get_config(db, subject_key, default=None)
|
||||
custom_html = SystemConfigService.get_config(db, html_key, default=None)
|
||||
|
||||
return {
|
||||
"subject": custom_subject if custom_subject else default["subject"],
|
||||
"html": custom_html if custom_html else default["html"],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def render_template(template_html: str, variables: Dict[str, Any]) -> str:
|
||||
"""
|
||||
渲染模板,替换 {{variable}} 格式的变量
|
||||
|
||||
Args:
|
||||
template_html: HTML 模板
|
||||
variables: 变量字典
|
||||
|
||||
Returns:
|
||||
渲染后的 HTML
|
||||
"""
|
||||
result = template_html
|
||||
for key, value in variables.items():
|
||||
# HTML 转义变量值,防止 XSS
|
||||
escaped_value = html.escape(str(value))
|
||||
# 替换 {{key}} 格式的变量
|
||||
pattern = r"\{\{\s*" + re.escape(key) + r"\s*\}\}"
|
||||
result = re.sub(pattern, escaped_value, result)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def html_to_text(html: str) -> str:
|
||||
"""
|
||||
从 HTML 提取纯文本
|
||||
|
||||
Args:
|
||||
html: HTML 内容
|
||||
|
||||
Returns:
|
||||
纯文本内容
|
||||
"""
|
||||
parser = HTMLToTextParser()
|
||||
parser.feed(html)
|
||||
text = " ".join(parser.text_parts)
|
||||
# 清理多余空白
|
||||
text = re.sub(r"\n\s*\n", "\n\n", text)
|
||||
text = re.sub(r" +", " ", text)
|
||||
return text.strip()
|
||||
|
||||
@staticmethod
|
||||
def get_verification_code_html(
|
||||
code: str, expire_minutes: int = 5, db: Optional[Session] = None, **kwargs
|
||||
) -> str:
|
||||
"""
|
||||
获取验证码邮件 HTML
|
||||
|
||||
Args:
|
||||
code: 验证码
|
||||
expire_minutes: 过期时间(分钟)
|
||||
db: 数据库会话(用于获取自定义模板)
|
||||
**kwargs: 其他模板变量
|
||||
|
||||
Returns:
|
||||
渲染后的 HTML
|
||||
"""
|
||||
app_name = kwargs.get("app_name", "Aether")
|
||||
email = kwargs.get("email", "")
|
||||
|
||||
# 获取模板
|
||||
if db:
|
||||
template = EmailTemplate.get_template(db, EmailTemplate.TEMPLATE_VERIFICATION)
|
||||
else:
|
||||
template = EmailTemplate.get_default_template(EmailTemplate.TEMPLATE_VERIFICATION)
|
||||
|
||||
# 渲染变量
|
||||
variables = {
|
||||
"app_name": app_name,
|
||||
"code": code,
|
||||
"expire_minutes": expire_minutes,
|
||||
"email": email,
|
||||
}
|
||||
|
||||
return EmailTemplate.render_template(template["html"], variables)
|
||||
|
||||
@staticmethod
|
||||
def get_verification_code_text(
|
||||
code: str, expire_minutes: int = 5, db: Optional[Session] = None, **kwargs
|
||||
) -> str:
|
||||
"""
|
||||
获取验证码邮件纯文本(从 HTML 自动生成)
|
||||
|
||||
Args:
|
||||
code: 验证码
|
||||
expire_minutes: 过期时间(分钟)
|
||||
db: 数据库会话
|
||||
**kwargs: 其他模板变量
|
||||
|
||||
Returns:
|
||||
纯文本邮件内容
|
||||
"""
|
||||
html = EmailTemplate.get_verification_code_html(code, expire_minutes, db, **kwargs)
|
||||
return EmailTemplate.html_to_text(html)
|
||||
|
||||
@staticmethod
|
||||
def get_password_reset_html(
|
||||
reset_link: str, expire_minutes: int = 30, db: Optional[Session] = None, **kwargs
|
||||
) -> str:
|
||||
"""
|
||||
获取密码重置邮件 HTML
|
||||
|
||||
Args:
|
||||
reset_link: 重置链接
|
||||
expire_minutes: 过期时间(分钟)
|
||||
db: 数据库会话
|
||||
**kwargs: 其他模板变量
|
||||
|
||||
Returns:
|
||||
渲染后的 HTML
|
||||
"""
|
||||
app_name = kwargs.get("app_name", "Aether")
|
||||
email = kwargs.get("email", "")
|
||||
|
||||
# 获取模板
|
||||
if db:
|
||||
template = EmailTemplate.get_template(db, EmailTemplate.TEMPLATE_PASSWORD_RESET)
|
||||
else:
|
||||
template = EmailTemplate.get_default_template(EmailTemplate.TEMPLATE_PASSWORD_RESET)
|
||||
|
||||
# 渲染变量
|
||||
variables = {
|
||||
"app_name": app_name,
|
||||
"reset_link": reset_link,
|
||||
"expire_minutes": expire_minutes,
|
||||
"email": email,
|
||||
}
|
||||
|
||||
return EmailTemplate.render_template(template["html"], variables)
|
||||
|
||||
@staticmethod
|
||||
def get_password_reset_text(
|
||||
reset_link: str, expire_minutes: int = 30, db: Optional[Session] = None, **kwargs
|
||||
) -> str:
|
||||
"""
|
||||
获取密码重置邮件纯文本(从 HTML 自动生成)
|
||||
|
||||
Args:
|
||||
reset_link: 重置链接
|
||||
expire_minutes: 过期时间(分钟)
|
||||
db: 数据库会话
|
||||
**kwargs: 其他模板变量
|
||||
|
||||
Returns:
|
||||
纯文本邮件内容
|
||||
"""
|
||||
html = EmailTemplate.get_password_reset_html(reset_link, expire_minutes, db, **kwargs)
|
||||
return EmailTemplate.html_to_text(html)
|
||||
|
||||
@staticmethod
|
||||
def get_subject(
|
||||
template_type: str = "verification", db: Optional[Session] = None
|
||||
) -> str:
|
||||
"""
|
||||
获取邮件主题
|
||||
|
||||
Args:
|
||||
template_type: 模板类型
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
邮件主题
|
||||
"""
|
||||
if db:
|
||||
template = EmailTemplate.get_template(db, template_type)
|
||||
return template["subject"]
|
||||
|
||||
default_subjects = {
|
||||
"verification": "验证码",
|
||||
"welcome": "欢迎加入",
|
||||
"password_reset": "密码重置",
|
||||
}
|
||||
return default_subjects.get(template_type, "通知")
|
||||
247
src/services/email/email_verification.py
Normal file
247
src/services/email/email_verification.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""
|
||||
邮箱验证服务
|
||||
提供验证码生成、发送、验证等功能
|
||||
"""
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from src.clients.redis_client import get_redis_client
|
||||
from src.config.settings import Config
|
||||
from src.core.logger import logger
|
||||
|
||||
# 从环境变量加载配置
|
||||
_config = Config()
|
||||
|
||||
|
||||
class EmailVerificationService:
|
||||
"""邮箱验证码服务"""
|
||||
|
||||
# Redis key 前缀
|
||||
VERIFICATION_PREFIX = "email:verification:"
|
||||
VERIFIED_PREFIX = "email:verified:"
|
||||
|
||||
# 从环境变量读取配置
|
||||
DEFAULT_CODE_EXPIRE_MINUTES = _config.verification_code_expire_minutes
|
||||
SEND_COOLDOWN_SECONDS = _config.verification_send_cooldown
|
||||
|
||||
@staticmethod
|
||||
def _generate_code() -> str:
|
||||
"""
|
||||
生成 6 位数字验证码
|
||||
|
||||
Returns:
|
||||
6 位数字字符串
|
||||
"""
|
||||
# 使用 secrets 模块生成安全的随机数
|
||||
code = secrets.randbelow(1000000)
|
||||
return f"{code:06d}"
|
||||
|
||||
@staticmethod
|
||||
async def send_verification_code(
|
||||
email: str,
|
||||
expire_minutes: Optional[int] = None,
|
||||
) -> Tuple[bool, str, Optional[str]]:
|
||||
"""
|
||||
发送验证码(生成并存储到 Redis)
|
||||
|
||||
Args:
|
||||
email: 目标邮箱地址
|
||||
expire_minutes: 验证码过期时间(分钟),None 则使用默认值
|
||||
|
||||
Returns:
|
||||
(是否成功, 验证码/错误信息, 错误详情)
|
||||
"""
|
||||
redis_client = await get_redis_client(require_redis=False)
|
||||
|
||||
if redis_client is None:
|
||||
logger.error("Redis 不可用,无法发送验证码")
|
||||
return False, "系统错误", "Redis 服务不可用"
|
||||
|
||||
try:
|
||||
# 检查冷却时间
|
||||
verification_key = f"{EmailVerificationService.VERIFICATION_PREFIX}{email}"
|
||||
existing_data = await redis_client.get(verification_key)
|
||||
|
||||
if existing_data:
|
||||
data = json.loads(existing_data)
|
||||
created_at = datetime.fromisoformat(data["created_at"])
|
||||
elapsed = (datetime.now(timezone.utc) - created_at).total_seconds()
|
||||
|
||||
if elapsed < EmailVerificationService.SEND_COOLDOWN_SECONDS:
|
||||
remaining = int(EmailVerificationService.SEND_COOLDOWN_SECONDS - elapsed)
|
||||
logger.warning(f"邮箱 {email} 请求验证码过于频繁,需等待 {remaining} 秒")
|
||||
return False, "请求过于频繁", f"请在 {remaining} 秒后重试"
|
||||
|
||||
# 生成验证码
|
||||
code = EmailVerificationService._generate_code()
|
||||
expire_time = expire_minutes or EmailVerificationService.DEFAULT_CODE_EXPIRE_MINUTES
|
||||
|
||||
# 存储验证码数据
|
||||
verification_data = {
|
||||
"code": code,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
# 存储到 Redis(设置过期时间)
|
||||
await redis_client.setex(
|
||||
verification_key, expire_time * 60, json.dumps(verification_data)
|
||||
)
|
||||
|
||||
logger.info(f"验证码已生成并存储: {email}, 有效期: {expire_time} 分钟")
|
||||
return True, code, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送验证码失败: {e}")
|
||||
return False, "系统错误", str(e)
|
||||
|
||||
@staticmethod
|
||||
async def verify_code(email: str, code: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
验证验证码
|
||||
|
||||
Args:
|
||||
email: 邮箱地址
|
||||
code: 用户输入的验证码
|
||||
|
||||
Returns:
|
||||
(是否验证成功, 错误信息)
|
||||
"""
|
||||
redis_client = await get_redis_client(require_redis=False)
|
||||
|
||||
if redis_client is None:
|
||||
logger.error("Redis 不可用,无法验证验证码")
|
||||
return False, "系统错误"
|
||||
|
||||
try:
|
||||
verification_key = f"{EmailVerificationService.VERIFICATION_PREFIX}{email}"
|
||||
data_str = await redis_client.get(verification_key)
|
||||
|
||||
if not data_str:
|
||||
logger.warning(f"验证码不存在或已过期: {email}")
|
||||
return False, "验证码不存在或已过期"
|
||||
|
||||
data = json.loads(data_str)
|
||||
|
||||
# 验证码比对 - 使用常量时间比较防止时序攻击
|
||||
if not secrets.compare_digest(code, data["code"]):
|
||||
logger.warning(f"验证码错误: {email}")
|
||||
return False, "验证码错误"
|
||||
|
||||
# 验证成功:删除验证码,标记邮箱已验证
|
||||
await redis_client.delete(verification_key)
|
||||
|
||||
verified_key = f"{EmailVerificationService.VERIFIED_PREFIX}{email}"
|
||||
# 已验证标记保留 1 小时,足够完成注册流程
|
||||
await redis_client.setex(verified_key, 3600, "verified")
|
||||
|
||||
logger.info(f"验证码验证成功: {email}")
|
||||
return True, "验证成功"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"验证验证码失败: {e}")
|
||||
return False, "系统错误"
|
||||
|
||||
@staticmethod
|
||||
async def is_email_verified(email: str) -> bool:
|
||||
"""
|
||||
检查邮箱是否已验证
|
||||
|
||||
Args:
|
||||
email: 邮箱地址
|
||||
|
||||
Returns:
|
||||
是否已验证
|
||||
"""
|
||||
redis_client = await get_redis_client(require_redis=False)
|
||||
|
||||
if redis_client is None:
|
||||
logger.warning("Redis 不可用,跳过邮箱验证检查")
|
||||
return False
|
||||
|
||||
try:
|
||||
verified_key = f"{EmailVerificationService.VERIFIED_PREFIX}{email}"
|
||||
verified = await redis_client.exists(verified_key)
|
||||
return bool(verified)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"检查邮箱验证状态失败: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def clear_verification(email: str) -> bool:
|
||||
"""
|
||||
清除邮箱验证状态(注册成功后调用)
|
||||
|
||||
Args:
|
||||
email: 邮箱地址
|
||||
|
||||
Returns:
|
||||
是否清除成功
|
||||
"""
|
||||
redis_client = await get_redis_client(require_redis=False)
|
||||
|
||||
if redis_client is None:
|
||||
logger.warning("Redis 不可用,无法清除验证状态")
|
||||
return False
|
||||
|
||||
try:
|
||||
verified_key = f"{EmailVerificationService.VERIFIED_PREFIX}{email}"
|
||||
verification_key = f"{EmailVerificationService.VERIFICATION_PREFIX}{email}"
|
||||
|
||||
# 删除已验证标记和验证码(如果还存在)
|
||||
await redis_client.delete(verified_key)
|
||||
await redis_client.delete(verification_key)
|
||||
|
||||
logger.info(f"邮箱验证状态已清除: {email}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"清除邮箱验证状态失败: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def get_verification_status(email: str) -> dict:
|
||||
"""
|
||||
获取邮箱验证状态(用于调试和管理)
|
||||
|
||||
Args:
|
||||
email: 邮箱地址
|
||||
|
||||
Returns:
|
||||
验证状态信息
|
||||
"""
|
||||
redis_client = await get_redis_client(require_redis=False)
|
||||
|
||||
if redis_client is None:
|
||||
return {"error": "Redis 不可用"}
|
||||
|
||||
try:
|
||||
verification_key = f"{EmailVerificationService.VERIFICATION_PREFIX}{email}"
|
||||
verified_key = f"{EmailVerificationService.VERIFIED_PREFIX}{email}"
|
||||
|
||||
# 获取各个状态
|
||||
verification_data = await redis_client.get(verification_key)
|
||||
is_verified = await redis_client.exists(verified_key)
|
||||
verification_ttl = await redis_client.ttl(verification_key)
|
||||
verified_ttl = await redis_client.ttl(verified_key)
|
||||
|
||||
status = {
|
||||
"email": email,
|
||||
"has_pending_code": bool(verification_data),
|
||||
"is_verified": bool(is_verified),
|
||||
"code_expires_in": verification_ttl if verification_ttl > 0 else None,
|
||||
"verified_expires_in": verified_ttl if verified_ttl > 0 else None,
|
||||
}
|
||||
|
||||
if verification_data:
|
||||
data = json.loads(verification_data)
|
||||
status["created_at"] = data.get("created_at")
|
||||
|
||||
return status
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取邮箱验证状态失败: {e}")
|
||||
return {"error": str(e)}
|
||||
Reference in New Issue
Block a user