478 lines
14 KiB
Python
478 lines
14 KiB
Python
|
|
"""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)
|