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:
bolade
2025-08-11 17:34:35 +01:00
parent ca7df9d598
commit d553d6f31e
13 changed files with 879 additions and 150 deletions
+208 -1
View File
@@ -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