from __future__ import annotations import smtplib from dataclasses import asdict, dataclass from email.message import EmailMessage from pathlib import Path from typing import Any from sqlalchemy.orm import Session from app.core.secret_box import decrypt_secret from app.models.system_setting import SystemSetting from app.models.system_setting_secret import SystemSettingSecret from app.services.settings import SettingsService @dataclass(frozen=True, slots=True) class FinanceReportDeliveryResult: status: str recipients: list[str] subject: str message: str smtp_host: str attachment_name: str def to_dict(self) -> dict[str, Any]: return asdict(self) class FinanceReportMailer: def __init__(self, db: Session) -> None: self.db = db def send_report( self, *, context: dict[str, Any], pdf_path: Path, recipients: list[str] | None = None, dry_run: bool = False, ) -> FinanceReportDeliveryResult: settings_row, secrets_row = SettingsService(self.db).ensure_settings_ready() resolved_recipients = self._resolve_recipients(settings_row, recipients) subject = self._subject(context) missing = self._missing_config(settings_row, secrets_row, resolved_recipients) if missing: return FinanceReportDeliveryResult( status="pending_configuration", recipients=resolved_recipients, subject=subject, message=f"邮件未发送,缺少配置:{', '.join(missing)}。", smtp_host=str(settings_row.smtp_host or ""), attachment_name=pdf_path.name, ) if dry_run: return FinanceReportDeliveryResult( status="dry_run", recipients=resolved_recipients, subject=subject, message="邮件 dry-run 完成,未连接 SMTP。", smtp_host=str(settings_row.smtp_host or ""), attachment_name=pdf_path.name, ) password = self._decrypt_password(secrets_row) message = self._message( settings_row=settings_row, context=context, pdf_path=pdf_path, recipients=resolved_recipients, subject=subject, ) try: self._send(settings_row, message, password) except Exception as exc: return FinanceReportDeliveryResult( status="failed", recipients=resolved_recipients, subject=subject, message=f"邮件发送失败:{exc}", smtp_host=str(settings_row.smtp_host or ""), attachment_name=pdf_path.name, ) return FinanceReportDeliveryResult( status="sent", recipients=resolved_recipients, subject=subject, message="邮件已发送。", smtp_host=str(settings_row.smtp_host or ""), attachment_name=pdf_path.name, ) def _message( self, *, settings_row: SystemSetting, context: dict[str, Any], pdf_path: Path, recipients: list[str], subject: str, ) -> EmailMessage: sender_address = str( settings_row.sender_address or settings_row.smtp_username or "" ).strip() sender_name = str( settings_row.sender_name or settings_row.company_name or "X-Financial" ).strip() summary = context.get("summary") if isinstance(context.get("summary"), dict) else {} insights = list(context.get("insights") or [])[:3] body = "\n".join( [ "各位好,", "", "数字员工已生成本期财务经营报告,摘要如下:", *[f"- {item}" for item in insights], "", f"报销单数:{summary.get('reimbursement_count', 0)}", f"报销金额:¥{float(summary.get('reimbursement_amount') or 0):,.0f}", f"行动项:{summary.get('action_count', 0)}", "", "详细内容请查看附件 PDF。", ] ) message = EmailMessage() message["Subject"] = subject message["From"] = f"{sender_name} <{sender_address}>" message["To"] = ", ".join(recipients) message.set_content(body) message.add_attachment( pdf_path.read_bytes(), maintype="application", subtype="pdf", filename=pdf_path.name, ) return message @staticmethod def _resolve_recipients( settings_row: SystemSetting, override_recipients: list[str] | None, ) -> list[str]: raw_values = override_recipients or [ str(settings_row.default_receiver or ""), str(settings_row.notice_email or ""), str(settings_row.admin_email or ""), ] recipients: list[str] = [] for raw in raw_values: for item in str(raw or "").replace(";", ",").split(","): email = item.strip() if email and "@" in email and email not in recipients: recipients.append(email) return recipients @staticmethod def _missing_config( settings_row: SystemSetting, secrets_row: SystemSettingSecret, recipients: list[str], ) -> list[str]: missing: list[str] = [] if not str(settings_row.smtp_host or "").strip(): missing.append("smtp_host") if not int(settings_row.smtp_port or 0): missing.append("smtp_port") if not str(settings_row.sender_address or settings_row.smtp_username or "").strip(): missing.append("sender_address") if not str(settings_row.smtp_username or "").strip(): missing.append("smtp_username") if not str(secrets_row.smtp_password_encrypted or "").strip(): missing.append("smtp_password") if not recipients: missing.append("recipients") return missing @staticmethod def _decrypt_password(secrets_row: SystemSettingSecret) -> str: encrypted = str(secrets_row.smtp_password_encrypted or "").strip() return decrypt_secret(encrypted) if encrypted else "" @staticmethod def _subject(context: dict[str, Any]) -> str: period = context.get("period") if isinstance(context.get("period"), dict) else {} title = str(period.get("title") or "财务经营报告") label = str(period.get("label") or "").strip() return f"X-Financial {title} | {label}".strip() @staticmethod def _send(settings_row: SystemSetting, message: EmailMessage, password: str) -> None: host = str(settings_row.smtp_host or "").strip() port = int(settings_row.smtp_port or 465) username = str(settings_row.smtp_username or "").strip() encryption = str(settings_row.smtp_encryption or "").strip().lower() if "ssl" in encryption: with smtplib.SMTP_SSL(host, port, timeout=20) as smtp: smtp.login(username, password) smtp.send_message(message) return with smtplib.SMTP(host, port, timeout=20) as smtp: if "tls" in encryption or "starttls" in encryption: smtp.starttls() smtp.login(username, password) smtp.send_message(message)