import asyncio import json import os from contextlib import suppress from typing import List from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Request from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from sqlalchemy.orm import Session from ai import analyze_thread from alerts import process_alerts from database import ( Message, SessionLocal, Thread, create_db_tables, get_thread_messages, ingest_emails, ) from zoho_client import ZohoClient def get_db(): db = SessionLocal() try: yield db finally: db.close() create_db_tables() app = FastAPI(title="Email Alerts UI") # Static and templates os.makedirs("templates", exist_ok=True) os.makedirs("static", exist_ok=True) templates = Jinja2Templates(directory="templates") app.mount("/static", StaticFiles(directory="static"), name="static") @app.get("/", response_class=HTMLResponse) def home(request: Request, db: Session = Depends(get_db), account: str | None = None): q = db.query(Thread).order_by(Thread.updated_at.desc()) if account: q = q.filter(Thread.account_email == account.lower()) threads = q.limit(100).all() return templates.TemplateResponse( "threads.html", { "request": request, "threads": threads, "account": account or "", "status": _status_for_templates(), }, ) @app.get("/thread/{thread_id}", response_class=HTMLResponse) def show_thread(thread_id: int, request: Request, db: Session = Depends(get_db)): thread = db.query(Thread).filter(Thread.id == thread_id).one_or_none() if not thread: raise HTTPException(status_code=404, detail="Thread not found") messages: List[Message] = get_thread_messages(db, thread_id) # Convert for AI analyzer and template msg_dicts = [ { "id": m.id, "date_sent": m.date_sent, "subject": m.subject, "from_email": m.from_email, "to_email": m.to_email, "body": m.body, "is_incoming": m.is_incoming, } for m in messages ] ai = analyze_thread(thread.subject or "", msg_dicts) # Save AI info on the thread for listing and downstream alerts try: from datetime import datetime, timezone thread.actionable = bool(ai.get("actionable", False)) thread.ai_summary = ai.get("summary") thread.ai_confidence = ai.get("confidence") thread.last_analyzed_at = datetime.now(timezone.utc) db.commit() except Exception: pass return templates.TemplateResponse( "thread_detail.html", { "request": request, "thread": thread, "messages": messages, "ai": ai, "status": _status_for_templates(), }, ) @app.get("/health") def health(): return {"status": "ok"} # --------------------- # Config editor routes # --------------------- CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json") def load_config() -> dict: if not os.path.exists(CONFIG_PATH): return { "email_address": "", "time_frames": [ {"name": "1-24 hours", "hours": 24, "alert_level": 1}, {"name": "24-48 hours", "hours": 48, "alert_level": 2}, {"name": "48+ hours", "hours": 72, "alert_level": 3}, ], "email_days_back": 7, "agency_domains": [], "zoho_email": "", "zoho_app_password": "", "whatsapp_to": "", "auto_process": False, "auto_process_interval": 30, # Sync status "sync_in_progress": False, "last_sync_at": None, "last_sync_count": 0, "last_sync_status": "idle", "last_sync_error": None, } with open(CONFIG_PATH, "r", encoding="utf-8") as f: return json.load(f) def save_config(cfg: dict) -> None: with open(CONFIG_PATH, "w", encoding="utf-8") as f: json.dump(cfg, f, indent=2, ensure_ascii=False) def _status_for_templates() -> dict: cfg = load_config() return { "auto_process": bool(cfg.get("auto_process")), "interval": int(cfg.get("auto_process_interval", 30) or 30), "sync_in_progress": bool(cfg.get("sync_in_progress")), "last_sync_at": cfg.get("last_sync_at"), "last_sync_status": cfg.get("last_sync_status", "idle"), "last_sync_count": int(cfg.get("last_sync_count") or 0), "last_sync_error": cfg.get("last_sync_error"), } @app.get("/config", response_class=HTMLResponse) def config_form(request: Request, saved: int | None = None): cfg = load_config() # Render up to existing frames or at least 3 rows frames = cfg.get("time_frames") or [] min_rows = max(len(frames), 3) return templates.TemplateResponse( "config.html", { "request": request, "cfg": cfg, "rows": list(range(min_rows)), "saved": bool(saved), "status": _status_for_templates(), }, ) @app.post("/config") async def config_save(request: Request): form = await request.form() cfg = load_config() # Basic fields cfg["email_address"] = (form.get("email_address") or "").strip() # email_days_back try: cfg["email_days_back"] = int( form.get("email_days_back") or cfg.get("email_days_back", 7) ) except Exception: cfg["email_days_back"] = 7 # agency domains (comma or newline separated) domains_raw = (form.get("agency_domains") or "").replace("\r", "") parts = [p.strip() for p in domains_raw.replace(",", "\n").split("\n") if p.strip()] cfg["agency_domains"] = parts # auto_process cfg["auto_process"] = form.get("auto_process") == "on" # auto_process_interval try: cfg["auto_process_interval"] = int( form.get("auto_process_interval") or cfg.get("auto_process_interval", 30) ) except Exception: cfg["auto_process_interval"] = 30 # Zoho (optional - note: current client reads env vars) cfg["zoho_email"] = (form.get("zoho_email") or cfg.get("zoho_email", "")).strip() cfg["zoho_app_password"] = ( form.get("zoho_app_password") or cfg.get("zoho_app_password", "") ).strip() # WhatsApp destination cfg["whatsapp_to"] = (form.get("whatsapp_to") or cfg.get("whatsapp_to", "")).strip() # Time frames: collect indexed rows frames: list[dict] = [] # find indices present indices = set() for k in form.keys(): if k.startswith("time_name_"): try: indices.add(int(k.split("_")[-1])) except Exception: pass for i in sorted(indices): name = (form.get(f"time_name_{i}") or "").strip() hrs_raw = form.get(f"time_hours_{i}") or "" lvl_raw = form.get(f"time_alert_{i}") or "" if not name and not hrs_raw and not lvl_raw: continue try: hours = int(hrs_raw) except Exception: hours = 0 try: level = int(lvl_raw) except Exception: level = 0 if name: frames.append({"name": name, "hours": hours, "alert_level": level}) if frames: cfg["time_frames"] = frames save_config(cfg) return RedirectResponse(url="/config?saved=1", status_code=303) @app.post("/process") def process(db: Session = Depends(get_db)): cfg = load_config() alerted = process_alerts(db, cfg) return {"alerted_threads": alerted} def _sync_emails_once(cfg: dict) -> int: """Fetch INBOX and Sent from Zoho and ingest into DB. Returns threads requiring reply count.""" from datetime import datetime, timezone # Mark status cfg["sync_in_progress"] = True cfg["last_sync_status"] = "running" cfg["last_sync_error"] = None save_config(cfg) account_email = cfg.get("email_address") or cfg.get("zoho_email") or "" if not account_email: cfg.update( { "last_sync_status": "error", "last_sync_error": "Configure email_address or zoho_email in /config", "sync_in_progress": False, } ) save_config(cfg) raise HTTPException( status_code=400, detail="Configure email_address or zoho_email in /config" ) # Incremental lookback by last_sync_at days_back = int(cfg.get("email_days_back", 7) or 7) last_sync_at = cfg.get("last_sync_at") if last_sync_at: try: last_dt = datetime.fromisoformat(last_sync_at) now = datetime.now(timezone.utc) delta_days = int(((now - last_dt).total_seconds() + 86399) // 86400) days_back = max(1, delta_days) except Exception: pass max_results = 100 client = ZohoClient( email=cfg.get("zoho_email") or account_email, app_password=cfg.get("zoho_app_password"), ) try: inbox = client.fetch_folder_emails( folder="INBOX", max_results=max_results, days_back=days_back ) sent = client.fetch_folder_emails( folder="Sent", max_results=max_results, days_back=days_back ) finally: client.close() db = SessionLocal() try: ingest_emails( db, account_email=account_email, emails=inbox, default_folder="INBOX" ) ingest_emails( db, account_email=account_email, emails=sent, default_folder="Sent" ) # Update sync completion status and metrics cfg.update( { "last_sync_at": datetime.now(timezone.utc).isoformat(), "last_sync_status": "ok", "last_sync_error": None, "last_sync_count": int( (len(inbox) if inbox else 0) + (len(sent) if sent else 0) ), "sync_in_progress": False, } ) save_config(cfg) return count except Exception as e: # Update error status cfg.update( { "last_sync_status": "error", "last_sync_error": str(e), "sync_in_progress": False, } ) save_config(cfg) raise finally: db.close() @app.post("/sync_emails") def sync_emails(background_tasks: BackgroundTasks): cfg = load_config() if cfg.get("sync_in_progress"): return RedirectResponse(url="/?sync=busy", status_code=303) def _background_sync(): try: _sync_emails_once(load_config()) except Exception: # Error handling is done inside _sync_emails_once pass # Mark as starting and add background task cfg["sync_in_progress"] = True cfg["last_sync_status"] = "running" cfg["last_sync_error"] = None save_config(cfg) background_tasks.add_task(_background_sync) return RedirectResponse(url="/?sync=started", status_code=303) # --------------------- # Auto-processing loop # --------------------- _auto_task = None _stop_event = asyncio.Event() async def _auto_runner(): # Delay a bit on startup await asyncio.sleep(2) while not _stop_event.is_set(): cfg = load_config() interval_min = int(cfg.get("auto_process_interval", 30) or 30) if cfg.get("auto_process"): # Sync emails then run alerts try: if not cfg.get("sync_in_progress"): _sync_emails_once(cfg) except Exception: # keep loop alive pass db = SessionLocal() try: process_alerts(db, cfg) finally: db.close() try: await asyncio.wait_for( _stop_event.wait(), timeout=max(5, interval_min * 60) ) except asyncio.TimeoutError: continue @app.on_event("startup") async def on_startup(): global _auto_task _stop_event.clear() _auto_task = asyncio.create_task(_auto_runner()) @app.on_event("shutdown") async def on_shutdown(): if _auto_task: _stop_event.set() with suppress(Exception): await _auto_task