"""Web UI for managing recipients, manual send, and weekly scheduled send.""" import json import os import re import subprocess import sys import threading import time from datetime import datetime, timedelta, timezone from pathlib import Path from typing import List from zoneinfo import ZoneInfo from dotenv import load_dotenv from flask import Flask, flash, redirect, render_template, request, url_for from email_sender import EmailSender load_dotenv() app = Flask(__name__) app.config["SECRET_KEY"] = os.getenv("WEB_UI_SECRET_KEY", "dev-secret-key-change-in-env") PROJECT_ROOT = Path(__file__).resolve().parent DATA_DIR = Path(os.getenv("DATA_DIR", "./data")).resolve() MANAGED_RECIPIENTS_FILE = Path( os.getenv("RECIPIENTS_STORE_FILE", str(DATA_DIR / "managed_recipients.json")) ).resolve() SCHEDULE_CONFIG_FILE = Path( os.getenv("SCHEDULE_STORE_FILE", str(DATA_DIR / "scheduled_send_config.json")) ).resolve() EMAIL_PATTERN = re.compile(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$") SCHEDULE_WEEKDAY_OPTIONS = [ (0, "星期一"), (1, "星期二"), (2, "星期三"), (3, "星期四"), (4, "星期五"), (5, "星期六"), (6, "星期日"), ] SCHEDULE_DEFAULT = { "enabled": False, "weekday": 0, "time": "09:00", "subject": "", "recipients": [], "last_run_week": "", } _SCHEDULE_LOCK = threading.Lock() _SCHEDULER_STARTED = False def _ensure_data_dir() -> None: DATA_DIR.mkdir(parents=True, exist_ok=True) def _split_recipients(raw_text: str) -> List[str]: if not raw_text: return [] tokens = re.split(r"[,\n;,;\s]+", raw_text.strip()) return [item.strip() for item in tokens if item.strip()] def _normalize_email(email: str) -> str: return email.strip().lower() def _dedupe_emails(items: List[str]) -> List[str]: seen = set() result = [] for raw in items: item = _normalize_email(raw) if item and item not in seen: seen.add(item) result.append(item) return result def _save_managed_recipients(items: List[str]) -> None: _ensure_data_dir() MANAGED_RECIPIENTS_FILE.write_text( json.dumps(items, ensure_ascii=False, indent=2), encoding="utf-8", ) def get_managed_recipients() -> List[str]: if MANAGED_RECIPIENTS_FILE.exists(): try: payload = json.loads(MANAGED_RECIPIENTS_FILE.read_text(encoding="utf-8")) if isinstance(payload, list): recipients = _dedupe_emails([str(item) for item in payload]) valid = [x for x in recipients if EMAIL_PATTERN.match(x)] if valid != recipients: _save_managed_recipients(valid) return valid except Exception: pass seed = _dedupe_emails(_split_recipients(os.getenv("EMAIL_RECIPIENTS", ""))) valid_seed = [x for x in seed if EMAIL_PATTERN.match(x)] if valid_seed: _save_managed_recipients(valid_seed) return valid_seed def _normalize_weekly_schedule(payload: dict | None) -> dict: data = dict(SCHEDULE_DEFAULT) if isinstance(payload, dict): data.update(payload) data["enabled"] = bool(data.get("enabled", False)) weekday_raw = data.get("weekday", 0) try: weekday = int(weekday_raw) except (TypeError, ValueError): weekday = 0 data["weekday"] = min(max(weekday, 0), 6) time_raw = str(data.get("time", "09:00")).strip() if not re.match(r"^\d{2}:\d{2}$", time_raw): time_raw = "09:00" hour, minute = time_raw.split(":", 1) hour_val = int(hour) minute_val = int(minute) if hour_val < 0 or hour_val > 23 or minute_val < 0 or minute_val > 59: time_raw = "09:00" data["time"] = time_raw subject = str(data.get("subject", "")).strip() data["subject"] = subject[:120] recipients = data.get("recipients", []) if not isinstance(recipients, list): recipients = [] recipients = _dedupe_emails([str(item) for item in recipients]) recipients = [item for item in recipients if EMAIL_PATTERN.match(item)] data["recipients"] = recipients week_key = str(data.get("last_run_week", "")).strip() if not re.match(r"^\d{4}-W\d{2}$", week_key): week_key = "" data["last_run_week"] = week_key return data def get_weekly_schedule_config() -> dict: if not SCHEDULE_CONFIG_FILE.exists(): return dict(SCHEDULE_DEFAULT) try: payload = json.loads(SCHEDULE_CONFIG_FILE.read_text(encoding="utf-8")) except Exception: return dict(SCHEDULE_DEFAULT) return _normalize_weekly_schedule(payload) def save_weekly_schedule_config(schedule: dict) -> dict: _ensure_data_dir() normalized = _normalize_weekly_schedule(schedule) with _SCHEDULE_LOCK: SCHEDULE_CONFIG_FILE.write_text( json.dumps(normalized, ensure_ascii=False, indent=2), encoding="utf-8", ) return normalized def get_app_timezone() -> ZoneInfo: tz_name = os.getenv("WEB_UI_TIMEZONE", "Asia/Shanghai").strip() or "Asia/Shanghai" try: return ZoneInfo(tz_name) except Exception: # Windows may lack IANA timezone data; fall back to fixed UTC+8. return timezone(timedelta(hours=8), name="Asia/Shanghai") def _current_week_key(now: datetime) -> str: iso = now.isocalendar() return f"{iso.year}-W{iso.week:02d}" def run_full_refresh_and_generate_report() -> tuple[Path | None, str]: max_news = os.getenv("WEB_UI_MAX_NEWS", "20") wechat_count = os.getenv("WEB_UI_WECHAT_COUNT", "10") baidu_count = os.getenv("WEB_UI_BAIDU_COUNT", "10") ccgp_count = os.getenv("WEB_UI_CCGP_COUNT", "10") ccgp_keywords = os.getenv("WEB_UI_CCGP_KEYWORDS", "").strip() timeout_sec = int(os.getenv("WEB_UI_REFRESH_TIMEOUT_SEC", "1800")) command = [ sys.executable, "main.py", "--mode", "full", "--sources", "all", "--max-news", str(max_news), "--wechat-count", str(wechat_count), "--baidu-count", str(baidu_count), "--ccgp-count", str(ccgp_count), ] if ccgp_keywords: command.extend(["--ccgp-keywords", ccgp_keywords]) try: process = subprocess.run( command, cwd=str(PROJECT_ROOT), capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout_sec, check=False, ) except subprocess.TimeoutExpired: return None, "Refresh and report generation timed out." except Exception as exc: return None, f"Refresh pipeline failed: {exc}" if process.returncode != 0: debug_text = (process.stderr or process.stdout or "").strip() if len(debug_text) > 500: debug_text = debug_text[-500:] return None, f"Refresh/report failed (exit={process.returncode}). {debug_text}" report_files = sorted( [ path for path in DATA_DIR.glob("report_*") if path.is_file() and path.suffix.lower() in {".txt", ".md"} ], key=lambda p: p.stat().st_mtime, reverse=True, ) if not report_files: return None, "No report file found after refresh." return report_files[0], "" def send_report_with_refresh(recipients: List[str], subject: str | None = None) -> tuple[bool, str]: sender = os.getenv("EMAIL_SENDER", "").strip() sender_password = os.getenv("EMAIL_PASSWORD", "").strip() if not sender or not sender_password: return False, "EMAIL_SENDER or EMAIL_PASSWORD is missing." if not recipients: return False, "At least one recipient is required." invalid = [item for item in recipients if not EMAIL_PATTERN.match(item)] if invalid: return False, f"Invalid recipient(s): {', '.join(invalid)}" report_path, refresh_error = run_full_refresh_and_generate_report() if not report_path: return False, refresh_error or "No report generated." email_sender = EmailSender(sender, sender_password) success = email_sender.send_report( recipient_emails=recipients, report_path=str(report_path), subject=subject, ) if not success: return False, "SMTP send failed." return True, report_path.name def _run_weekly_schedule_once() -> None: schedule = get_weekly_schedule_config() if not schedule.get("enabled"): return recipients = schedule.get("recipients", []) if not recipients: return now = datetime.now(get_app_timezone()) if now.weekday() != int(schedule.get("weekday", 0)): return schedule_time = str(schedule.get("time", "09:00")) try: hour_str, minute_str = schedule_time.split(":", 1) schedule_hour = int(hour_str) schedule_minute = int(minute_str) except Exception: return if now.hour != schedule_hour or now.minute != schedule_minute: return week_key = _current_week_key(now) if schedule.get("last_run_week") == week_key: return ok, message = send_report_with_refresh( recipients=recipients, subject=schedule.get("subject") or None, ) if ok: schedule["last_run_week"] = week_key save_weekly_schedule_config(schedule) print( f"[scheduler] Weekly send completed at {now.strftime('%Y-%m-%d %H:%M:%S')} " f"for {len(recipients)} recipients, report={message}" ) else: print(f"[scheduler] Weekly send failed at {now.strftime('%Y-%m-%d %H:%M:%S')}: {message}") def _scheduler_loop() -> None: print("[scheduler] Weekly scheduler loop started.") while True: try: _run_weekly_schedule_once() except Exception as exc: print(f"[scheduler] Unexpected error: {exc}") time.sleep(30) def start_scheduler_thread() -> None: global _SCHEDULER_STARTED if _SCHEDULER_STARTED: return thread = threading.Thread(target=_scheduler_loop, daemon=True, name="weekly-scheduler") thread.start() _SCHEDULER_STARTED = True def is_scheduler_started() -> bool: """Return whether the background scheduler loop has started.""" return _SCHEDULER_STARTED def mask_email(email: str) -> str: if not email or "@" not in email: return "未配置" username, domain = email.split("@", 1) if len(username) <= 2: masked = username[0] + "*" else: masked = username[:2] + "*" * (len(username) - 2) return f"{masked}@{domain}" @app.route("/", methods=["GET"]) def index(): sender = os.getenv("EMAIL_SENDER", "").strip() sender_password = os.getenv("EMAIL_PASSWORD", "").strip() managed_recipients = get_managed_recipients() schedule_config = get_weekly_schedule_config() return render_template( "email_console.html", sender_masked=mask_email(sender), sender_ready=bool(sender and sender_password), managed_recipients=managed_recipients, schedule_config=schedule_config, schedule_weekday_options=SCHEDULE_WEEKDAY_OPTIONS, scheduler_started=is_scheduler_started(), ) @app.route("/send", methods=["POST"]) def send_email(): subject = request.form.get("subject", "").strip() or None selected_managed = request.form.getlist("managed_recipients") recipients = _dedupe_emails(selected_managed) if not recipients: flash("Please select at least one recipient.", "error") return redirect(url_for("index")) success, message = send_report_with_refresh(recipients=recipients, subject=subject) if success: flash(f"Sent successfully to {len(recipients)} recipient(s), report: {message}", "success") else: flash(f"Send failed: {message}", "error") return redirect(url_for("index")) @app.route("/schedule/update", methods=["POST"]) def update_schedule(): enabled = request.form.get("schedule_enabled") == "on" subject = request.form.get("schedule_subject", "").strip() weekday_raw = request.form.get("schedule_weekday", "0").strip() time_raw = request.form.get("schedule_time", "09:00").strip() recipients = get_managed_recipients() try: weekday = int(weekday_raw) except ValueError: weekday = 0 if weekday < 0 or weekday > 6: weekday = 0 if not re.match(r"^\d{2}:\d{2}$", time_raw): flash("Schedule time format should be HH:MM.", "error") return redirect(url_for("index")) if enabled and not recipients: flash("Please add at least one managed recipient before enabling weekly schedule.", "error") return redirect(url_for("index")) current = get_weekly_schedule_config() schedule = { "enabled": enabled, "weekday": weekday, "time": time_raw, "subject": subject[:120], "recipients": recipients, "last_run_week": current.get("last_run_week", ""), } save_weekly_schedule_config(schedule) flash("Weekly schedule saved.", "success") return redirect(url_for("index")) @app.route("/recipients/add", methods=["POST"]) def add_recipient(): raw_email = request.form.get("new_recipient", "").strip() if not raw_email: flash("Please input a recipient email.", "error") return redirect(url_for("index")) email = _normalize_email(raw_email) if not EMAIL_PATTERN.match(email): flash("Invalid email format.", "error") return redirect(url_for("index")) recipients = get_managed_recipients() if email in recipients: flash("Recipient already exists.", "error") return redirect(url_for("index")) recipients.append(email) _save_managed_recipients(recipients) flash(f"Recipient added: {email}", "success") return redirect(url_for("index")) @app.route("/recipients/delete", methods=["POST"]) def delete_recipient(): raw_email = request.form.get("email", "").strip() email = _normalize_email(raw_email) recipients = get_managed_recipients() if email not in recipients: flash("Recipient not found.", "error") return redirect(url_for("index")) updated = [item for item in recipients if item != email] _save_managed_recipients(updated) flash(f"Recipient deleted: {email}", "success") return redirect(url_for("index")) if __name__ == "__main__": host = os.getenv("WEB_UI_HOST", "127.0.0.1") port = int(os.getenv("WEB_UI_PORT", "7860")) debug = os.getenv("WEB_UI_DEBUG", "0") == "1" app.run(host=host, port=port, debug=debug)