Files
Aether/src/services/email/email_sender.py

474 lines
16 KiB
Python
Raw Normal View History

"""
邮件发送服务
提供 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