Implement email alert system with WhatsApp notifications
- Added alerts processing logic in src/alerts.py to analyze threads and send WhatsApp alerts based on configured time frames. - Created FastAPI application in src/app.py to manage threads, display configurations, and trigger alert processing. - Developed database models and utility functions in src/database.py for managing threads and messages. - Integrated Twilio API for sending WhatsApp messages in src/whatsapp_sender.py. - Implemented Zoho email client in src/zoho_client.py to fetch emails and check for replies. - Added configuration management for email settings and alert parameters. - Established auto-processing loop for periodic email syncing and alert generation.
This commit is contained in:
@@ -1,21 +1,26 @@
|
|||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
from contextlib import suppress
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ai import analyze_thread
|
from ai import analyze_thread
|
||||||
|
from alerts import process_alerts
|
||||||
from database import (
|
from database import (
|
||||||
Message,
|
Message,
|
||||||
SessionLocal,
|
SessionLocal,
|
||||||
Thread,
|
Thread,
|
||||||
create_db_tables,
|
create_db_tables,
|
||||||
get_thread_messages,
|
get_thread_messages,
|
||||||
|
ingest_emails,
|
||||||
)
|
)
|
||||||
|
from zoho_client import ZohoClient
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
@@ -48,6 +53,7 @@ def home(request: Request, db: Session = Depends(get_db), account: str | None =
|
|||||||
"request": request,
|
"request": request,
|
||||||
"threads": threads,
|
"threads": threads,
|
||||||
"account": account or "",
|
"account": account or "",
|
||||||
|
"status": _status_for_templates(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -72,6 +78,17 @@ def show_thread(thread_id: int, request: Request, db: Session = Depends(get_db))
|
|||||||
for m in messages
|
for m in messages
|
||||||
]
|
]
|
||||||
ai = analyze_thread(thread.subject or "", msg_dicts)
|
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(
|
return templates.TemplateResponse(
|
||||||
"thread_detail.html",
|
"thread_detail.html",
|
||||||
{
|
{
|
||||||
@@ -79,6 +96,7 @@ def show_thread(thread_id: int, request: Request, db: Session = Depends(get_db))
|
|||||||
"thread": thread,
|
"thread": thread,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"ai": ai,
|
"ai": ai,
|
||||||
|
"status": _status_for_templates(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -108,8 +126,15 @@ def load_config() -> dict:
|
|||||||
"agency_domains": [],
|
"agency_domains": [],
|
||||||
"zoho_email": "",
|
"zoho_email": "",
|
||||||
"zoho_app_password": "",
|
"zoho_app_password": "",
|
||||||
|
"whatsapp_to": "",
|
||||||
"auto_process": False,
|
"auto_process": False,
|
||||||
"auto_process_interval": 30,
|
"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:
|
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
@@ -120,6 +145,19 @@ def save_config(cfg: dict) -> None:
|
|||||||
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
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)
|
@app.get("/config", response_class=HTMLResponse)
|
||||||
def config_form(request: Request, saved: int | None = None):
|
def config_form(request: Request, saved: int | None = None):
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
@@ -133,6 +171,7 @@ def config_form(request: Request, saved: int | None = None):
|
|||||||
"cfg": cfg,
|
"cfg": cfg,
|
||||||
"rows": list(range(min_rows)),
|
"rows": list(range(min_rows)),
|
||||||
"saved": bool(saved),
|
"saved": bool(saved),
|
||||||
|
"status": _status_for_templates(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -170,6 +209,8 @@ async def config_save(request: Request):
|
|||||||
cfg["zoho_app_password"] = (
|
cfg["zoho_app_password"] = (
|
||||||
form.get("zoho_app_password") or cfg.get("zoho_app_password", "")
|
form.get("zoho_app_password") or cfg.get("zoho_app_password", "")
|
||||||
).strip()
|
).strip()
|
||||||
|
# WhatsApp destination
|
||||||
|
cfg["whatsapp_to"] = (form.get("whatsapp_to") or cfg.get("whatsapp_to", "")).strip()
|
||||||
|
|
||||||
# Time frames: collect indexed rows
|
# Time frames: collect indexed rows
|
||||||
frames: list[dict] = []
|
frames: list[dict] = []
|
||||||
@@ -202,3 +243,169 @@ async def config_save(request: Request):
|
|||||||
|
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
return RedirectResponse(url="/config?saved=1", status_code=303)
|
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
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from database import (
|
|
||||||
Message,
|
|
||||||
SessionLocal,
|
|
||||||
Thread,
|
|
||||||
create_db_tables,
|
|
||||||
get_thread_messages,
|
|
||||||
get_threads_requiring_reply,
|
|
||||||
ingest_emails,
|
|
||||||
)
|
|
||||||
from zoho_client import ZohoClient
|
|
||||||
|
|
||||||
|
|
||||||
def ingest_action(
|
|
||||||
account_email: str, days_back: int = 7, max_results: int = 50
|
|
||||||
) -> None:
|
|
||||||
create_db_tables()
|
|
||||||
client = ZohoClient(email=account_email)
|
|
||||||
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
|
|
||||||
)
|
|
||||||
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"
|
|
||||||
)
|
|
||||||
threads: List[Thread] = get_threads_requiring_reply(db, account_email)
|
|
||||||
print(f"Threads requiring reply for {account_email}: {len(threads)}")
|
|
||||||
for t in threads:
|
|
||||||
print(
|
|
||||||
f"- Thread #{t.id} | Subject: {t.subject!r} | requires_reply={t.requires_reply}"
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
def list_threads_action(
|
|
||||||
account_email: Optional[str] = None, limit: int = 20, only_requiring: bool = False
|
|
||||||
) -> None:
|
|
||||||
create_db_tables()
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
q = db.query(Thread).order_by(Thread.updated_at.desc())
|
|
||||||
if account_email:
|
|
||||||
q = q.filter(Thread.account_email == account_email.lower())
|
|
||||||
if only_requiring:
|
|
||||||
q = q.filter(Thread.requires_reply.is_(True))
|
|
||||||
threads = q.limit(limit).all()
|
|
||||||
print(
|
|
||||||
f"Showing {len(threads)} threads"
|
|
||||||
+ (f" for {account_email}" if account_email else "")
|
|
||||||
)
|
|
||||||
for t in threads:
|
|
||||||
count = db.query(Message).filter(Message.thread_id == t.id).count()
|
|
||||||
print(
|
|
||||||
f"- id={t.id} msgs={count} requires_reply={t.requires_reply} subject={t.subject!r}"
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
def show_thread_action(thread_id: int) -> None:
|
|
||||||
create_db_tables()
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
thread = db.query(Thread).filter(Thread.id == thread_id).one_or_none()
|
|
||||||
if not thread:
|
|
||||||
print(f"Thread {thread_id} not found")
|
|
||||||
return
|
|
||||||
|
|
||||||
print(
|
|
||||||
f"Thread #{thread.id} subject={thread.subject!r} account={thread.account_email} requires_reply={thread.requires_reply}"
|
|
||||||
)
|
|
||||||
messages: List[Message] = get_thread_messages(db, thread.id)
|
|
||||||
for i, m in enumerate(messages, 1):
|
|
||||||
direction = "IN" if m.is_incoming else "OUT"
|
|
||||||
snippet = (m.body or "").strip().replace("\n", " ")
|
|
||||||
if len(snippet) > 140:
|
|
||||||
snippet = snippet[:140] + "..."
|
|
||||||
print(
|
|
||||||
f"[{i}] {m.date_sent} [{direction}] {m.folder} | from={m.from_email} -> to={m.to_email}\n"
|
|
||||||
f" subject={m.subject!r}\n"
|
|
||||||
f" message_id={m.message_id} in_reply_to={m.in_reply_to}\n"
|
|
||||||
f" body={snippet}"
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Email alerts utility")
|
|
||||||
sub = parser.add_subparsers(dest="cmd", required=False)
|
|
||||||
|
|
||||||
p_ingest = sub.add_parser("ingest", help="Fetch INBOX and Sent and ingest into DB")
|
|
||||||
p_ingest.add_argument(
|
|
||||||
"--account", dest="account", default=os.getenv("ZOHO_EMAIL", "")
|
|
||||||
)
|
|
||||||
p_ingest.add_argument("--days-back", dest="days_back", type=int, default=7)
|
|
||||||
p_ingest.add_argument("--max-results", dest="max_results", type=int, default=50)
|
|
||||||
|
|
||||||
p_list = sub.add_parser("list-threads", help="List threads")
|
|
||||||
p_list.add_argument("--account", dest="account", default=None)
|
|
||||||
p_list.add_argument("--limit", dest="limit", type=int, default=20)
|
|
||||||
p_list.add_argument("--only-requiring", dest="only_req", action="store_true")
|
|
||||||
|
|
||||||
p_show = sub.add_parser("show-thread", help="Print all messages in a thread")
|
|
||||||
p_show.add_argument("thread_id", type=int)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.cmd == "ingest":
|
|
||||||
acct = args.account or os.getenv("ZOHO_EMAIL", "")
|
|
||||||
if not acct:
|
|
||||||
raise SystemExit("Provide --account or set ZOHO_EMAIL")
|
|
||||||
ingest_action(acct, days_back=args.days_back, max_results=args.max_results)
|
|
||||||
elif args.cmd == "list-threads":
|
|
||||||
list_threads_action(
|
|
||||||
account_email=args.account, limit=args.limit, only_requiring=args.only_req
|
|
||||||
)
|
|
||||||
elif args.cmd == "show-thread":
|
|
||||||
show_thread_action(args.thread_id)
|
|
||||||
else:
|
|
||||||
# Default behavior: run ingest using env and then list requiring-reply threads
|
|
||||||
acct = os.getenv("ZOHO_EMAIL", "")
|
|
||||||
if not acct:
|
|
||||||
parser.print_help()
|
|
||||||
raise SystemExit(0)
|
|
||||||
ingest_action(acct)
|
|
||||||
+166
@@ -0,0 +1,166 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from src.ai import analyze_thread
|
||||||
|
from src.database import (
|
||||||
|
Message,
|
||||||
|
Thread,
|
||||||
|
get_last_incoming_outgoing,
|
||||||
|
get_thread_messages,
|
||||||
|
)
|
||||||
|
from src.whatsapp_sender import WhatsAppSender
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TimeFrame:
|
||||||
|
name: str
|
||||||
|
hours: int
|
||||||
|
alert_level: int
|
||||||
|
|
||||||
|
|
||||||
|
def _utc(dt: datetime) -> datetime:
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_frames_from_config(cfg: dict) -> List[TimeFrame]:
|
||||||
|
frames = []
|
||||||
|
for f in cfg.get("time_frames", []):
|
||||||
|
try:
|
||||||
|
frames.append(
|
||||||
|
TimeFrame(
|
||||||
|
name=f.get("name", ""),
|
||||||
|
hours=int(f.get("hours", 0)),
|
||||||
|
alert_level=int(f.get("alert_level", 0)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
# sort ascending by hours
|
||||||
|
frames.sort(key=lambda x: x.hours)
|
||||||
|
return frames
|
||||||
|
|
||||||
|
|
||||||
|
def _format_alert(
|
||||||
|
level: int, hours: int, thread: Thread, ai: dict, last_in: Optional[Message]
|
||||||
|
) -> str:
|
||||||
|
title = {
|
||||||
|
1: "LEVEL 1 ALERT (24 Hours)",
|
||||||
|
2: "LEVEL 2 ALERT (48 Hours - URGENT)",
|
||||||
|
3: "LEVEL 3 ALERT (72 Hours - CRITICAL)",
|
||||||
|
}.get(level, f"LEVEL {level} ALERT ({hours} Hours)")
|
||||||
|
sender = last_in.from_email if last_in else "unknown"
|
||||||
|
subject = thread.subject or "(no subject)"
|
||||||
|
summary = ai.get("summary") or thread.ai_summary or ""
|
||||||
|
conf_pct = int(round((ai.get("confidence") or thread.ai_confidence or 0) * 100))
|
||||||
|
return (
|
||||||
|
f"🚨 {title} 🚨\n\n"
|
||||||
|
f"Conversation with: {sender}\n"
|
||||||
|
f"Subject: {subject}\n"
|
||||||
|
f"Contextual Summary: {summary}\n\n"
|
||||||
|
f"Thread ID: {thread.id}\n"
|
||||||
|
f"Confidence: {conf_pct}%"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def process_alerts(db: Session, cfg: dict) -> List[int]:
|
||||||
|
"""Analyze threads and send WhatsApp alerts when thresholds are met.
|
||||||
|
|
||||||
|
Returns list of thread IDs that had alerts sent this run.
|
||||||
|
"""
|
||||||
|
account_email = (cfg.get("email_address") or cfg.get("zoho_email") or "").lower()
|
||||||
|
if not account_email:
|
||||||
|
return []
|
||||||
|
|
||||||
|
frames = _load_frames_from_config(cfg)
|
||||||
|
if not frames:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Ensure thresholds unique by level
|
||||||
|
level_to_hours = {f.alert_level: f.hours for f in frames}
|
||||||
|
|
||||||
|
# Find candidate threads: requires_reply True and account matches
|
||||||
|
threads: List[Thread] = (
|
||||||
|
db.query(Thread)
|
||||||
|
.filter(Thread.account_email == account_email, Thread.requires_reply.is_(True))
|
||||||
|
.order_by(Thread.updated_at.desc())
|
||||||
|
.limit(200)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
to_number = cfg.get("whatsapp_to") or None
|
||||||
|
sender = WhatsAppSender(to_number=to_number)
|
||||||
|
alerted = []
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
for t in threads:
|
||||||
|
last_in, last_out = get_last_incoming_outgoing(db, t.id)
|
||||||
|
if not last_in:
|
||||||
|
continue
|
||||||
|
last_in_dt = _utc(last_in.date_sent) if last_in.date_sent else None
|
||||||
|
last_out_dt = (
|
||||||
|
_utc(last_out.date_sent) if last_out and last_out.date_sent else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only if last message is incoming or last_in is later than last_out
|
||||||
|
if last_out_dt and last_out_dt > last_in_dt:
|
||||||
|
# There's a reply from us after the last incoming; skip
|
||||||
|
continue
|
||||||
|
|
||||||
|
hours_since_last_in = (
|
||||||
|
(now - last_in_dt).total_seconds() / 3600.0 if last_in_dt else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine highest frame crossed
|
||||||
|
target_level = 0
|
||||||
|
target_hours = 0
|
||||||
|
for level, hours in sorted(level_to_hours.items(), key=lambda x: x[1]):
|
||||||
|
if hours_since_last_in >= hours:
|
||||||
|
target_level = level
|
||||||
|
target_hours = hours
|
||||||
|
|
||||||
|
if target_level == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Avoid re-sending same or lower level
|
||||||
|
if (t.last_alert_level_sent or 0) >= target_level:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Build AI input messages
|
||||||
|
msgs = [
|
||||||
|
{
|
||||||
|
"date_sent": m.date_sent.isoformat() if m.date_sent else None,
|
||||||
|
"subject": m.subject,
|
||||||
|
"from_email": m.from_email,
|
||||||
|
"to_email": m.to_email,
|
||||||
|
"body": m.body,
|
||||||
|
"is_incoming": m.is_incoming,
|
||||||
|
}
|
||||||
|
for m in get_thread_messages(db, t.id)[-4:]
|
||||||
|
]
|
||||||
|
ai = analyze_thread(t.subject or "", msgs)
|
||||||
|
|
||||||
|
# Persist AI decision on thread
|
||||||
|
t.actionable = bool(ai.get("actionable", False))
|
||||||
|
t.ai_summary = ai.get("summary")
|
||||||
|
t.ai_confidence = ai.get("confidence")
|
||||||
|
t.last_analyzed_at = now
|
||||||
|
|
||||||
|
if not t.actionable:
|
||||||
|
# Don't alert on non-actionable
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Compose and send WhatsApp
|
||||||
|
msg = _format_alert(target_level, target_hours, t, ai, last_in)
|
||||||
|
res = sender.send_alert(msg, thread_id=str(t.id))
|
||||||
|
if res.get("status") == "success":
|
||||||
|
t.last_alert_level_sent = target_level
|
||||||
|
t.last_alert_sent_at = now
|
||||||
|
alerted.append(t.id)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return alerted
|
||||||
+390
@@ -0,0 +1,390 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from contextlib import suppress
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from fastapi import 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 src.ai import analyze_thread
|
||||||
|
from src.alerts import process_alerts
|
||||||
|
from src.database import (
|
||||||
|
Message,
|
||||||
|
SessionLocal,
|
||||||
|
Thread,
|
||||||
|
create_db_tables,
|
||||||
|
get_thread_messages,
|
||||||
|
ingest_emails,
|
||||||
|
)
|
||||||
|
from src.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"
|
||||||
|
)
|
||||||
|
# Return count for UX/debug
|
||||||
|
count = (
|
||||||
|
db.query(Thread)
|
||||||
|
.filter(Thread.account_email == account_email.lower())
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
return count
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/sync_emails")
|
||||||
|
async def sync_emails():
|
||||||
|
cfg = load_config()
|
||||||
|
if cfg.get("sync_in_progress"):
|
||||||
|
return RedirectResponse(url="/?sync=busy", status_code=303)
|
||||||
|
|
||||||
|
async def _task():
|
||||||
|
try:
|
||||||
|
_sync_emails_once(load_config())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cfg["sync_in_progress"] = True
|
||||||
|
cfg["last_sync_status"] = "running"
|
||||||
|
cfg["last_sync_error"] = None
|
||||||
|
save_config(cfg)
|
||||||
|
asyncio.create_task(_task())
|
||||||
|
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
|
||||||
@@ -6,6 +6,7 @@ from sqlalchemy import (
|
|||||||
Boolean,
|
Boolean,
|
||||||
Column,
|
Column,
|
||||||
DateTime,
|
DateTime,
|
||||||
|
Float,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Index,
|
Index,
|
||||||
Integer,
|
Integer,
|
||||||
@@ -41,6 +42,47 @@ Base = declarative_base()
|
|||||||
|
|
||||||
def create_db_tables():
|
def create_db_tables():
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
_migrate_add_missing_columns()
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(table: str, column: str) -> bool:
|
||||||
|
try:
|
||||||
|
with engine.connect() as conn:
|
||||||
|
rows = conn.exec_driver_sql(f"PRAGMA table_info('{table}')").fetchall()
|
||||||
|
cols = {row[1] for row in rows}
|
||||||
|
return column in cols
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_add_missing_columns() -> None:
|
||||||
|
"""Best-effort SQLite migrations for newly added columns."""
|
||||||
|
try:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
if not _column_exists("threads", "actionable"):
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"ALTER TABLE threads ADD COLUMN actionable BOOLEAN NOT NULL DEFAULT 0"
|
||||||
|
)
|
||||||
|
if not _column_exists("threads", "ai_summary"):
|
||||||
|
conn.exec_driver_sql("ALTER TABLE threads ADD COLUMN ai_summary TEXT")
|
||||||
|
if not _column_exists("threads", "ai_confidence"):
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"ALTER TABLE threads ADD COLUMN ai_confidence FLOAT"
|
||||||
|
)
|
||||||
|
if not _column_exists("threads", "last_analyzed_at"):
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"ALTER TABLE threads ADD COLUMN last_analyzed_at DATETIME"
|
||||||
|
)
|
||||||
|
if not _column_exists("threads", "last_alert_level_sent"):
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"ALTER TABLE threads ADD COLUMN last_alert_level_sent INTEGER NOT NULL DEFAULT 0"
|
||||||
|
)
|
||||||
|
if not _column_exists("threads", "last_alert_sent_at"):
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"ALTER TABLE threads ADD COLUMN last_alert_sent_at DATETIME"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"DB migration warning: {e}")
|
||||||
|
|
||||||
|
|
||||||
class Thread(Base):
|
class Thread(Base):
|
||||||
@@ -53,6 +95,13 @@ class Thread(Base):
|
|||||||
thread_key = Column(String, nullable=False)
|
thread_key = Column(String, nullable=False)
|
||||||
subject = Column(String, index=True)
|
subject = Column(String, index=True)
|
||||||
requires_reply = Column(Boolean, nullable=False, default=False)
|
requires_reply = Column(Boolean, nullable=False, default=False)
|
||||||
|
# AI/alert fields
|
||||||
|
actionable = Column(Boolean, nullable=False, default=False)
|
||||||
|
ai_summary = Column(Text)
|
||||||
|
ai_confidence = Column(Float)
|
||||||
|
last_analyzed_at = Column(DateTime)
|
||||||
|
last_alert_level_sent = Column(Integer, nullable=False, default=0)
|
||||||
|
last_alert_sent_at = Column(DateTime)
|
||||||
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||||
updated_at = Column(
|
updated_at = Column(
|
||||||
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
|
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
@@ -269,6 +318,25 @@ def get_threads_requiring_reply(db: Session, account_email: str) -> List[Thread]
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_last_incoming_outgoing(
|
||||||
|
db: Session, thread_id: int
|
||||||
|
) -> Tuple[Optional["Message"], Optional["Message"]]:
|
||||||
|
"""Return the last incoming and outgoing messages for a thread."""
|
||||||
|
last_incoming = (
|
||||||
|
db.query(Message)
|
||||||
|
.filter(Message.thread_id == thread_id, Message.is_incoming.is_(True))
|
||||||
|
.order_by(Message.date_sent.desc(), Message.id.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
last_outgoing = (
|
||||||
|
db.query(Message)
|
||||||
|
.filter(Message.thread_id == thread_id, Message.is_incoming.is_(False))
|
||||||
|
.order_by(Message.date_sent.desc(), Message.id.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return last_incoming, last_outgoing
|
||||||
|
|
||||||
|
|
||||||
def ingest_emails(
|
def ingest_emails(
|
||||||
db: Session,
|
db: Session,
|
||||||
account_email: str,
|
account_email: str,
|
||||||
@@ -9,13 +9,14 @@ load_dotenv()
|
|||||||
|
|
||||||
|
|
||||||
class WhatsAppSender:
|
class WhatsAppSender:
|
||||||
def __init__(self):
|
def __init__(self, to_number: str | None = None):
|
||||||
self.account_sid = os.getenv("TWILIO_ACCOUNT_SID")
|
self.account_sid = os.getenv("TWILIO_ACCOUNT_SID")
|
||||||
self.auth_token = os.getenv("TWILIO_AUTH_TOKEN")
|
self.auth_token = os.getenv("TWILIO_AUTH_TOKEN")
|
||||||
self.from_number = os.getenv("TWILIO_WHATSAPP_NUMBER")
|
self.from_number = os.getenv("TWILIO_WHATSAPP_NUMBER")
|
||||||
self.to_number = os.getenv("WHATSAPP_TO_NUMBER") # Individual phone number
|
env_to = os.getenv("WHATSAPP_TO_NUMBER")
|
||||||
|
self.to_number = to_number or env_to # Individual phone number
|
||||||
|
|
||||||
if self.account_sid and self.auth_token:
|
if self.account_sid and self.auth_token and self.from_number and self.to_number:
|
||||||
try:
|
try:
|
||||||
self.client = Client(self.account_sid, self.auth_token)
|
self.client = Client(self.account_sid, self.auth_token)
|
||||||
self.use_mock = False
|
self.use_mock = False
|
||||||
@@ -24,11 +25,9 @@ class WhatsAppSender:
|
|||||||
self.use_mock = True
|
self.use_mock = True
|
||||||
else:
|
else:
|
||||||
self.use_mock = True
|
self.use_mock = True
|
||||||
print("Note: Using mock WhatsApp sender (add Twilio credentials to .env)")
|
print(
|
||||||
|
"Note: Using mock WhatsApp sender (set TWILIO_* and WHATSAPP_TO_NUMBER or pass to_number)"
|
||||||
# Use real WhatsApp mode
|
)
|
||||||
self.use_mock = False
|
|
||||||
print("📱 Using WhatsApp mode")
|
|
||||||
|
|
||||||
def send_alert(self, alert_message: str, thread_id: str = None) -> Dict[str, Any]:
|
def send_alert(self, alert_message: str, thread_id: str = None) -> Dict[str, Any]:
|
||||||
"""Send alert message to WhatsApp"""
|
"""Send alert message to WhatsApp"""
|
||||||
@@ -13,6 +13,9 @@
|
|||||||
<nav>
|
<nav>
|
||||||
<a href="/">Threads</a>
|
<a href="/">Threads</a>
|
||||||
<a href="/config">Config</a>
|
<a href="/config">Config</a>
|
||||||
|
<form action="/sync_emails" method="post" style="display:inline-block; margin-left:12px;">
|
||||||
|
<button type="submit">Process Emails</button>
|
||||||
|
</form>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -47,6 +47,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3 class="mt-2">WhatsApp</h3>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<label>Send To (WhatsApp E.164, no spaces)<br>
|
||||||
|
<input type="text" name="whatsapp_to" placeholder="+15551234567" value="{{ cfg.whatsapp_to or '' }}" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3 class="mt-2">Processing</h3>
|
<h3 class="mt-2">Processing</h3>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
|
|||||||
@@ -23,8 +23,10 @@
|
|||||||
<strong>Actionable:</strong>
|
<strong>Actionable:</strong>
|
||||||
{% if ai.actionable %}<span class="badge warn">Yes</span>{% else %}<span class="badge success">No</span>{% endif %}
|
{% if ai.actionable %}<span class="badge warn">Yes</span>{% else %}<span class="badge success">No</span>{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<p><strong>Summary:</strong> {{ ai.summary }}</p>
|
<p><strong>Summary:</strong> {{ ai.summary or thread.ai_summary }}</p>
|
||||||
<p class="muted">Confidence: {{ ai.confidence }} • Model: {{ ai.model }}</p>
|
<p class="muted">Confidence: {{ ai.confidence }} • Model: {{ ai.model }}</p>
|
||||||
|
<p class="muted">Last stored: {{ thread.last_analyzed_at }}</p>
|
||||||
|
<p class="muted">Last alert level sent: {{ thread.last_alert_level_sent }} at {{ thread.last_alert_sent_at }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,29 @@
|
|||||||
<div class="col">
|
<div class="col">
|
||||||
<h2>Threads{% if account %} for {{ account }}{% endif %}</h2>
|
<h2>Threads{% if account %} for {{ account }}{% endif %}</h2>
|
||||||
<p class="muted">Latest updated threads. Click an ID to view details and AI analysis.</p>
|
<p class="muted">Latest updated threads. Click an ID to view details and AI analysis.</p>
|
||||||
|
{% if status %}
|
||||||
|
<div class="muted" style="margin-top:6px;">
|
||||||
|
{% if status.sync_in_progress %}
|
||||||
|
<span class="badge brand">Syncing…</span>
|
||||||
|
{% else %}
|
||||||
|
{% if status.last_sync_status == 'ok' %}
|
||||||
|
<span class="badge success">Last sync OK</span>
|
||||||
|
{% elif status.last_sync_status == 'error' %}
|
||||||
|
<span class="badge warn">Last sync error</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge">Idle</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<span style="margin-left:8px;">Last Sync: {{ status.last_sync_at or 'never' }}</span>
|
||||||
|
<span style="margin-left:8px;">Items: {{ status.last_sync_count }}</span>
|
||||||
|
{% if status.last_sync_error %}
|
||||||
|
<div class="muted">Error: {{ status.last_sync_error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if status.auto_process %}
|
||||||
|
<div class="muted">Auto process enabled (every {{ status.interval }}m)</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -13,6 +36,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Subject</th>
|
<th>Subject</th>
|
||||||
|
<th>AI Summary</th>
|
||||||
<th>Account</th>
|
<th>Account</th>
|
||||||
<th>Msgs</th>
|
<th>Msgs</th>
|
||||||
<th>Requires Reply</th>
|
<th>Requires Reply</th>
|
||||||
@@ -24,6 +48,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td><a href="/thread/{{ t.id }}">{{ t.id }}</a></td>
|
<td><a href="/thread/{{ t.id }}">{{ t.id }}</a></td>
|
||||||
<td>{{ t.subject }}</td>
|
<td>{{ t.subject }}</td>
|
||||||
|
<td class="muted">{{ t.ai_summary or '' }}</td>
|
||||||
<td><span class="badge">{{ t.account_email }}</span></td>
|
<td><span class="badge">{{ t.account_email }}</span></td>
|
||||||
<td><span class="badge brand">{{ t.messages|length }}</span></td>
|
<td><span class="badge brand">{{ t.messages|length }}</span></td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
Reference in New Issue
Block a user