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

告警通知

品牌:{brand_name}

告警类型:{alert_type}

严重程度:{severity}

详情:{description}

时间:{timestamp}

""", "body_text": "告警通知 - 品牌:{brand_name}, 类型:{alert_type}, severity:{severity}" }, "quota_warning": { "subject": "[GEO平台] 额度预警:{quota_type}", "body_html": """

额度预警

您的{quota_type}使用量已达到{usage_percentage}%

已使用:{used} / 总额度:{limit}

建议操作:{recommended_action}

""", "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