"""IMAP Proxy dashboard router - status and metrics API."""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse
from datetime import datetime, timezone
Public router - no auth required for status/metrics
public_router = APIRouter(prefix="/api/public/imap", tags=["imap-proxy"])
Private router - requires auth for dashboard HTML (if needed in future)
router = APIRouter(prefix="/imap", tags=["imap-proxy"])
Will be set by main app at startup
_proxy_instance = None
def set_proxy(proxy):
"""Register the active IMAP proxy instance for dashboard queries."""
global _proxy_instance
_proxy_instance = proxy
@public_router.get("/status")
async def imap_status():
"""IMAP proxy status as JSON (public endpoint)."""
if not _proxy_instance:
return {
"status": "not_configured",
"message": "No IMAP proxy configured. Set IMAP_USER and IMAP_PASSWORD env vars.",
}
return _proxy_instance.get_status()
@public_router.get("/metrics")
async def imap_metrics():
"""Recent email processing metrics (public endpoint)."""
from .proxy import get_metrics
return get_metrics()
Keep old routes for backward compatibility during transition
@router.get("/status")
async def imap_status_legacy():
"""DEPRECATED: Use /api/public/imap/status instead."""
return await imap_status()
@router.get("/metrics")
async def imap_metrics_legacy():
"""DEPRECATED: Use /api/public/imap/metrics instead."""
return await imap_metrics()
@router.get("/dashboard", response_class=HTMLResponse)
async def imap_dashboard():
"""IMAP proxy dashboard as embeddable HTML fragment."""
status = {}
metrics = {}
if _proxy_instance:
status = _proxy_instance.get_status()
else:
status = {"status": "not_configured"}
from .proxy import get_metrics
metrics = get_metrics()
# Build status pill
status_val = status.get("status", "unknown")
pill_map = {
"connected": ("status-healthy", "● Connected"),
"disconnected": ("status-degraded", "○ Disconnected"),
"auth_error": ("status-critical", "✕ Auth Failed"),
"connection_error": ("status-degraded", "○ Conn Error"),
"stopped": ("status-degraded", "■ Stopped"),
"not_configured": ("status-critical", "○ Not Configured"),
}
pill_class, pill_label = pill_map.get(status_val, ("status-degraded", status_val))
provider_label = status.get("provider_label", status.get("provider", "—"))
uptime = status.get("uptime_seconds")
uptime_str = _format_uptime(uptime) if uptime else "—"
last_fetch = status.get("last_fetch_at")
last_fetch_str = _format_time_ago(last_fetch) if last_fetch else "Never"
emails_count = status.get("emails_fetched", 0)
last_error = status.get("last_error", "")
recent = metrics.get("recent_emails", [])
recent_html = ""
if recent:
for e in reversed(recent[-10:]):
subj = _esc(e.get("subject", "(no subject)"))
sender = _esc(e.get("from", "unknown"))
ft = e.get("fetched_at", "")
ago = _format_time_ago(ft) if ft else ""
# Truncate sender to just the name part if it's an email
sender_clean = sender.split('<')[0].strip() if '<' in sender else sender
if len(sender_clean) > 25:
sender_clean = sender_clean[:22] + "..."
recent_html += f'''
<div class="email-item">
<div class="email-subject">{subj}</div>
<div class="email-meta">
<span class="email-sender">{sender_clean}</span>
<span class="email-time">{ago}</span>
</div>
</div>'''
else:
recent_html = '''
<div class="imap-empty">
<div class="imap-empty-icon">📭</div>
<p>No emails processed yet</p>
</div>'''
error_html = ""
if last_error:
error_html = f'''<div class="imap-error">{_esc(last_error)}</div>'''
# Provider icon mapping
provider_icon = {
"gmail": "📧 Gmail",
"icloud": "☁️ iCloud",
"outlook": "📧 Outlook",
"yahoo": "📧 Yahoo"
}.get(status.get("provider", "").lower(), f"📧 {provider_label}")
return HTMLResponse(content=f"""
<div class="card" id="imap-card" aria-label="IMAP Proxy"
hx-get="/api/public/imap/dashboard"
hx-trigger="every 10s"
hx-swap="outerHTML">
<div class="card-header">
<h2 class="text-headline">📧 Email</h2>
<span class="status-pill {pill_class}">{pill_label}</span>
</div>
<div class="card-body">
<div class="imap-status-header">
<div class="imap-status-dot {status_val}"></div>
<div class="imap-status-text {status_val}">{pill_label}</div>
<div class="imap-status-detail">{uptime_str}</div>
</div>
<div class="imap-overview">
<div class="imap-row">
<span>Provider</span>
<span data-provider="{_esc(status.get('provider', '').lower())}">{provider_icon}</span>
</div>
<div class="imap-row">
<span>Account</span>
<span>{_esc(status.get('username', '—'))}</span>
</div>
<div class="imap-row">
<span>Emails Processed</span>
<span>{emails_count}</span>
</div>
<div class="imap-row">
<span>Last Fetch</span>
<span>{last_fetch_str}</span>
</div>
</div>
{error_html}
<div class="imap-recent">
<h3>Recent Emails</h3>
<div class="email-list">
{recent_html}
</div>
</div>
</div>
</div>
""")
def _format_uptime(seconds):
if seconds < 60:
return f"{int(seconds)}s"
if seconds < 3600:
return f"{int(seconds // 60)}m"
if seconds < 86400:
return f"{int(seconds // 3600)}h {int((seconds % 3600) // 60)}m"
return f"{int(seconds // 86400)}d"
@public_router.get("/dashboard", response_class=HTMLResponse)
async def imap_dashboard_public():
"""Public IMAP proxy dashboard fragment (no auth required)."""
return await imap_dashboard()
def _format_time_ago(iso_str):
try:
then = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
now = datetime.now(timezone.utc)
diff = (now - then).total_seconds()
if diff < 60:
return "just now"
if diff < 3600:
return f"{int(diff // 60)}m ago"
if diff < 86400:
return f"{int(diff // 3600)}h ago"
return f"{int(diff // 86400)}d ago"
except Exception:
return iso_str or "—"
def _esc(text):
if not text:
return ""
return str(text).replace("&", "&").replace("<", "<").replace(">", ">")