From bd4a795a5a2e19c3f9c5bcdadf043f3a5483f349 Mon Sep 17 00:00:00 2001 From: bolade Date: Wed, 13 Aug 2025 14:49:23 +0100 Subject: [PATCH] feat: Enhance notification system with WebSocket support and auto-hide alerts --- src/alerts.py | 1 + src/app.py | 67 +++++++++++++++++++++++-- static/styles.css | 86 +++++++++++++++++++++++++++++++- templates/base.html | 108 +++++++++++++++++++++++++++++++++++++++-- templates/threads.html | 19 +++++++- 5 files changed, 269 insertions(+), 12 deletions(-) diff --git a/src/alerts.py b/src/alerts.py index 444c414..0bd03ef 100644 --- a/src/alerts.py +++ b/src/alerts.py @@ -108,6 +108,7 @@ def process_alerts(db: Session, cfg: dict) -> List[int]: # 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: + print("Here in lies the problem") # There's a reply from us after the last incoming; skip continue diff --git a/src/app.py b/src/app.py index 24f10a1..5e1e1e0 100644 --- a/src/app.py +++ b/src/app.py @@ -6,7 +6,7 @@ from contextlib import suppress from typing import List from dotenv import load_dotenv -from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Request +from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Request, WebSocket from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates @@ -345,6 +345,17 @@ def process(db: Session = Depends(get_db)): return RedirectResponse(url=f"/?alerts_processed={len(alerted)}", status_code=303) +def send_to_all(message: str): + logging.info( + "Broadcasting to %d clients: %s", len(manager.active_connections), message + ) + loop = getattr(app.state, "loop", None) + if not loop or not loop.is_running(): + logging.warning("ASGI event loop not ready; skipping broadcast") + return + asyncio.run_coroutine_threadsafe(manager.broadcast(message), loop) + + 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 @@ -364,14 +375,16 @@ def _sync_emails_once(cfg: dict) -> int: days_back = max(1, delta_days) except Exception: pass - max_results = 100 + max_results = 10 client = ZohoClient( email=cfg.get("zoho_email") or account_email, app_password=cfg.get("zoho_app_password"), ) db = SessionLocal() + try: + send_to_all("Fetching emails from inbox...") inbox = client.fetch_folder_emails( folder="INBOX", max_results=max_results, @@ -379,6 +392,8 @@ def _sync_emails_once(cfg: dict) -> int: db_session=db, account_email=account_email, ) + + send_to_all("Fetching emails from sent...") sent = client.fetch_folder_emails( folder="Sent", max_results=max_results, @@ -389,6 +404,7 @@ def _sync_emails_once(cfg: dict) -> int: finally: client.close() + send_to_all("Analysing Threads with AI") try: ingest_emails( db, account_email=account_email, emails=inbox, default_folder="INBOX" @@ -402,7 +418,9 @@ def _sync_emails_once(cfg: dict) -> int: .filter(Thread.account_email == account_email.lower()) .count() ) + send_to_all("Email synced Successfully") return count + finally: db.close() @@ -445,19 +463,57 @@ def _sync_emails_background_task(): @app.post("/sync_emails") async def sync_emails(background_tasks: BackgroundTasks): cfg = load_config() + if cfg.get("sync_in_progress"): - return RedirectResponse(url="/?sync=busy", status_code=303) + return {"status": "already_running"} # Mark sync as starting cfg["sync_in_progress"] = True cfg["last_sync_status"] = "running" cfg["last_sync_error"] = None save_config(cfg) - # Add the background task background_tasks.add_task(_sync_emails_background_task) - return RedirectResponse(url="/?sync=started", status_code=303) + return {"status": "syncing"} + + +class ConnectionManager: + def __init__(self): + self.active_connections: List[WebSocket] = [] + + async def connect(self, websocket: WebSocket): + await websocket.accept() + self.active_connections.append(websocket) + + def disconnect(self, websocket: WebSocket): + with suppress(ValueError): + self.active_connections.remove(websocket) + + async def broadcast(self, message: str): + for connection in list(self.active_connections): + try: + await connection.send_json({"message": message}) + except Exception: + with suppress(Exception): + await connection.close() + with suppress(ValueError): + if connection in self.active_connections: + self.active_connections.remove(connection) + + +manager = ConnectionManager() + + +@app.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket): + await manager.connect(websocket) + try: + while True: + data = await websocket.receive_text() + await manager.broadcast(f"{data}") + except Exception: + manager.disconnect(websocket) # --------------------- @@ -529,6 +585,7 @@ async def _auto_runner(): @app.on_event("startup") async def on_startup(): + app.state.loop = asyncio.get_running_loop() # Reset any stale sync status from previous session try: cfg = load_config() diff --git a/static/styles.css b/static/styles.css index b12a465..6390227 100644 --- a/static/styles.css +++ b/static/styles.css @@ -60,7 +60,7 @@ th, td { border: 1px solid var(--border); padding: 12px 15px; vertical-align: to th { background: #0f152a; color: var(--muted); text-align: left; position: sticky; top: 0; font-weight: 600; } tbody tr:hover { background: #0e1426; } -pre, code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } +/* pre, code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } */ pre { background: #0b1121; padding: 0.75rem; @@ -149,6 +149,8 @@ button:hover { filter: brightness(1.05); } border-radius: 8px; border: 1px solid; font-size: 0.9rem; + /* Smooth hide animation support */ + transition: opacity 0.35s ease, transform 0.35s ease; } .alert.success { background: rgba(34, 197, 94, 0.1); @@ -165,3 +167,85 @@ button:hover { filter: brightness(1.05); } border-color: var(--danger); color: #f87171; } + +/* Generic fade-out helper when dismissing alerts */ +.alert.fade-out { + opacity: 0; + transform: translateY(-4px); + pointer-events: none; +} + +/* Notification banner */ +.notify-banner { + position: relative; /* lives under the header */ + max-width: 1400px; + margin: 0.5rem auto 0; /* small gap under header */ + padding: 0 1rem; /* match container gutter */ +} + +.notify { + pointer-events: auto; + width: 100%; + display: flex; + align-items: center; + gap: 10px; + --accent: #39518a; + background: + linear-gradient(180deg, var(--panel) 0%, var(--panel-soft) 100%), + linear-gradient(0deg, rgba(79, 140, 255, 0.06), rgba(79, 140, 255, 0.06)); + border: 1px solid var(--border); + border-left: 4px solid var(--accent); + color: var(--text); + border-radius: 10px; + box-shadow: 0 8px 22px rgba(0,0,0,0.28); + padding: 0.75rem 0.75rem 0.75rem 0.9rem; + transition: transform 0.2s ease, opacity 0.2s ease, background 0.2s ease; +} + +.notify.info { + --accent: #4f8cff; + background: + linear-gradient(180deg, var(--panel) 0%, var(--panel-soft) 100%), + linear-gradient(0deg, rgba(79, 140, 255, 0.08), rgba(79, 140, 255, 0.08)); +} +.notify.success { + --accent: var(--success); + background: + linear-gradient(180deg, var(--panel) 0%, var(--panel-soft) 100%), + linear-gradient(0deg, rgba(34, 197, 94, 0.10), rgba(34, 197, 94, 0.10)); +} +.notify.warn { + --accent: var(--warn); + background: + linear-gradient(180deg, var(--panel) 0%, var(--panel-soft) 100%), + linear-gradient(0deg, rgba(245, 158, 11, 0.10), rgba(245, 158, 11, 0.10)); +} +.notify.danger { + --accent: var(--danger); + background: + linear-gradient(180deg, var(--panel) 0%, var(--panel-soft) 100%), + linear-gradient(0deg, rgba(239, 68, 68, 0.10), rgba(239, 68, 68, 0.10)); +} + +.notify-content { font-size: 0.95rem; line-height: 1.35rem; flex: 1; } + +.notify-close { + margin-left: 8px; + background: transparent; + color: var(--muted); + border: 1px solid transparent; + border-radius: 6px; + padding: 2px 6px; + font-size: 18px; + line-height: 1; +} +.notify-close:hover { + background: #27345a; + border-color: var(--border); + color: #c9d1ff; +} + +@media (max-width: 600px) { + .notify-banner { padding: 0 10px; } + .notify { max-width: 100%; min-width: 0; } +} diff --git a/templates/base.html b/templates/base.html index 32153eb..523a7bd 100644 --- a/templates/base.html +++ b/templates/base.html @@ -4,7 +4,7 @@ Email Alerts - +
@@ -12,18 +12,116 @@

Email Alerts

+ +
{% block content %}{% endblock %}
+ + + diff --git a/templates/threads.html b/templates/threads.html index 0be9078..19cf822 100644 --- a/templates/threads.html +++ b/templates/threads.html @@ -8,6 +8,23 @@
✓ Alerts processed! {{ alerts_processed }} thread(s) were checked for alerts.
+ {% endif %} {% if status %}
@@ -25,7 +42,7 @@ Last Sync: {{ status.last_sync_at or 'never' }} Items: {{ status.last_sync_count }} {% if status.last_sync_error %} -
Error: {{ status.last_sync_error }}
+
Error: {{ status.last_sync_error }}
{% endif %} {% if status.auto_process %}
Auto process enabled (every {{ status.interval }}m)