229 lines
7.7 KiB
Python
229 lines
7.7 KiB
Python
"""
|
|
异步邮件发送工具
|
|
|
|
使用 aiosmtplib 实现异步 SMTP 邮件发送,支持:
|
|
- 纯文本邮件
|
|
- HTML 邮件
|
|
- 批量发送(并发)
|
|
- 自动 TLS/SSL
|
|
"""
|
|
import asyncio
|
|
import logging
|
|
from email.mime.multipart import MIMEMultipart
|
|
from email.mime.text import MIMEText
|
|
from typing import Dict, List, Optional
|
|
|
|
import aiosmtplib
|
|
|
|
from app.config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class EmailSender:
|
|
"""异步邮件发送器"""
|
|
|
|
@staticmethod
|
|
async def _get_smtp_config() -> dict:
|
|
"""
|
|
通过 config_manager 三级获取 SMTP 配置(Redis → 数据库 → env)
|
|
"""
|
|
from app.config_manager import config_manager
|
|
group_config = await config_manager.get_group("notify_email")
|
|
return {
|
|
"host": group_config.get("smtp_host") or None,
|
|
"port": int(group_config.get("smtp_port") or settings.SMTP_PORT or 465),
|
|
"user": group_config.get("smtp_user") or None,
|
|
"password": group_config.get("smtp_password") or None,
|
|
"use_tls": str(group_config.get("smtp_use_tls", "True")).lower() in ("true", "1", "yes"),
|
|
"from_name": group_config.get("smtp_from_name") or settings.APP_NAME,
|
|
"from_email": group_config.get("smtp_from_email") or group_config.get("smtp_user") or None,
|
|
}
|
|
|
|
@staticmethod
|
|
def is_configured() -> bool:
|
|
"""检查 SMTP 是否已配置(仅检查 env,同步兼容)"""
|
|
return bool(settings.SMTP_HOST and settings.SMTP_USER and settings.SMTP_PASSWORD)
|
|
|
|
@staticmethod
|
|
async def is_configured_async() -> bool:
|
|
"""检查 SMTP 是否已配置(通过 config_manager 三级获取)"""
|
|
config = await EmailSender._get_smtp_config()
|
|
return bool(config["host"] and config["user"] and config["password"])
|
|
|
|
@staticmethod
|
|
def _build_from_address(config: dict) -> str:
|
|
"""根据配置构建发件人地址"""
|
|
from_email = config["from_email"] or config["user"]
|
|
from_name = config["from_name"] or settings.APP_NAME
|
|
return f"{from_name} <{from_email}>"
|
|
|
|
@staticmethod
|
|
def _get_from_address() -> str:
|
|
"""获取发件人地址(同步兼容,仅读 env)"""
|
|
from_email = settings.SMTP_FROM_EMAIL or settings.SMTP_USER
|
|
from_name = settings.SMTP_FROM_NAME or settings.APP_NAME
|
|
return f"{from_name} <{from_email}>"
|
|
|
|
@staticmethod
|
|
async def send(
|
|
to_email: str,
|
|
subject: str,
|
|
content: str,
|
|
html: bool = False,
|
|
) -> bool:
|
|
"""
|
|
发送单封邮件
|
|
|
|
Args:
|
|
to_email: 收件人邮箱
|
|
subject: 邮件主题
|
|
content: 邮件内容(纯文本或 HTML)
|
|
html: 是否为 HTML 内容
|
|
|
|
Returns:
|
|
是否发送成功
|
|
"""
|
|
config = await EmailSender._get_smtp_config()
|
|
if not (config["host"] and config["user"] and config["password"]):
|
|
logger.warning("SMTP 未配置,跳过邮件发送")
|
|
return False
|
|
|
|
try:
|
|
msg = MIMEMultipart("alternative")
|
|
msg["From"] = EmailSender._build_from_address(config)
|
|
msg["To"] = to_email
|
|
msg["Subject"] = subject
|
|
|
|
if html:
|
|
msg.attach(MIMEText(content, "html", "utf-8"))
|
|
else:
|
|
msg.attach(MIMEText(content, "plain", "utf-8"))
|
|
|
|
# sender 必须使用 smtp_user,部分邮箱(如QQ)要求 MAIL FROM 与认证用户一致
|
|
sender = config["user"]
|
|
if config["use_tls"]:
|
|
await aiosmtplib.send(
|
|
msg,
|
|
sender=sender,
|
|
hostname=config["host"],
|
|
port=config["port"],
|
|
username=config["user"],
|
|
password=config["password"],
|
|
use_tls=True,
|
|
)
|
|
else:
|
|
await aiosmtplib.send(
|
|
msg,
|
|
sender=sender,
|
|
hostname=config["host"],
|
|
port=config["port"],
|
|
username=config["user"],
|
|
password=config["password"],
|
|
start_tls=True,
|
|
)
|
|
|
|
logger.info(f"邮件发送成功: {to_email}")
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"邮件发送失败 [{to_email}]: {e}")
|
|
return False
|
|
|
|
@staticmethod
|
|
async def send_batch(
|
|
to_emails: List[str],
|
|
subject: str,
|
|
content: str,
|
|
html: bool = False,
|
|
) -> Dict[str, bool]:
|
|
"""
|
|
批量发送邮件(并发)
|
|
|
|
Args:
|
|
to_emails: 收件人邮箱列表
|
|
subject: 邮件主题
|
|
content: 邮件内容
|
|
html: 是否为 HTML 内容
|
|
|
|
Returns:
|
|
每个邮箱的发送结果
|
|
"""
|
|
config = await EmailSender._get_smtp_config()
|
|
if not (config["host"] and config["user"] and config["password"]):
|
|
logger.warning("SMTP 未配置,跳过批量邮件发送")
|
|
return {email: False for email in to_emails}
|
|
|
|
tasks = [
|
|
EmailSender.send(email, subject, content, html)
|
|
for email in to_emails
|
|
]
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
return {
|
|
email: (result is True)
|
|
for email, result in zip(to_emails, results)
|
|
}
|
|
|
|
@staticmethod
|
|
def build_notification_html(title: str, content: str, app_name: Optional[str] = None) -> str:
|
|
"""
|
|
构建通知邮件的 HTML 模板
|
|
|
|
Args:
|
|
title: 通知标题
|
|
content: 通知内容
|
|
app_name: 应用名称
|
|
|
|
Returns:
|
|
HTML 字符串
|
|
"""
|
|
app = app_name or settings.APP_NAME
|
|
return f"""<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
</head>
|
|
<body style="margin:0;padding:0;background-color:#f0f4f8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="padding:40px 20px;">
|
|
<tr>
|
|
<td align="center">
|
|
<table width="600" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.06);">
|
|
<!-- Header -->
|
|
<tr>
|
|
<td style="background:linear-gradient(135deg,#1a73e8 0%,#0d47a1 100%);padding:32px 40px;">
|
|
<h1 style="margin:0;color:#ffffff;font-size:22px;font-weight:700;letter-spacing:0.5px;">{app}</h1>
|
|
</td>
|
|
</tr>
|
|
<!-- Accent line -->
|
|
<tr>
|
|
<td style="height:3px;background:linear-gradient(90deg,#42a5f5,#1a73e8,#0d47a1);"></td>
|
|
</tr>
|
|
<!-- Body -->
|
|
<tr>
|
|
<td style="padding:36px 40px 32px;">
|
|
<h2 style="margin:0 0 16px;color:#1a1a1a;font-size:18px;font-weight:600;">{title}</h2>
|
|
<div style="color:#4a4a4a;font-size:14px;line-height:1.8;word-break:break-word;">{content}</div>
|
|
</td>
|
|
</tr>
|
|
<!-- Divider -->
|
|
<tr>
|
|
<td style="padding:0 40px;">
|
|
<div style="border-top:1px solid #e8edf2;"></div>
|
|
</td>
|
|
</tr>
|
|
<!-- Footer -->
|
|
<tr>
|
|
<td style="padding:20px 40px 24px;">
|
|
<p style="margin:0;color:#9e9e9e;font-size:12px;text-align:center;line-height:1.6;">
|
|
This email was sent by {app}. Please do not reply directly.
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</body>
|
|
</html>"""
|