Files
palladium-monitor/mailer.py
T
Your Name 6726524c50 fix: db.get_or_404 -> query.get_or_404 + rename to Palladium Monitor
- 修复删除快照时 AttributeError: SQLAlchemy.get_or_404 (低版本兼容)
- UI/标题/邮件主题从 'Palladium Z1 Monitor' 改为 'Palladium Monitor'
2026-07-08 14:46:18 +08:00

646 lines
27 KiB
Python

from typing import List, Dict, Tuple, Set, Any, Optional, Union
"""
mailer.py — Palladium 每日报告邮件发送
通过 SMTP 把"今日快照 + 24h 趋势"汇总发送到指定邮箱。
所有配置都从环境变量读 (生产建议用 systemd EnvironmentFile 或 .env)。
"""
import os
import smtplib
import socket
import ssl
from datetime import datetime, timezone, timedelta
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formataddr, format_datetime
from app import (
ScanSnapshot, BoardStatus, JobInfo, db, app,
DOMAIN_LABELS, DOMAIN_COLORS,
)
# ═══════════════════════════════════════════════════════════════════════════
# 配置 (从环境变量读)
# ═══════════════════════════════════════════════════════════════════════════
def _bool(name: str, default: bool = False) -> bool:
v = os.environ.get(name, "").strip().lower()
if v in ("1", "true", "yes", "y", "on"):
return True
if v in ("0", "false", "no", "n", "off", ""):
return False
return default
def _int_env(name: str, default: int) -> int:
try:
return int(os.environ.get(name, "").strip())
except (TypeError, ValueError):
return default
# ═══════════════════════════════════════════════════════════════════════════
# 数据库配置持久化
# ═══════════════════════════════════════════════════════════════════════════
def _get_email_config_model():
"""惰性获取 EmailConfig 模型 (避免循环导入)。"""
try:
from app import EmailConfig
return EmailConfig
except (ImportError, AttributeError):
return None
def _read_db_config() -> Optional[Dict[str, str]]:
"""从 EmailConfig 表读取配置, 返回 {key: value} dict, 无记录返回 None。"""
Model = _get_email_config_model()
if not Model:
return None
try:
with app.app_context():
rows = Model.query.all()
if not rows:
return None
return {r.key: r.value for r in rows}
except Exception:
return None
def _build_config_from_env() -> dict:
"""从环境变量读取配置 (原始逻辑)。"""
return {
"enabled": _bool("MAIL_ENABLED", False),
"smtp_host": os.environ.get("SMTP_HOST", "").strip(),
"smtp_port": _int_env("SMTP_PORT", 587),
"smtp_user": os.environ.get("SMTP_USER", "").strip(),
"smtp_password_set": bool(os.environ.get("SMTP_PASSWORD", "").strip()),
"smtp_use_tls": _bool("SMTP_USE_TLS", True),
"smtp_use_ssl": _bool("SMTP_USE_SSL", False),
"from_addr": os.environ.get("MAIL_FROM", "").strip(),
"from_name": os.environ.get("MAIL_FROM_NAME", "Palladium Monitor").strip(),
"to_addrs": [a.strip() for a in os.environ.get("MAIL_TO", "").split(",") if a.strip()],
"send_hour": _int_env("MAIL_SEND_HOUR", 9),
"send_minute": _int_env("MAIL_SEND_MINUTE", 0),
"monitor_base_url": os.environ.get("MONITOR_BASE_URL", "http://127.0.0.1:5100").strip(),
}
def _build_config_from_dict(raw: Dict[str, str]) -> dict:
"""从数据库 {key: value} dict 构建配置。"""
def _s(key: str, default: str = "") -> str:
return raw.get(key, default).strip()
def _b(key: str, default: bool) -> bool:
v = raw.get(key, "").strip().lower()
return v in ("1", "true", "yes", "y", "on")
def _i(key: str, default: int) -> int:
try:
return int(raw.get(key, str(default)))
except (TypeError, ValueError):
return default
return {
"enabled": _b("enabled", False),
"smtp_host": _s("smtp_host"),
"smtp_port": _i("smtp_port", 587),
"smtp_user": _s("smtp_user"),
"smtp_password_set": bool(_s("smtp_password")),
"smtp_use_tls": _b("smtp_use_tls", True),
"smtp_use_ssl": _b("smtp_use_ssl", False),
"from_addr": _s("from_addr"),
"from_name": _s("from_name") or "Palladium Monitor",
"to_addrs": [a.strip() for a in _s("to_addrs").split(",") if a.strip()],
"send_hour": _i("send_hour", 9),
"send_minute": _i("send_minute", 0),
"monitor_base_url": _s("monitor_base_url") or "http://127.0.0.1:5100",
}
def save_config(data: dict) -> int:
"""保存配置到数据库。返回保存的键数。"""
Model = _get_email_config_model()
if not Model:
raise RuntimeError("EmailConfig 模型不可用")
to_addrs = data.get("to_addrs")
if isinstance(to_addrs, list):
to_addrs_str = ",".join(str(x) for x in to_addrs if x)
else:
to_addrs_str = str(to_addrs or "")
save_fields = {
"enabled": str(data.get("enabled", False)).lower(),
"smtp_host": str(data.get("smtp_host", "")).strip(),
"smtp_port": str(data.get("smtp_port", 587)),
"smtp_user": str(data.get("smtp_user", "")).strip(),
"smtp_password": str(data.get("smtp_password", "")),
"smtp_use_tls": str(data.get("smtp_use_tls", True)).lower(),
"smtp_use_ssl": str(data.get("smtp_use_ssl", False)).lower(),
"from_addr": str(data.get("from_addr", "")).strip(),
"from_name": str(data.get("from_name", "Palladium Monitor")).strip(),
"to_addrs": to_addrs_str.strip(),
"send_hour": str(data.get("send_hour", 9)),
"send_minute": str(data.get("send_minute", 0)),
"monitor_base_url": str(data.get("monitor_base_url", "http://127.0.0.1:5100")).strip(),
}
count = 0
with app.app_context():
for key, value in save_fields.items():
row = Model.query.filter_by(key=key).first()
if row:
row.value = value
else:
db.session.add(Model(key=key, value=value))
count += 1
db.session.commit()
return count
def reset_config() -> bool:
"""清除数据库配置, 回退到环境变量。"""
Model = _get_email_config_model()
if not Model:
return False
with app.app_context():
Model.query.delete()
db.session.commit()
return True
def get_config() -> dict:
"""读取邮件配置。优先使用数据库存储的配置, 回退到环境变量。"""
db_raw = _read_db_config()
if db_raw is not None:
return _build_config_from_dict(db_raw)
return _build_config_from_env()
def get_config_full() -> dict:
"""读取完整配置 (含密码原始值)。优先 DB, 回退到环境变量。"""
cfg = get_config()
db_raw = _read_db_config()
if db_raw is not None:
cfg["smtp_password"] = db_raw.get("smtp_password", "").strip()
else:
cfg["smtp_password"] = os.environ.get("SMTP_PASSWORD", "").strip()
return cfg
def config_errors(cfg: dict) -> List[str]:
"""检查配置是否完整可发送。返回缺失/错误项的列表 (空=OK)。"""
errs = []
if not cfg["enabled"]:
return errs # 禁用时不做任何检查
if not cfg["smtp_host"]:
errs.append("SMTP_HOST 未设置")
if not cfg["smtp_user"]:
errs.append("SMTP_USER 未设置")
if not cfg["smtp_password_set"]:
errs.append("SMTP_PASSWORD 未设置")
if not cfg["from_addr"]:
errs.append("MAIL_FROM 未设置")
if not cfg["to_addrs"]:
errs.append("MAIL_TO 未设置 (逗号分隔多个地址)")
if not (0 <= cfg["send_hour"] <= 23):
errs.append(f"MAIL_SEND_HOUR 越界: {cfg['send_hour']}")
if not (0 <= cfg["send_minute"] <= 59):
errs.append(f"MAIL_SEND_MINUTE 越界: {cfg['send_minute']}")
return errs
# ═══════════════════════════════════════════════════════════════════════════
# 数据收集
# ═══════════════════════════════════════════════════════════════════════════
def collect_report_data() -> dict:
"""
拉取邮件所需的所有数据。
返回 dict 包含: latest, boards_offline, jobs_active, stats_24h, samples_24h。
"""
now = datetime.now(timezone.utc)
cutoff_24h = now - timedelta(hours=24)
cutoff_yesterday = now - timedelta(hours=48)
# ── 最新快照 ──
latest = ScanSnapshot.query.order_by(ScanSnapshot.timestamp.desc()).first()
if not latest:
return {
"latest": None,
"boards_offline": [],
"jobs_active": [],
"stats_24h": None,
"samples_24h": [],
"now_utc": now,
"now_local": datetime.now(),
}
# ── 离线板列表 ──
boards_offline = BoardStatus.query.filter_by(
snapshot_id=latest.id, status="OFFLINE"
).order_by(BoardStatus.rack, BoardStatus.cluster, BoardStatus.ld_index).all()
# ── 活跃作业 ──
jobs_active = JobInfo.query.filter_by(snapshot_id=latest.id).order_by(JobInfo.job_index).all()
# ── 24h 统计 ──
snaps_24h = ScanSnapshot.query.filter(
ScanSnapshot.timestamp >= cutoff_24h
).order_by(ScanSnapshot.timestamp.asc()).all()
if snaps_24h:
utils = [s.utilization for s in snaps_24h]
used_counts = [s.used_boards for s in snaps_24h]
stats_24h = {
"count": len(snaps_24h),
"util_min": min(utils),
"util_max": max(utils),
"util_avg": round(sum(utils) / len(utils), 1),
"used_min": min(used_counts),
"used_max": max(used_counts),
"used_avg": round(sum(used_counts) / len(used_counts), 1),
"first_ts": snaps_24h[0].timestamp,
"last_ts": snaps_24h[-1].timestamp,
}
# 趋势样本: 最多 12 个等距点 (避免邮件太长)
if len(snaps_24h) > 12:
step = len(snaps_24h) // 12
samples = snaps_24h[::step]
if samples[-1] != snaps_24h[-1]:
samples.append(snaps_24h[-1])
else:
samples = snaps_24h
samples_24h = samples
else:
stats_24h = None
samples_24h = []
return {
"latest": latest,
"boards_offline": boards_offline,
"jobs_active": jobs_active,
"stats_24h": stats_24h,
"samples_24h": samples_24h,
"now_utc": now,
"now_local": datetime.now(),
}
# ═══════════════════════════════════════════════════════════════════════════
# 模板渲染
# ═══════════════════════════════════════════════════════════════════════════
def _fmt_ts(ts, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
if ts is None:
return ""
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
return ts.astimezone().strftime(fmt)
def _util_color_class(util: float) -> str:
"""根据利用率返回 CSS class 名 (用于 HTML 颜色条)。"""
if util >= 80:
return "util-high"
if util >= 50:
return "util-mid"
return "util-low"
def _render_text(data: dict, base_url: str) -> str:
"""纯文本版本 (HTML 不支持时回退)。"""
latest = data["latest"]
if not latest:
return "Palladium Monitor — 当前无快照数据, 无法生成报告。\n"
lines = []
title_date = _fmt_ts(latest.timestamp, "%Y-%m-%d")
lines.append(f"Palladium 每日报告 — {title_date}")
lines.append("=" * 60)
lines.append(f"生成时间: {_fmt_ts(data['now_utc'])} UTC")
lines.append(f"快照时间: {_fmt_ts(latest.timestamp)}")
lines.append("")
# 系统信息
lines.append("【系统信息】")
lines.append(f" 仿真器: {latest.emulator or ''}")
lines.append(f" 硬件: {latest.hardware or ''}")
lines.append(f" ConfigMgr:{latest.configmgr or ''}")
lines.append(f" 系统状态: {latest.system_status or ''}")
lines.append(f" 数据来源: {latest.source or ''}")
lines.append("")
# 当前指标
lines.append("【当前指标】")
lines.append(f" 逻辑板总数: {latest.total_boards}")
lines.append(f" 在线: {latest.online_boards}")
lines.append(f" 离线: {latest.offline_boards}")
lines.append(f" 利用率: {latest.utilization:.1f}% (使用 {latest.used_boards}/{latest.total_boards})")
lines.append(f" 活跃作业: {latest.active_jobs}")
lines.append("")
# 24h 趋势
if data["stats_24h"]:
s = data["stats_24h"]
lines.append("【近 24 小时趋势】")
lines.append(f" 快照数: {s['count']}")
lines.append(f" 利用率: min={s['util_min']:.1f}% max={s['util_max']:.1f}% avg={s['util_avg']:.1f}%")
lines.append(f" 使用板: min={s['used_min']} max={s['used_max']} avg={s['used_avg']:.1f}")
lines.append("")
lines.append(" 趋势样本 (最多 12 个):")
for s in data["samples_24h"]:
bar = "" * int(round(s.utilization / 5)) # 1 个 █ = 5%
lines.append(f" {_fmt_ts(s.timestamp, '%m-%d %H:%M')} {s.utilization:5.1f}% {bar}")
lines.append("")
# 离线板
if data["boards_offline"]:
lines.append(f"【离线板列表】 ({len(data['boards_offline'])} 块)")
for b in data["boards_offline"]:
lines.append(f" Rack {b.rack} / Cluster {b.cluster} / LD {b.ld_index} CCD={b.ccd or '?'}")
lines.append("")
else:
lines.append("【离线板列表】 无 (全部在线)")
lines.append("")
# 活跃作业
if data["jobs_active"]:
lines.append(f"【活跃作业】 ({len(data['jobs_active'])} 个)")
for j in data["jobs_active"]:
t_pod = j.t_pod or ""
lines.append(f" #{j.job_index} {j.owner} {j.pid} T-Pod: {t_pod} {j.design or ''} ({j.elap_time or ''})")
lines.append("")
lines.append("" * 60)
lines.append(f"查看完整仪表板: {base_url}/")
return "\n".join(lines)
def _render_html(data: dict, base_url: str) -> str:
"""HTML 版本 (multipart/alternative 中优先使用)。"""
latest = data["latest"]
if not latest:
return (
'<html><body style="font-family:sans-serif;padding:20px">'
'<h2>Palladium Monitor</h2>'
'<p>当前无快照数据, 无法生成报告。</p>'
'</body></html>'
)
title_date = _fmt_ts(latest.timestamp, "%Y-%m-%d")
util_class = _util_color_class(latest.utilization)
util_bar_width = f"{min(latest.utilization, 100):.1f}%"
# 离线板 HTML
if data["boards_offline"]:
offline_rows = "".join(
f"<tr><td>Rack {b.rack}</td><td>Cluster {b.cluster}</td>"
f"<td>LD {b.ld_index}</td><td>{b.ccd or ''}</td></tr>"
for b in data["boards_offline"]
)
offline_html = f"""
<table style="border-collapse:collapse;margin-top:6px">
<thead><tr style="background:#dc3545;color:#fff">
<th style="padding:6px 12px">Rack</th>
<th style="padding:6px 12px">Cluster</th>
<th style="padding:6px 12px">LD</th>
<th style="padding:6px 12px">CCD</th>
</tr></thead>
<tbody>{offline_rows}</tbody>
</table>"""
else:
offline_html = '<p style="color:#28a745;margin:6px 0">✓ 无离线板, 全部在线</p>'
# 作业 HTML
if data["jobs_active"]:
job_rows = "".join(
f"<tr><td>#{j.job_index}</td><td>{j.owner}</td><td>{j.pid}</td>"
f"<td>{j.t_pod or ''}</td><td>{j.design or ''}</td><td>{j.elap_time or ''}</td></tr>"
for j in data["jobs_active"]
)
jobs_html = f"""
<table style="border-collapse:collapse;margin-top:6px">
<thead><tr style="background:#007bff;color:#fff">
<th style="padding:6px 12px">#</th>
<th style="padding:6px 12px">用户</th>
<th style="padding:6px 12px">PID</th>
<th style="padding:6px 12px">T-Pod</th>
<th style="padding:6px 12px">设计</th>
<th style="padding:6px 12px">已运行</th>
</tr></thead>
<tbody>{job_rows}</tbody>
</table>"""
else:
jobs_html = '<p style="color:#888;margin:6px 0">无活跃作业</p>'
# 24h 趋势 HTML
if data["stats_24h"]:
s = data["stats_24h"]
sample_rows = "".join(
f'<tr><td style="padding:4px 12px;font-family:monospace">{_fmt_ts(smp.timestamp, "%m-%d %H:%M")}</td>'
f'<td style="padding:4px 12px;text-align:right;font-weight:bold">{smp.utilization:.1f}%</td>'
f'<td style="padding:4px 12px">'
f'<div style="background:#e0e0e0;height:14px;width:200px;border-radius:2px">'
f'<div style="background:{DOMAIN_COLORS.get("downloaded", "#007bff")};'
f'height:14px;width:{min(smp.utilization, 100):.1f}%;border-radius:2px"></div>'
f'</div></td></tr>'
for smp in data["samples_24h"]
)
trend_html = f"""
<table style="border-collapse:collapse;width:100%;margin-top:6px">
<tr><td style="padding:4px 12px">快照数</td><td style="padding:4px 12px"><b>{s['count']}</b></td></tr>
<tr style="background:#f5f5f5"><td style="padding:4px 12px">利用率最小/最大/平均</td>
<td style="padding:4px 12px"><b>{s['util_min']:.1f}%</b> / <b>{s['util_max']:.1f}%</b> / <b>{s['util_avg']:.1f}%</b></td></tr>
<tr><td style="padding:4px 12px">使用板数最小/最大/平均</td>
<td style="padding:4px 12px"><b>{s['used_min']}</b> / <b>{s['used_max']}</b> / <b>{s['used_avg']:.1f}</b></td></tr>
</table>
<h4 style="margin:14px 0 4px">趋势样本</h4>
<table style="border-collapse:collapse;width:100%">
<thead><tr style="background:#f5f5f5">
<th style="padding:6px 12px;text-align:left">时间</th>
<th style="padding:6px 12px;text-align:right">利用率</th>
<th style="padding:6px 12px;text-align:left">图示</th>
</tr></thead>
<tbody>{sample_rows}</tbody>
</table>"""
else:
trend_html = '<p style="color:#888;margin:6px 0">近 24 小时无快照数据</p>'
return f"""
<html>
<head><meta charset="UTF-8"></head>
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
background:#f5f5f7;padding:0;margin:0;color:#222">
<div style="max-width:720px;margin:20px auto;background:#fff;border-radius:8px;
box-shadow:0 2px 8px rgba(0,0,0,0.08);overflow:hidden">
<!-- Header -->
<div style="background:linear-gradient(135deg,#007bff,#0056b3);color:#fff;padding:24px 28px">
<h1 style="margin:0;font-size:22px">⚡ Palladium 每日报告</h1>
<div style="margin-top:6px;opacity:0.9;font-size:14px">{title_date}</div>
</div>
<div style="padding:24px 28px">
<!-- 系统信息 -->
<h2 style="font-size:16px;margin:0 0 10px;color:#555">📋 系统信息</h2>
<table style="border-collapse:collapse;width:100%;font-size:14px">
<tr><td style="padding:5px 0;color:#888;width:90px">仿真器</td><td><b>{latest.emulator or ''}</b></td></tr>
<tr><td style="padding:5px 0;color:#888">硬件</td><td>{latest.hardware or ''}</td></tr>
<tr><td style="padding:5px 0;color:#888">ConfigMgr</td><td>{latest.configmgr or ''}</td></tr>
<tr><td style="padding:5px 0;color:#888">系统状态</td><td>{latest.system_status or ''}</td></tr>
<tr><td style="padding:5px 0;color:#888">数据来源</td><td><code>{latest.source or ''}</code></td></tr>
<tr><td style="padding:5px 0;color:#888">快照时间</td><td>{_fmt_ts(latest.timestamp)}</td></tr>
</table>
<!-- 当前指标 -->
<h2 style="font-size:16px;margin:20px 0 10px;color:#555">📊 当前指标</h2>
<div style="display:flex;gap:12px;flex-wrap:wrap">
<div style="flex:1;min-width:140px;background:#f8f9fa;padding:14px;border-radius:6px;border-left:4px solid #6c757d">
<div style="color:#888;font-size:12px">逻辑板总数</div>
<div style="font-size:24px;font-weight:bold;margin-top:4px">{latest.total_boards}</div>
<div style="color:#28a745;font-size:12px">在线 {latest.online_boards}</div>
<div style="color:#dc3545;font-size:12px">离线 {latest.offline_boards}</div>
</div>
<div style="flex:1;min-width:200px;background:#f8f9fa;padding:14px;border-radius:6px;border-left:4px solid {DOMAIN_COLORS.get('downloaded')}">
<div style="color:#888;font-size:12px">逻辑板利用率</div>
<div style="font-size:24px;font-weight:bold;margin-top:4px;color:#007bff">{latest.utilization:.1f}%</div>
<div style="background:#e0e0e0;height:8px;border-radius:4px;margin-top:6px">
<div style="background:{DOMAIN_COLORS.get('downloaded')};height:8px;width:{util_bar_width};border-radius:4px"></div>
</div>
<div style="font-size:12px;margin-top:4px">使用 {latest.used_boards} / 空闲 {latest.online_boards - latest.used_boards}</div>
</div>
<div style="flex:1;min-width:140px;background:#f8f9fa;padding:14px;border-radius:6px;border-left:4px solid #28a745">
<div style="color:#888;font-size:12px">活跃作业</div>
<div style="font-size:24px;font-weight:bold;margin-top:4px">{latest.active_jobs}</div>
</div>
</div>
<!-- 24h 趋势 -->
<h2 style="font-size:16px;margin:20px 0 10px;color:#555">📈 近 24 小时趋势</h2>
{trend_html}
<!-- 离线板 -->
<h2 style="font-size:16px;margin:20px 0 10px;color:#555">🔴 离线板</h2>
{offline_html}
<!-- 活跃作业 -->
<h2 style="font-size:16px;margin:20px 0 10px;color:#555">⚙️ 活跃作业</h2>
{jobs_html}
</div>
<!-- Footer -->
<div style="background:#f8f9fa;padding:16px 28px;border-top:1px solid #e0e0e0;
font-size:12px;color:#888;text-align:center">
由 <a href="{base_url}/" style="color:#007bff;text-decoration:none">Palladium Monitor</a> 自动生成 ·
{_fmt_ts(data['now_utc'], '%Y-%m-%d %H:%M:%S')} UTC
</div>
</div>
</body>
</html>"""
# ═══════════════════════════════════════════════════════════════════════════
# 发送
# ═══════════════════════════════════════════════════════════════════════════
def render_report(data: dict, cfg: dict) -> Tuple[str, str, str]:
"""返回 (subject, html, text)。"""
latest = data["latest"]
if latest:
title_date = _fmt_ts(latest.timestamp, "%Y-%m-%d")
else:
title_date = _fmt_ts(data["now_utc"], "%Y-%m-%d")
util = f"{latest.utilization:.1f}%" if latest else ""
subject = f"[Palladium] 每日报告 {title_date} — 利用率 {util}"
html = _render_html(data, cfg["monitor_base_url"])
text = _render_text(data, cfg["monitor_base_url"])
return subject, html, text
def _send_smtp(cfg: dict, subject: str, html: str, text: str) -> Tuple[bool, str]:
"""实际 SMTP 发送, 返回 (ok, error_msg)。"""
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = formataddr((cfg["from_name"], cfg["from_addr"]))
msg["To"] = ", ".join(cfg["to_addrs"])
msg["Date"] = format_datetime(datetime.now(timezone.utc))
msg.attach(MIMEText(text, "plain", "utf-8"))
msg.attach(MIMEText(html, "html", "utf-8"))
password = cfg.get("smtp_password", "")
if not password:
password = os.environ.get("SMTP_PASSWORD", "")
try:
if cfg["smtp_use_ssl"]:
# SMTP_SSL (port 465)
ctx = ssl.create_default_context()
with smtplib.SMTP_SSL(cfg["smtp_host"], cfg["smtp_port"], context=ctx, timeout=30) as s:
s.login(cfg["smtp_user"], password)
s.sendmail(cfg["from_addr"], cfg["to_addrs"], msg.as_string())
else:
# STARTTLS (port 587) 或 明文 (port 25)
with smtplib.SMTP(cfg["smtp_host"], cfg["smtp_port"], timeout=30) as s:
s.ehlo()
if cfg["smtp_use_tls"]:
s.starttls()
s.ehlo()
s.login(cfg["smtp_user"], password)
s.sendmail(cfg["from_addr"], cfg["to_addrs"], msg.as_string())
return (True, "")
except smtplib.SMTPAuthenticationError as e:
# Python 3.12+: smtp_error is str; older: bytes
smtp_err = e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else (e.smtp_error or "")
return (False, f"SMTP 认证失败: {e.smtp_code} {smtp_err}")
except smtplib.SMTPException as e:
return (False, f"SMTP 错误: {e}")
except (socket.timeout, ConnectionError, OSError) as e:
return (False, f"网络错误: {e}")
except Exception as e:
return (False, f"{type(e).__name__}: {e}")
def send_report(cfg: Optional[Dict] = None) -> dict:
"""
收集数据 → 渲染 → 发送。
返回: {"ok": bool, "subject": str, "recipients": [...], "error": str, "preview_text": str}
"""
cfg = cfg or get_config_full()
if not cfg["enabled"]:
return {"ok": False, "error": "邮件功能未启用 (MAIL_ENABLED 未设 true)", "subject": "", "recipients": []}
errs = config_errors(cfg)
if errs:
return {"ok": False, "error": "配置不完整: " + "; ".join(errs), "subject": "", "recipients": []}
with app.app_context():
data = collect_report_data()
subject, html, text = render_report(data, cfg)
ok, err = _send_smtp(cfg, subject, html, text)
return {
"ok": ok,
"subject": subject,
"recipients": cfg["to_addrs"],
"error": err,
"preview_text": text[:500] + ("..." if len(text) > 500 else ""),
"bytes": len(text) + len(html),
}
def write_preview(output_path: str) -> str:
"""把渲染好的报告写到文件 (不发送, 仅供调试)。返回写入路径。"""
cfg = get_config()
with app.app_context():
data = collect_report_data()
subject, html, text = render_report(data, cfg)
with open(output_path, "w", encoding="utf-8") as f:
f.write(f"Subject: {subject}\n")
f.write(f"To: {', '.join(cfg['to_addrs'])}\n")
f.write(f"From: {cfg['from_name']} <{cfg['from_addr']}>\n")
f.write("\n--- TEXT VERSION ---\n")
f.write(text)
f.write("\n\n--- HTML VERSION ---\n")
f.write(html)
return output_path