geo/backend/app/services/email_service.py

383 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
邮件通知服务
支持发送告警通知、额度预警等邮件。
功能:
- 邮件模板引擎: 变量替换渲染邮件内容
- 邮件内容生成: 告警通知、额度预警邮件生成
- 邮件发送: 支持真实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