Files

285 lines
12 KiB
Python

# -*- coding: utf-8 -*-
"""
新闻智能跟踪系统 - 邮件通知
支持 plain / starttls / ssl 三种 SMTP 模式,发送实时重要资讯与每日汇总。
"""
import smtplib
from datetime import datetime
from email.header import Header
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formataddr, formatdate
import config
import db
def get_mail_cfg():
cfg = dict(config.MAIL_DEFAULTS)
cfg.update(db.get_all_settings().get("mail", {}))
return cfg
def send_email(subject, html, to=None):
cfg = get_mail_cfg()
to = to or cfg["email_to"]
msg = MIMEMultipart("alternative")
msg["From"] = formataddr((str(Header(cfg.get("sender_name", "新闻智能跟踪"), "utf-8")), cfg["smtp_user"]))
msg["To"] = to
msg["Subject"] = Header(subject, "utf-8")
# mail.tphai.com 的 amavisd 强制要求 Date 头,缺失会退信(554 5.6.0 BAD HEADER)
msg["Date"] = formatdate(localtime=True)
msg.attach(MIMEText(html, "html", "utf-8"))
mode = cfg.get("smtp_mode", "plain")
if mode == "ssl":
server = smtplib.SMTP_SSL(cfg["smtp_host"], int(cfg["smtp_port"]), timeout=20)
else:
server = smtplib.SMTP(cfg["smtp_host"], int(cfg["smtp_port"]), timeout=20)
if mode == "starttls":
server.starttls()
try:
server.login(cfg["smtp_user"], cfg["smtp_pass"])
server.sendmail(cfg["smtp_user"], [to], msg.as_string())
finally:
server.quit()
return True
# ============ 系统错误邮件通知(频率 + 静默时段) ============
def _esc(s):
return str(s or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
def _errnotify_cfg():
cfg = dict(config.ERRNOTIFY_DEFAULTS)
cfg.update(db.get_all_settings().get("errnotify", {}))
return cfg
def _in_quiet_period(now=None):
"""是否处于静默时段。支持多段、跨午夜(如 23:00-07:30)。"""
cfg = _errnotify_cfg()
if not cfg.get("quiet_enabled"):
return False
now = now or datetime.now()
cur_min = now.hour * 60 + now.minute
for p in cfg.get("quiet_periods") or []:
p = (p or "").strip()
if not p or "-" not in p:
continue
try:
s, e = p.split("-")
sh, sm = map(int, s.strip().split(":"))
eh, em = map(int, e.strip().split(":"))
except Exception:
continue
s_min, e_min = sh * 60 + sm, eh * 60 + em
if s_min <= e_min:
if s_min <= cur_min < e_min:
return True
else: # 跨午夜
if cur_min >= s_min or cur_min < e_min:
return True
return False
def _send_error_digest(errors):
"""发送一封错误通知邮件(受调用方冷却/静默控制),并记录通知日志"""
rows = "".join(
f"<div style='border:1px solid #fecaca;border-left:4px solid #dc2626;border-radius:6px;"
f"padding:10px 14px;margin:8px 0;'>"
f"<div style='font-weight:bold;color:#b91c1c;'>{_esc(e.get('source', ''))} · 发生 {e.get('count', 1)} 次</div>"
f"<div style='font-size:13px;color:#374151;margin-top:2px;'>{_esc(e.get('message', ''))}</div>"
f"<div style='font-size:12px;color:#6b7280;'>{_esc(e.get('detail', ''))}</div>"
f"<div style='font-size:11px;color:#9ca3af;'>最近: {_esc(e.get('last_seen', ''))} · "
f"首次: {_esc(e.get('first_seen', ''))}</div></div>"
for e in errors
)
subject = f"⚠️ 系统异常通知 · {len(errors)} 类错误"
html = _html_wrap(
"系统运行异常提醒",
f"<p style='color:#374151;font-size:13px;'>新闻智能跟踪系统检测到以下异常,请及时处理:</p>{rows}",
)
send_email(subject, html)
db.add_log("error", subject, len(errors), [], status="ok", detail="系统错误邮件通知", body=html)
def _try_send_pending():
"""按通知策略发送待通知错误(受静默时段 + 冷却频率控制)。返回发送条数。"""
cfg = _errnotify_cfg()
if not cfg.get("enabled") or cfg.get("mode") == "off":
return 0
if _in_quiet_period():
return 0 # 静默时段不发,等待调度器在静默结束后 flush
# 冷却频率:两次错误邮件最小间隔
last_sent = db.get_err_last_send()
if last_sent:
try:
lt = datetime.strptime(last_sent, "%Y-%m-%d %H:%M:%S")
cooldown = max(0, int(cfg.get("cooldown_min", 60) or 0))
if cooldown > 0 and (datetime.now() - lt).total_seconds() < cooldown * 60:
return 0 # 冷却中
except Exception:
pass
errors = db.pending_system_errors(limit=int(cfg.get("max_items", 10) or 10))
if not errors:
return 0
try:
_send_error_digest(errors)
except Exception:
# 邮件发送失败:保留 pending,下次重试
return 0
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
db.mark_errors_notified([e["id"] for e in errors], now)
db.set_err_last_send(now)
return len(errors)
def report_error(source, message, detail=""):
"""记录一条系统错误;immediate 模式下尝试立即发送(受冷却/静默控制)。"""
try:
db.add_system_error(source, message, detail)
except Exception:
return None
cfg = _errnotify_cfg()
if not cfg.get("enabled") or cfg.get("mode") not in ("immediate", "cooldown"):
return None
if cfg.get("mode") == "immediate":
try:
_try_send_pending()
except Exception:
pass
return True
def flush_pending_errors():
"""后台调度器定时调用:把待通知错误按策略发送(冷却 + 静默控制)"""
return _try_send_pending()
def test_error_notify():
"""发送一封测试错误邮件(无视冷却/静默,用于设置页测试按钮)"""
_send_error_digest([{
"source": "测试", "message": "这是一封测试错误通知邮件",
"detail": "如果你收到了这封邮件,说明系统错误邮件通知链路正常。",
"count": 1, "first_seen": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"last_seen": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}])
return True
def _score_color(score):
if score >= 80:
return "#e74c3c"
if score >= 60:
return "#e67e22"
return "#7f8c8d"
def _card(art):
ents = "、".join(art.get("entities") or [])
src = db.get_source(art.get("source_id") or 0) if art.get("source_id") else None
custom = bool(src and src.get("kind") == "custom")
badge = ('<span style="background:#ede9fe;color:#6d28d9;padding:2px 8px;'
'border-radius:6px;font-size:11px;margin-right:6px;">🎯 定制监控命中</span>') if custom else ""
return f"""
<div style="border:1px solid #e5e7eb;border-left:4px solid {_score_color(art.get('total_score',0))};
border-radius:6px;padding:10px 14px;margin:8px 0;">
<div style="font-size:14px;font-weight:bold;color:#1f2937;">
{badge}<a href="{art.get('url','#')}" style="color:#1f2937;text-decoration:none;">{art['title']}</a>
</div>
<div style="font-size:12px;color:#6b7280;margin:4px 0;">
综合分 {art.get('total_score',0)} · 相关度 {art.get('relevance',0)} ·
{art.get('source_name','')} · {art.get('published_at','')}
</div>
<div style="font-size:13px;color:#374151;">{art.get('summary') or art.get('content','')[:120]}</div>
<div style="font-size:12px;color:#9ca3af;">涉及:{ents if ents else '—'}</div>
{('<div style="font-size:12px;color:#b45309;background:#fef3c7;padding:4px 8px;border-radius:4px;margin-top:4px;">💡 ' + art.get('analysis','') + '</div>') if art.get('analysis') else ''}
</div>"""
def _html_wrap(title, body):
return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"></head>
<body style="font-family:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;background:#f9fafb;padding:20px;">
<div style="max-width:720px;margin:0 auto;background:#ffffff;border-radius:10px;padding:24px;box-shadow:0 1px 3px rgba(0,0,0,.08);">
<h2 style="margin:0 0 4px;color:#111827;">{title}</h2>
{body}
<div style="margin-top:24px;padding-top:12px;border-top:1px solid #e5e7eb;font-size:12px;color:#9ca3af;">
由 新闻智能跟踪系统 自动生成 · {config.SERVICE_NAME}
</div>
</div>
</body></html>"""
def send_realtime(articles):
"""实时重要资讯通知(一次一批)"""
if not articles:
return 0
cards = "".join(_card(a) for a in articles)
subject = f"🔥 重要AI资讯 {len(articles)}条 · {articles[0]['published_at'][:16]}"
html = _html_wrap(
"重要资讯实时提醒",
f"<p style='color:#374151;font-size:13px;'>以下 {len(articles)} 条资讯对您重点关注领域很重要,已自动甄别:</p>{cards}",
)
send_email(subject, html)
ids = [a["id"] for a in articles]
for aid in ids:
db.update_article(aid, notified=1)
db.add_log("realtime", subject, len(articles), ids, status="ok", detail="实时通知", body=html)
return len(articles)
def send_daily_summary(articles, window_label):
"""新闻机制:每日汇总(默认每天10点)——普通源的重要/相关资讯"""
if not articles:
return 0
top = articles[: int(db.get_setting("max_summary_items", config.AUTO_DEFAULTS["max_summary_items"]))]
cards = "".join(_card(a) for a in top)
# 分领域统计
from collections import Counter
dom_cnt = Counter(a.get("domain") or "未分类" for a in articles)
stats = " · ".join(f"{k} {v}条" for k, v in dom_cnt.most_common(6))
subject = f"📰 AI资讯日报 {window_label} · 共{len(articles)}条 重点{len(top)}条"
html = _html_wrap(
"AI 重要资讯日报",
f"""
<p style="color:#374151;font-size:13px;">汇总时段:{window_label}</p>
<p style="color:#6b7280;font-size:12px;">领域分布:{stats}</p>
<p style="color:#374151;font-size:13px;margin-top:16px;">重点资讯(按综合分排序):</p>
{cards}
""",
)
send_email(subject, html)
ids = [a["id"] for a in articles if a["id"]]
db.add_log("summary", subject, len(articles), ids, status="ok", detail=f"汇总{len(top)}条", body=html)
for aid in ids:
art = db.get_article(aid)
if art and art.get("status") != "summarized":
db.update_article(aid, status="summarized")
return len(top)
def send_custom_summary(articles, window_label):
"""定制监控机制:独立汇总邮件——定制源在窗口内命中推送标准的资讯(单独配置/单独时间)"""
if not articles:
return 0
top = articles[: int(db.get_setting("custom_max_summary_items",
config.CUSTOM_DEFAULTS["custom_max_summary_items"]))]
cards = "".join(_card(a) for a in top)
subject = f"🎯 定制监控汇总 {window_label} · 命中{len(articles)}条"
html = _html_wrap(
"定制监控命中汇总",
f"""
<p style="color:#374151;font-size:13px;">汇总时段:{window_label}</p>
<p style="color:#6b7280;font-size:12px;">以下为定制监控源中<b>达到推送标准</b>的资讯(由大模型按各源推送标准判定):</p>
{cards}
""",
)
send_email(subject, html)
ids = [a["id"] for a in articles if a["id"]]
db.add_log("custom_summary", subject, len(articles), ids, status="ok", detail=f"汇总{len(top)}条", body=html)
return len(top)