# -*- 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("&", "&").replace("<", "<").replace(">", ">").replace('"', """) 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"
新闻智能跟踪系统检测到以下异常,请及时处理:
{rows}", ) send_email(subject, html) db.add_log("error", subject, len(errors), [], status="ok", detail="系统错误邮件通知") 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 = ('🎯 定制监控命中') if custom else "" return f"""以下 {len(articles)} 条资讯对您重点关注领域很重要,已自动甄别:
{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="实时通知") 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"""汇总时段:{window_label}
领域分布:{stats}
重点资讯(按综合分排序):
{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)}条") 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"""汇总时段:{window_label}
以下为定制监控源中达到推送标准的资讯(由大模型按各源推送标准判定):
{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)}条") return len(top)