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 os
|
||||
from contextlib import suppress
|
||||
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.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():
|
||||
@@ -48,6 +53,7 @@ def home(request: Request, db: Session = Depends(get_db), account: str | None =
|
||||
"request": request,
|
||||
"threads": threads,
|
||||
"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
|
||||
]
|
||||
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",
|
||||
{
|
||||
@@ -79,6 +96,7 @@ def show_thread(thread_id: int, request: Request, db: Session = Depends(get_db))
|
||||
"thread": thread,
|
||||
"messages": messages,
|
||||
"ai": ai,
|
||||
"status": _status_for_templates(),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -108,8 +126,15 @@ def load_config() -> dict:
|
||||
"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)
|
||||
@@ -120,6 +145,19 @@ def save_config(cfg: dict) -> None:
|
||||
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()
|
||||
@@ -133,6 +171,7 @@ def config_form(request: Request, saved: int | None = None):
|
||||
"cfg": cfg,
|
||||
"rows": list(range(min_rows)),
|
||||
"saved": bool(saved),
|
||||
"status": _status_for_templates(),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -170,6 +209,8 @@ async def config_save(request: Request):
|
||||
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] = []
|
||||
@@ -202,3 +243,169 @@ async def config_save(request: Request):
|
||||
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
Column,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
@@ -41,6 +42,47 @@ Base = declarative_base()
|
||||
|
||||
def create_db_tables():
|
||||
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):
|
||||
@@ -53,6 +95,13 @@ class Thread(Base):
|
||||
thread_key = Column(String, nullable=False)
|
||||
subject = Column(String, index=True)
|
||||
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())
|
||||
updated_at = Column(
|
||||
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(
|
||||
db: Session,
|
||||
account_email: str,
|
||||
@@ -9,13 +9,14 @@ load_dotenv()
|
||||
|
||||
|
||||
class WhatsAppSender:
|
||||
def __init__(self):
|
||||
def __init__(self, to_number: str | None = None):
|
||||
self.account_sid = os.getenv("TWILIO_ACCOUNT_SID")
|
||||
self.auth_token = os.getenv("TWILIO_AUTH_TOKEN")
|
||||
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:
|
||||
self.client = Client(self.account_sid, self.auth_token)
|
||||
self.use_mock = False
|
||||
@@ -24,11 +25,9 @@ class WhatsAppSender:
|
||||
self.use_mock = True
|
||||
else:
|
||||
self.use_mock = True
|
||||
print("Note: Using mock WhatsApp sender (add Twilio credentials to .env)")
|
||||
|
||||
# Use real WhatsApp mode
|
||||
self.use_mock = False
|
||||
print("📱 Using WhatsApp mode")
|
||||
print(
|
||||
"Note: Using mock WhatsApp sender (set TWILIO_* and WHATSAPP_TO_NUMBER or pass to_number)"
|
||||
)
|
||||
|
||||
def send_alert(self, alert_message: str, thread_id: str = None) -> Dict[str, Any]:
|
||||
"""Send alert message to WhatsApp"""
|
||||
@@ -13,6 +13,9 @@
|
||||
<nav>
|
||||
<a href="/">Threads</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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -47,6 +47,15 @@
|
||||
</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>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
|
||||
@@ -23,8 +23,10 @@
|
||||
<strong>Actionable:</strong>
|
||||
{% if ai.actionable %}<span class="badge warn">Yes</span>{% else %}<span class="badge success">No</span>{% endif %}
|
||||
</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">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>
|
||||
|
||||
@@ -4,6 +4,29 @@
|
||||
<div class="col">
|
||||
<h2>Threads{% if account %} for {{ account }}{% endif %}</h2>
|
||||
<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>
|
||||
|
||||
@@ -13,6 +36,7 @@
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Subject</th>
|
||||
<th>AI Summary</th>
|
||||
<th>Account</th>
|
||||
<th>Msgs</th>
|
||||
<th>Requires Reply</th>
|
||||
@@ -24,6 +48,7 @@
|
||||
<tr>
|
||||
<td><a href="/thread/{{ t.id }}">{{ t.id }}</a></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 brand">{{ t.messages|length }}</span></td>
|
||||
<td>
|
||||
|
||||
Reference in New Issue
Block a user