383 lines
12 KiB
Python
383 lines
12 KiB
Python
"""
|
||
邮件通知服务
|
||
|
||
支持发送告警通知、额度预警等邮件。
|
||
|
||
功能:
|
||
- 邮件模板引擎: 变量替换渲染邮件内容
|
||
- 邮件内容生成: 告警通知、额度预警邮件生成
|
||
- 邮件发送: 支持真实SMTP和模拟模式
|
||
- 邮件队列管理: 批量添加和发送
|
||
- 错误处理和重试: 自动重试机制
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import re
|
||
import smtplib
|
||
import time
|
||
import uuid
|
||
from dataclasses import dataclass, field
|
||
from email.mime.multipart import MIMEMultipart
|
||
from email.mime.text import MIMEText
|
||
from email.mime.base import MIMEBase
|
||
from email import encoders
|
||
from typing import Any
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
EMAIL_TEMPLATES = {
|
||
"alert_notification": {
|
||
"subject": "[GEO平台] 告警通知:{alert_type}",
|
||
"body_html": """
|
||
<h2>告警通知</h2>
|
||
<p>品牌:<strong>{brand_name}</strong></p>
|
||
<p>告警类型:{alert_type}</p>
|
||
<p>严重程度:{severity}</p>
|
||
<p>详情:{description}</p>
|
||
<p>时间:{timestamp}</p>
|
||
""",
|
||
"body_text": "告警通知 - 品牌:{brand_name}, 类型:{alert_type}, severity:{severity}"
|
||
},
|
||
"quota_warning": {
|
||
"subject": "[GEO平台] 额度预警:{quota_type}",
|
||
"body_html": """
|
||
<h2>额度预警</h2>
|
||
<p>您的{quota_type}使用量已达到{usage_percentage}%</p>
|
||
<p>已使用:{used} / 总额度:{limit}</p>
|
||
<p>建议操作:{recommended_action}</p>
|
||
""",
|
||
"body_text": "额度预警 - {quota_type}使用量:{usage_percentage}%"
|
||
}
|
||
}
|
||
|
||
|
||
@dataclass
|
||
class EmailMessage:
|
||
to: str
|
||
subject: str
|
||
body_html: str
|
||
body_text: str
|
||
attachments: list[dict] = field(default_factory=list)
|
||
metadata: dict = field(default_factory=dict)
|
||
|
||
|
||
@dataclass
|
||
class EmailSendResult:
|
||
success: bool
|
||
message_id: str | None
|
||
error: str | None
|
||
retry_count: int
|
||
|
||
|
||
class EmailService:
|
||
"""邮件通知服务
|
||
|
||
提供邮件模板渲染、内容生成、发送(支持模拟模式)、队列管理等功能。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
simulate_mode: bool = True,
|
||
smtp_host: str = "localhost",
|
||
smtp_port: int = 587,
|
||
smtp_user: str = "",
|
||
smtp_password: str = "",
|
||
max_retries: int = 3,
|
||
):
|
||
self.simulate_mode = simulate_mode
|
||
self.smtp_host = smtp_host
|
||
self.smtp_port = smtp_port
|
||
self.smtp_user = smtp_user
|
||
self.smtp_password = smtp_password
|
||
self.max_retries = max_retries
|
||
self._queue: list[EmailMessage] = []
|
||
|
||
def validate_email(self, email: str) -> bool:
|
||
"""验证邮箱地址格式
|
||
|
||
Args:
|
||
email: 邮箱地址
|
||
|
||
Returns:
|
||
是否为有效邮箱地址
|
||
"""
|
||
if not email:
|
||
return False
|
||
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||
return bool(re.match(pattern, email))
|
||
|
||
def _safe_format(self, template: str, variables: dict[str, Any]) -> str:
|
||
"""安全格式化模板,缺失变量时保留原占位符
|
||
|
||
Args:
|
||
template: 模板字符串
|
||
variables: 变量字典
|
||
|
||
Returns:
|
||
格式化后的字符串
|
||
"""
|
||
try:
|
||
return template.format(**variables)
|
||
except KeyError:
|
||
result = template
|
||
for key, value in variables.items():
|
||
result = result.replace("{" + key + "}", str(value))
|
||
return result
|
||
|
||
def render_template(
|
||
self,
|
||
template_name: str,
|
||
to: str,
|
||
variables: dict[str, Any],
|
||
) -> EmailMessage:
|
||
"""渲染邮件模板
|
||
|
||
Args:
|
||
template_name: 模板名称
|
||
to: 收件人邮箱
|
||
variables: 模板变量
|
||
|
||
Returns:
|
||
EmailMessage邮件消息对象
|
||
|
||
Raises:
|
||
ValueError: 模板不存在
|
||
"""
|
||
if template_name not in EMAIL_TEMPLATES:
|
||
raise ValueError(f"模板不存在: {template_name}")
|
||
|
||
template = EMAIL_TEMPLATES[template_name]
|
||
|
||
subject = self._safe_format(template["subject"], variables)
|
||
body_html = self._safe_format(template["body_html"], variables)
|
||
body_text = self._safe_format(template["body_text"], variables)
|
||
|
||
return EmailMessage(
|
||
to=to,
|
||
subject=subject,
|
||
body_html=body_html,
|
||
body_text=body_text,
|
||
metadata=variables,
|
||
)
|
||
|
||
def generate_alert_email(
|
||
self,
|
||
to: str,
|
||
alert_type: str,
|
||
brand_name: str,
|
||
severity: str,
|
||
description: str,
|
||
timestamp: str,
|
||
) -> EmailMessage:
|
||
"""生成告警通知邮件
|
||
|
||
Args:
|
||
to: 收件人邮箱
|
||
alert_type: 告警类型
|
||
brand_name: 品牌名称
|
||
severity: 严重程度
|
||
description: 告警详情
|
||
timestamp: 时间戳
|
||
|
||
Returns:
|
||
EmailMessage邮件消息对象
|
||
"""
|
||
variables = {
|
||
"alert_type": alert_type,
|
||
"brand_name": brand_name,
|
||
"severity": severity,
|
||
"description": description,
|
||
"timestamp": timestamp,
|
||
}
|
||
return self.render_template("alert_notification", to, variables)
|
||
|
||
def generate_quota_warning_email(
|
||
self,
|
||
to: str,
|
||
quota_type: str,
|
||
usage_percentage: int,
|
||
used: int,
|
||
limit: int,
|
||
recommended_action: str,
|
||
) -> EmailMessage:
|
||
"""生成额度预警邮件
|
||
|
||
Args:
|
||
to: 收件人邮箱
|
||
quota_type: 额度类型
|
||
usage_percentage: 使用百分比
|
||
used: 已使用量
|
||
limit: 总额度
|
||
recommended_action: 建议操作
|
||
|
||
Returns:
|
||
EmailMessage邮件消息对象
|
||
"""
|
||
variables = {
|
||
"quota_type": quota_type,
|
||
"usage_percentage": usage_percentage,
|
||
"used": used,
|
||
"limit": limit,
|
||
"recommended_action": recommended_action,
|
||
}
|
||
return self.render_template("quota_warning", to, variables)
|
||
|
||
def add_attachment(self, msg: EmailMessage, filename: str, content: bytes) -> None:
|
||
"""添加附件到邮件
|
||
|
||
Args:
|
||
msg: 邮件消息对象
|
||
filename: 附件文件名
|
||
content: 附件内容
|
||
"""
|
||
msg.attachments.append({
|
||
"filename": filename,
|
||
"content": content,
|
||
})
|
||
|
||
def _create_mime_message(self, msg: EmailMessage) -> MIMEMultipart:
|
||
"""创建MIME邮件对象
|
||
|
||
Args:
|
||
msg: EmailMessage对象
|
||
|
||
Returns:
|
||
MIMEMultipart MIME邮件对象
|
||
"""
|
||
mime_msg = MIMEMultipart()
|
||
mime_msg["From"] = self.smtp_user
|
||
mime_msg["To"] = msg.to
|
||
mime_msg["Subject"] = msg.subject
|
||
|
||
mime_msg.attach(MIMEText(msg.body_html, "html", "utf-8"))
|
||
mime_msg.attach(MIMEText(msg.body_text, "plain", "utf-8"))
|
||
|
||
for attachment in msg.attachments:
|
||
part = MIMEBase("application", "octet-stream")
|
||
part.set_payload(attachment["content"])
|
||
encoders.encode_base64(part)
|
||
part.add_header(
|
||
"Content-Disposition",
|
||
f'attachment; filename="{attachment["filename"]}"',
|
||
)
|
||
mime_msg.attach(part)
|
||
|
||
return mime_msg
|
||
|
||
def send_email(self, msg: EmailMessage) -> EmailSendResult:
|
||
"""发送邮件
|
||
|
||
Args:
|
||
msg: EmailMessage邮件消息对象
|
||
|
||
Returns:
|
||
EmailSendResult发送结果对象
|
||
"""
|
||
if not self.validate_email(msg.to):
|
||
logger.warning(f"无效的邮箱地址: {msg.to}")
|
||
return EmailSendResult(
|
||
success=False,
|
||
message_id=None,
|
||
error=f"无效的邮箱地址: {msg.to}",
|
||
retry_count=0,
|
||
)
|
||
|
||
if self.simulate_mode:
|
||
message_id = f"sim_{uuid.uuid4().hex[:8]}"
|
||
logger.info(f"[模拟模式] 邮件发送成功: {msg.to}, ID: {message_id}")
|
||
return EmailSendResult(
|
||
success=True,
|
||
message_id=message_id,
|
||
error=None,
|
||
retry_count=0,
|
||
)
|
||
|
||
retry_count = 0
|
||
last_error = None
|
||
|
||
while retry_count <= self.max_retries:
|
||
try:
|
||
logger.info(f"尝试发送邮件到 {msg.to} (尝试 {retry_count + 1}/{self.max_retries + 1})")
|
||
server = smtplib.SMTP(self.smtp_host, self.smtp_port)
|
||
server.starttls()
|
||
server.login(self.smtp_user, self.smtp_password)
|
||
|
||
mime_msg = self._create_mime_message(msg)
|
||
server.send_message(mime_msg)
|
||
server.quit()
|
||
|
||
message_id = f"smtp_{uuid.uuid4().hex[:8]}"
|
||
logger.info(f"邮件发送成功: {msg.to}, ID: {message_id}")
|
||
return EmailSendResult(
|
||
success=True,
|
||
message_id=message_id,
|
||
error=None,
|
||
retry_count=retry_count,
|
||
)
|
||
except smtplib.SMTPException as e:
|
||
last_error = f"SMTP错误: {str(e)}"
|
||
logger.error(f"SMTP错误 (尝试 {retry_count + 1}/{self.max_retries + 1}): {e}")
|
||
retry_count += 1
|
||
if retry_count <= self.max_retries:
|
||
time.sleep(1 * retry_count)
|
||
except Exception as e:
|
||
last_error = str(e)
|
||
logger.error(f"邮件发送失败 (尝试 {retry_count + 1}/{self.max_retries + 1}): {e}")
|
||
retry_count += 1
|
||
if retry_count <= self.max_retries:
|
||
time.sleep(1 * retry_count)
|
||
|
||
logger.error(f"邮件发送最终失败,已重试 {retry_count} 次: {msg.to}, 错误: {last_error}")
|
||
return EmailSendResult(
|
||
success=False,
|
||
message_id=None,
|
||
error=last_error,
|
||
retry_count=retry_count,
|
||
)
|
||
|
||
def add_to_queue(self, msg: EmailMessage) -> None:
|
||
"""添加邮件到队列
|
||
|
||
Args:
|
||
msg: EmailMessage邮件消息对象
|
||
"""
|
||
self._queue.append(msg)
|
||
logger.debug(f"邮件已添加到队列: {msg.to}, 队列长度: {len(self._queue)}")
|
||
|
||
def get_queue(self) -> list[EmailMessage]:
|
||
"""获取队列中的邮件
|
||
|
||
Returns:
|
||
邮件消息列表
|
||
"""
|
||
return self._queue.copy()
|
||
|
||
def clear_queue(self) -> None:
|
||
"""清空队列"""
|
||
count = len(self._queue)
|
||
self._queue.clear()
|
||
logger.info(f"队列已清空,移除了 {count} 封邮件")
|
||
|
||
def send_queue(self) -> list[EmailSendResult]:
|
||
"""发送队列中的所有邮件
|
||
|
||
Returns:
|
||
发送结果列表
|
||
"""
|
||
results = []
|
||
messages = self._queue.copy()
|
||
self._queue.clear()
|
||
|
||
logger.info(f"开始发送队列中的 {len(messages)} 封邮件")
|
||
|
||
for msg in messages:
|
||
result = self.send_email(msg)
|
||
results.append(result)
|
||
|
||
success_count = sum(1 for r in results if r.success)
|
||
logger.info(f"队列发送完成: 成功 {success_count}/{len(results)}")
|
||
|
||
return results
|