feat: Enhance notification system with WebSocket support and auto-hide alerts
This commit is contained in:
@@ -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
|
# 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:
|
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
|
# There's a reply from us after the last incoming; skip
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
+62
-5
@@ -6,7 +6,7 @@ from contextlib import suppress
|
|||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
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.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
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)
|
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:
|
def _sync_emails_once(cfg: dict) -> int:
|
||||||
"""Fetch INBOX and Sent from Zoho and ingest into DB. Returns threads requiring reply count."""
|
"""Fetch INBOX and Sent from Zoho and ingest into DB. Returns threads requiring reply count."""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -364,14 +375,16 @@ def _sync_emails_once(cfg: dict) -> int:
|
|||||||
days_back = max(1, delta_days)
|
days_back = max(1, delta_days)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
max_results = 100
|
max_results = 10
|
||||||
client = ZohoClient(
|
client = ZohoClient(
|
||||||
email=cfg.get("zoho_email") or account_email,
|
email=cfg.get("zoho_email") or account_email,
|
||||||
app_password=cfg.get("zoho_app_password"),
|
app_password=cfg.get("zoho_app_password"),
|
||||||
)
|
)
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
send_to_all("Fetching emails from inbox...")
|
||||||
inbox = client.fetch_folder_emails(
|
inbox = client.fetch_folder_emails(
|
||||||
folder="INBOX",
|
folder="INBOX",
|
||||||
max_results=max_results,
|
max_results=max_results,
|
||||||
@@ -379,6 +392,8 @@ def _sync_emails_once(cfg: dict) -> int:
|
|||||||
db_session=db,
|
db_session=db,
|
||||||
account_email=account_email,
|
account_email=account_email,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
send_to_all("Fetching emails from sent...")
|
||||||
sent = client.fetch_folder_emails(
|
sent = client.fetch_folder_emails(
|
||||||
folder="Sent",
|
folder="Sent",
|
||||||
max_results=max_results,
|
max_results=max_results,
|
||||||
@@ -389,6 +404,7 @@ def _sync_emails_once(cfg: dict) -> int:
|
|||||||
finally:
|
finally:
|
||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
|
send_to_all("Analysing Threads with AI")
|
||||||
try:
|
try:
|
||||||
ingest_emails(
|
ingest_emails(
|
||||||
db, account_email=account_email, emails=inbox, default_folder="INBOX"
|
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())
|
.filter(Thread.account_email == account_email.lower())
|
||||||
.count()
|
.count()
|
||||||
)
|
)
|
||||||
|
send_to_all("Email synced Successfully")
|
||||||
return count
|
return count
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -445,19 +463,57 @@ def _sync_emails_background_task():
|
|||||||
@app.post("/sync_emails")
|
@app.post("/sync_emails")
|
||||||
async def sync_emails(background_tasks: BackgroundTasks):
|
async def sync_emails(background_tasks: BackgroundTasks):
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
|
|
||||||
if cfg.get("sync_in_progress"):
|
if cfg.get("sync_in_progress"):
|
||||||
return RedirectResponse(url="/?sync=busy", status_code=303)
|
return {"status": "already_running"}
|
||||||
|
|
||||||
# Mark sync as starting
|
# Mark sync as starting
|
||||||
cfg["sync_in_progress"] = True
|
cfg["sync_in_progress"] = True
|
||||||
cfg["last_sync_status"] = "running"
|
cfg["last_sync_status"] = "running"
|
||||||
cfg["last_sync_error"] = None
|
cfg["last_sync_error"] = None
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
|
|
||||||
# Add the background task
|
# Add the background task
|
||||||
background_tasks.add_task(_sync_emails_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")
|
@app.on_event("startup")
|
||||||
async def on_startup():
|
async def on_startup():
|
||||||
|
app.state.loop = asyncio.get_running_loop()
|
||||||
# Reset any stale sync status from previous session
|
# Reset any stale sync status from previous session
|
||||||
try:
|
try:
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
|
|||||||
+85
-1
@@ -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; }
|
th { background: #0f152a; color: var(--muted); text-align: left; position: sticky; top: 0; font-weight: 600; }
|
||||||
tbody tr:hover { background: #0e1426; }
|
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 {
|
pre {
|
||||||
background: #0b1121;
|
background: #0b1121;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
@@ -149,6 +149,8 @@ button:hover { filter: brightness(1.05); }
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid;
|
border: 1px solid;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
/* Smooth hide animation support */
|
||||||
|
transition: opacity 0.35s ease, transform 0.35s ease;
|
||||||
}
|
}
|
||||||
.alert.success {
|
.alert.success {
|
||||||
background: rgba(34, 197, 94, 0.1);
|
background: rgba(34, 197, 94, 0.1);
|
||||||
@@ -165,3 +167,85 @@ button:hover { filter: brightness(1.05); }
|
|||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
color: #f87171;
|
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; }
|
||||||
|
}
|
||||||
|
|||||||
+102
-4
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Email Alerts</title>
|
<title>Email Alerts</title>
|
||||||
<link rel="stylesheet" href="/static/styles.css" />
|
<link rel="stylesheet" href="{{ url_for('static', path='/styles.css') }}" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
@@ -13,17 +13,115 @@
|
|||||||
<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 id="processEmailsBtn" style="margin-left:12px;">Process Emails</button>
|
||||||
<button type="submit">Process Emails</button>
|
|
||||||
</form>
|
|
||||||
<form action="/process" method="post" style="display:inline-block; margin-left:12px;">
|
<form action="/process" method="post" style="display:inline-block; margin-left:12px;">
|
||||||
<button type="submit">Process Alerts</button>
|
<button type="submit">Process Alerts</button>
|
||||||
</form>
|
</form>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<!-- Notification banner -->
|
||||||
|
<div id="notify-root" class="notify-banner" aria-live="polite" aria-atomic="false"></div>
|
||||||
<main class="container">
|
<main class="container">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const wsScheme = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
const socket = new WebSocket(`${wsScheme}://${location.host}/ws`);
|
||||||
|
|
||||||
|
// One-time reload guard after successful sync
|
||||||
|
let reloadedAfterSync = false;
|
||||||
|
|
||||||
|
function maybeRefreshFromMessage(message) {
|
||||||
|
try {
|
||||||
|
const msg = String(message || '').toLowerCase();
|
||||||
|
if (!reloadedAfterSync && msg.includes('email synced successfully')) {
|
||||||
|
reloadedAfterSync = true;
|
||||||
|
// Give users a moment to read the success banner, then refresh
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple single-banner notification system
|
||||||
|
function pushNotification(message, level = 'info') {
|
||||||
|
try {
|
||||||
|
const root = document.getElementById('notify-root');
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const levels = new Set(['info', 'success', 'warn', 'danger']);
|
||||||
|
const cls = levels.has(String(level)) ? String(level) : 'info';
|
||||||
|
|
||||||
|
// Ensure only a single banner is visible at a time
|
||||||
|
root.innerHTML = '';
|
||||||
|
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = `notify ${cls}`;
|
||||||
|
item.setAttribute('role', 'status');
|
||||||
|
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.className = 'notify-content';
|
||||||
|
content.textContent = String(message ?? '');
|
||||||
|
|
||||||
|
const closeBtn = document.createElement('button');
|
||||||
|
closeBtn.className = 'notify-close';
|
||||||
|
closeBtn.setAttribute('aria-label', 'Close notification');
|
||||||
|
closeBtn.innerHTML = '×';
|
||||||
|
closeBtn.addEventListener('click', () => {
|
||||||
|
// Clear the banner on close
|
||||||
|
root.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
item.appendChild(content);
|
||||||
|
item.appendChild(closeBtn);
|
||||||
|
root.appendChild(item);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to render notification:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onmessage = function(event) {
|
||||||
|
try {
|
||||||
|
let data = null;
|
||||||
|
try { data = JSON.parse(event.data); } catch { /* may be plain text */ }
|
||||||
|
|
||||||
|
if (data && typeof data === 'object') {
|
||||||
|
const msg = data.message ?? data.msg ?? data.text ?? JSON.stringify(data);
|
||||||
|
const level = (data.level || data.type || data.status || 'info').toString().toLowerCase();
|
||||||
|
pushNotification(msg, level);
|
||||||
|
console.log('Received sync status update:', msg);
|
||||||
|
maybeRefreshFromMessage(msg);
|
||||||
|
} else {
|
||||||
|
const text = String(event.data);
|
||||||
|
pushNotification(text);
|
||||||
|
console.log('Received sync status update:', text);
|
||||||
|
maybeRefreshFromMessage(text);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error handling websocket message:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("processEmailsBtn").addEventListener("click", function () {
|
||||||
|
fetch("/sync_emails", { method: "POST" })
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
const status = data?.status ?? 'ok';
|
||||||
|
pushNotification(`Email sync triggered (${status})`, status === 'ok' ? 'success' : 'info');
|
||||||
|
console.log("Sync triggered:", status);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
pushNotification(`Error starting sync: ${err?.message || err}`, 'danger');
|
||||||
|
console.error("Error starting sync:", err)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+18
-1
@@ -8,6 +8,23 @@
|
|||||||
<div class="alert success" style="margin-bottom:12px;">
|
<div class="alert success" style="margin-bottom:12px;">
|
||||||
✓ Alerts processed! {{ alerts_processed }} thread(s) were checked for alerts.
|
✓ Alerts processed! {{ alerts_processed }} thread(s) were checked for alerts.
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
// Auto-hide the success alert after a short delay with a smooth fade-out
|
||||||
|
(function() {
|
||||||
|
const alertEl = document.currentScript?.previousElementSibling;
|
||||||
|
if (!alertEl || !alertEl.classList || !alertEl.classList.contains('alert')) return;
|
||||||
|
const hideMs = 3000; // visible duration
|
||||||
|
const fadeMs = 350; // should match CSS transition
|
||||||
|
setTimeout(() => {
|
||||||
|
alertEl.classList.add('fade-out');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (alertEl && alertEl.parentNode) {
|
||||||
|
alertEl.parentNode.removeChild(alertEl);
|
||||||
|
}
|
||||||
|
}, fadeMs + 25);
|
||||||
|
}, hideMs);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if status %}
|
{% if status %}
|
||||||
<div class="muted" style="margin-top:6px;">
|
<div class="muted" style="margin-top:6px;">
|
||||||
@@ -25,7 +42,7 @@
|
|||||||
<span style="margin-left:8px;">Last Sync: {{ status.last_sync_at or 'never' }}</span>
|
<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>
|
<span style="margin-left:8px;">Items: {{ status.last_sync_count }}</span>
|
||||||
{% if status.last_sync_error %}
|
{% if status.last_sync_error %}
|
||||||
<div class="muted">Error: {{ status.last_sync_error }}</div>
|
<div class="muted" style="margin-top:2px;">Error: {{ status.last_sync_error }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if status.auto_process %}
|
{% if status.auto_process %}
|
||||||
<div class="muted">Auto process enabled (every {{ status.interval }}m)</div>
|
<div class="muted">Auto process enabled (every {{ status.interval }}m)</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user