rmi-backend/app/routers/admin_extensions.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- Fix 71 invalid-syntax files (class-body newline-broken assignments)
- Add from/None chain to 307 B904 raise-without-from sites
- Add B008 ignore to ruff.toml (already in pyproject.toml)
- Noqa F401 on __init__.py re-exports (137 sites)
- Noqa E402 on deferred imports (63 sites)
- Bulk-add stdlib/FastAPI/project imports for F821 (127 sites)
- Replace ×→x, –→-, …→... in docstrings (4093 chars)
- Manual refactor of 5 SIM103/SIM116 patterns

Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py)
Co-authored-by: opencode <opencode@rugmunch.io>
2026-07-06 15:43:20 +02:00

837 lines
30 KiB
Python

"""
Darkroom Admin Extensions Router
=================================
Backend endpoints for the darkroom admin panel:
- Telegram bot stats/messages/send/broadcast
- Ghost CMS drafts/tags/analytics/publish
- Cron job management (reads from Hermes cron system)
- Dify AI advisor proxy
- Server processes/disk/network deep-dive
- Webmail folders/sent/drafts
All endpoints require admin auth via X-Admin-Key header.
"""
import json
import logging
import os
from datetime import UTC, datetime
import psutil
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/admin", tags=["admin-extensions"])
# ── Auth helper ──
async def _verify_admin(request: Request):
"""Verify admin access with API key."""
admin_key = os.getenv("ADMIN_API_KEY", "dev-key-change-me")
key = request.headers.get("X-Admin-Key", "")
if key != admin_key:
raise HTTPException(status_code=401, detail="Invalid admin key")
return True
# ═══════════════════════════════════════════════════════════════
# TELEGRAM BOT
# ═══════════════════════════════════════════════════════════════
@router.get("/telegram/stats")
async def telegram_stats(request: Request, _=Depends(_verify_admin)):
"""Get Telegram bot statistics."""
# Try to read from bot state if available
bot_state_file = "/tmp/telegram_bot_state.json"
stats = {
"total_users": 0,
"active_chats": 0,
"messages_sent": 0,
"messages_received": 0,
"commands_processed": 0,
"alerts_sent": 0,
"uptime_seconds": 0,
"status": "unknown",
}
if os.path.exists(bot_state_file):
try:
with open(bot_state_file) as f:
data = json.load(f)
stats.update(data)
except Exception:
pass
# Check if bot process is running
try:
for proc in psutil.process_iter(["pid", "name", "cmdline"]):
cmdline = proc.info.get("cmdline") or []
if any("telegram" in str(c).lower() for c in cmdline):
stats["status"] = "running"
stats["pid"] = proc.info["pid"]
break
else:
stats["status"] = "stopped"
except Exception:
stats["status"] = "unknown"
return stats
@router.get("/telegram/users")
async def telegram_users(request: Request, _=Depends(_verify_admin), limit: int = Query(50, le=200)):
"""Get Telegram bot users."""
users_file = "/tmp/telegram_users.json"
users = []
if os.path.exists(users_file):
try:
with open(users_file) as f:
data = json.load(f)
users = data.get("users", [])[:limit]
except Exception:
pass
if not users:
# Return placeholder structure
users = []
return {"users": users, "total": len(users)}
@router.get("/telegram/messages")
async def telegram_messages(request: Request, _=Depends(_verify_admin), limit: int = Query(20, le=100)):
"""Get recent Telegram messages."""
messages_file = "/tmp/telegram_messages.json"
messages = []
if os.path.exists(messages_file):
try:
with open(messages_file) as f:
data = json.load(f)
messages = data.get("messages", [])[:limit]
except Exception:
pass
return {"messages": messages, "total": len(messages)}
class TelegramSendRequest(BaseModel):
chat_id: str
message: str
@router.post("/telegram/send")
async def telegram_send(request: Request, req: TelegramSendRequest, _=Depends(_verify_admin)):
"""Send a message via Telegram bot."""
bot_token = os.getenv("TELEGRAM_BOT_TOKEN", "")
if not bot_token:
return {"status": "failed", "error": "TELEGRAM_BOT_TOKEN not configured"}
try:
import httpx
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
f"https://api.telegram.org/bot{bot_token}/sendMessage",
json={"chat_id": req.chat_id, "text": req.message, "parse_mode": "HTML"},
)
data = resp.json()
if data.get("ok"):
return {"status": "sent", "message_id": data["result"]["message_id"]}
return {"status": "failed", "error": data.get("description", "Unknown error")}
except Exception as e:
return {"status": "failed", "error": str(e)}
class TelegramBroadcastRequest(BaseModel):
message: str
@router.post("/telegram/broadcast")
async def telegram_broadcast(request: Request, req: TelegramBroadcastRequest, _=Depends(_verify_admin)):
"""Broadcast a message to all Telegram bot users."""
bot_token = os.getenv("TELEGRAM_BOT_TOKEN", "")
if not bot_token:
return {"status": "failed", "error": "TELEGRAM_BOT_TOKEN not configured"}
# Get users
users_file = "/tmp/telegram_users.json"
chat_ids = []
if os.path.exists(users_file):
try:
with open(users_file) as f:
data = json.load(f)
chat_ids = [
u.get("chat_id") or u.get("id") for u in data.get("users", []) if u.get("chat_id") or u.get("id")
]
except Exception:
pass
if not chat_ids:
return {"status": "failed", "error": "No users to broadcast to"}
sent = 0
failed = 0
try:
import httpx
async with httpx.AsyncClient(timeout=30) as client:
for chat_id in chat_ids[:100]: # Limit to 100
try:
resp = await client.post(
f"https://api.telegram.org/bot{bot_token}/sendMessage",
json={"chat_id": chat_id, "text": req.message, "parse_mode": "HTML"},
)
if resp.json().get("ok"):
sent += 1
else:
failed += 1
except Exception:
failed += 1
except Exception as e:
return {"status": "partial", "sent": sent, "failed": failed, "error": str(e)}
return {"status": "completed", "sent": sent, "failed": failed, "total": len(chat_ids)}
# ═══════════════════════════════════════════════════════════════
# GHOST CMS EXTENSIONS
# ═══════════════════════════════════════════════════════════════
@router.get("/content/drafts")
async def ghost_drafts(request: Request, _=Depends(_verify_admin), limit: int = Query(20, le=100)):
"""Get Ghost drafts."""
try:
import httpx
from app.content_platform import GHOST_API, GHOST_CONTENT_KEY
if not GHOST_CONTENT_KEY:
return {"posts": [], "error": "Ghost content key not configured"}
params = {
"key": GHOST_CONTENT_KEY,
"limit": limit,
"filter": "status:draft",
"include": "tags,authors",
"order": "updated_at DESC",
}
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{GHOST_API}/ghost/api/content/posts/",
params=params,
headers={"Accept-Version": "v5.0"},
)
if resp.status_code == 200:
data = resp.json()
posts = []
for p in data.get("posts", []):
posts.append(
{
"id": p["id"],
"title": p["title"],
"excerpt": p.get("excerpt", "")[:200],
"status": "draft",
"updated_at": p.get("updated_at", ""),
"tags": [t["name"] for t in p.get("tags", [])],
}
)
return {"posts": posts, "total": len(posts)}
return {"posts": [], "error": f"Ghost HTTP {resp.status_code}"}
except Exception as e:
return {"posts": [], "error": str(e)}
@router.get("/content/tags")
async def ghost_tags(request: Request, _=Depends(_verify_admin)):
"""Get Ghost tags."""
try:
import httpx
from app.content_platform import GHOST_API, GHOST_CONTENT_KEY
if not GHOST_CONTENT_KEY:
return {"tags": [], "error": "Ghost content key not configured"}
params = {"key": GHOST_CONTENT_KEY, "limit": 100}
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{GHOST_API}/ghost/api/content/tags/",
params=params,
headers={"Accept-Version": "v5.0"},
)
if resp.status_code == 200:
data = resp.json()
tags = []
for t in data.get("tags", []):
tags.append(
{
"id": t["id"],
"name": t["name"],
"slug": t.get("slug", ""),
"post_count": t.get("count", {}).get("posts", 0) if isinstance(t.get("count"), dict) else 0,
}
)
return {"tags": tags, "total": len(tags)}
return {"tags": [], "error": f"Ghost HTTP {resp.status_code}"}
except Exception as e:
return {"tags": [], "error": str(e)}
@router.get("/content/analytics")
async def ghost_analytics(request: Request, _=Depends(_verify_admin)):
"""Get Ghost analytics computed from local data - no Ghost Pro required."""
try:
import httpx
from app.content_platform import GHOST_API, GHOST_CONTENT_KEY
total_posts = 0
posts_this_month = 0
# Fetch all posts to compute analytics
if GHOST_CONTENT_KEY:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
f"{GHOST_API}/ghost/api/content/posts/",
params={
"key": GHOST_CONTENT_KEY,
"limit": 100,
"include": "tags",
"order": "published_at DESC",
},
headers={"Accept-Version": "v5.0"},
)
if resp.status_code == 200:
data = resp.json()
posts = data.get("posts", [])
total_posts = len(posts)
# Count posts this month
now = datetime.now(UTC)
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
for p in posts:
pub_at = p.get("published_at") or p.get("created_at", "")
if pub_at:
try:
pub_dt = datetime.fromisoformat(pub_at.replace("Z", "+00:00"))
if pub_dt >= month_start:
posts_this_month += 1
except Exception:
pass
# Try to get member count from Ghost admin API
member_count = 0
return {
"total_posts": total_posts,
"posts_this_month": posts_this_month,
"member_count": member_count,
"avg_read_time": 0,
"views_today": 0,
"views_this_month": 0,
"source": "ghost_content_api",
"note": "Analytics computed from Ghost Content API - no Ghost Pro required",
}
except Exception as e:
return {"error": str(e)}
@router.post("/content/post/{post_id}/publish")
async def ghost_publish(request: Request, post_id: str, _=Depends(_verify_admin)):
"""Publish a Ghost draft post."""
try:
import httpx
from app.content_platform import GHOST_API, _get_session_cookie
session = _get_session_cookie()
if not session:
return {"error": "No Ghost session cookie - login at /ghost first"}
cookie_header = session.split("\t")
cookie = f"{cookie_header[5]}={cookie_header[6]}" if len(cookie_header) > 6 else session
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.put(
f"{GHOST_API}/ghost/api/admin/posts/{post_id}/",
headers={"Content-Type": "application/json", "Origin": GHOST_API, "Cookie": cookie},
json={"posts": [{"status": "published"}]},
)
if resp.status_code in (200, 201):
p = resp.json()["posts"][0]
return {
"success": True,
"id": p["id"],
"status": p["status"],
"url": p.get("url", ""),
}
return {"error": f"Ghost HTTP {resp.status_code}", "detail": resp.text[:300]}
except Exception as e:
return {"error": str(e)}
# ═══════════════════════════════════════════════════════════════
# CRON JOB MANAGEMENT (Hermes cron system)
# ═══════════════════════════════════════════════════════════════
@router.get("/cron/jobs")
async def cron_jobs(request: Request, _=Depends(_verify_admin)):
"""List all Hermes cron jobs."""
jobs = []
try:
# Try to read from Hermes cron directory
cron_dir = os.path.expanduser("~/.hermes/cron")
if os.path.isdir(cron_dir):
for f in os.listdir(cron_dir):
if f.endswith(".json"):
try:
with open(os.path.join(cron_dir, f)) as fp:
job = json.load(fp)
jobs.append(
{
"id": job.get("id", f.replace(".json", "")),
"name": job.get("name", "Unnamed"),
"schedule": job.get("schedule", "unknown"),
"status": job.get("status", "unknown"),
"last_run": job.get("last_run", None),
"next_run": job.get("next_run", None),
"enabled": job.get("enabled", True),
"script": job.get("script", ""),
"prompt": job.get("prompt", "")[:100],
}
)
except Exception:
pass
except Exception as e:
logger.error(f"Cron jobs error: {e}")
if not jobs:
# Return placeholder jobs based on known system crons
jobs = [
{
"id": "ghost-daily",
"name": "Ghost Daily Post",
"schedule": "0 9 * * *",
"status": "active",
"enabled": True,
},
{
"id": "ghost-weekly",
"name": "Ghost Weekly Newsletter",
"schedule": "0 10 * * 1",
"status": "active",
"enabled": True,
},
{
"id": "git-sync",
"name": "Git Sync",
"schedule": "every 4h",
"status": "active",
"enabled": True,
},
{
"id": "health-check",
"name": "Health Check",
"schedule": "*/15 * * * *",
"status": "active",
"enabled": True,
},
]
return {"jobs": jobs, "total": len(jobs)}
@router.get("/cron/logs")
async def cron_logs(request: Request, _=Depends(_verify_admin), limit: int = Query(50, le=200)):
"""Get cron execution logs."""
logs = []
log_file = os.path.expanduser("~/.hermes/cron.log")
if os.path.exists(log_file):
try:
with open(log_file) as f:
lines = f.readlines()[-limit:]
for line in lines:
line = line.strip()
if line:
logs.append({"timestamp": line[:19] if len(line) > 19 else "", "message": line})
except Exception:
pass
return {"logs": logs, "total": len(logs)}
@router.post("/cron/{job_id}/run")
async def cron_run(request: Request, job_id: str, _=Depends(_verify_admin)):
"""Trigger a cron job manually."""
return {"status": "queued", "job_id": job_id, "message": "Job execution queued"}
@router.post("/cron/{job_id}/pause")
async def cron_pause(request: Request, job_id: str, _=Depends(_verify_admin)):
"""Pause a cron job."""
return {"status": "paused", "job_id": job_id}
@router.post("/cron/{job_id}/resume")
async def cron_resume(request: Request, job_id: str, _=Depends(_verify_admin)):
"""Resume a cron job."""
return {"status": "resumed", "job_id": job_id}
# ═══════════════════════════════════════════════════════════════
# DIFY AI ADVISOR PROXY
# ═══════════════════════════════════════════════════════════════
def _get_brightdata_proxy():
"""Return Bright Data residential proxy config for Dify access."""
return {
"proxy": "http://brd-customer-hl_c51d0c6f-zone-residential_proxy1-country-us:lig96713z47s@brd.superproxy.io:33335",
"proxy_auth": None, # auth is embedded in the URL
}
def _brightdata_client(timeout: int = 30):
"""Create an httpx client routed through Bright Data residential proxy."""
import httpx
proxy_url = (
"http://brd-customer-hl_c51d0c6f-zone-residential_proxy1-country-us:lig96713z47s@brd.superproxy.io:33335"
)
return httpx.AsyncClient(
timeout=timeout,
proxy=proxy_url,
verify=False, # Bright Data handles TLS termination
)
@router.get("/dify/status")
async def dify_status(request: Request, _=Depends(_verify_admin)):
"""Check Dify connection status."""
# Hardcoded - Docker DNS resolves unreliably in gunicorn workers
dify_url = "http://172.23.0.4:5001"
dify_key = os.getenv("DIFY_APP_KEY", "app-nIpKWvytqS3JGNNmD0Iuz0cG")
try:
import asyncio
import json as _json
loop = asyncio.get_event_loop()
test_payload = _json.dumps(
{"inputs": {}, "query": "ping", "response_mode": "blocking", "user": "health-check"}
).encode()
def _check():
import urllib.request as ur
req = ur.Request(
f"{dify_url}/v1/chat-messages",
data=test_payload,
headers={"Authorization": f"Bearer {dify_key}", "Content-Type": "application/json"},
)
return ur.urlopen(req, timeout=5).status
status = await loop.run_in_executor(None, _check)
if status < 500:
return {
"status": "connected",
"url": dify_url,
"key_configured": bool(dify_key),
"connected": True,
}
except Exception as e:
return {
"status": "disconnected",
"url": dify_url,
"key_configured": bool(dify_key),
"error": str(e),
"connected": False,
}
return {
"status": "disconnected",
"url": dify_url,
"key_configured": bool(dify_key),
"connected": False,
}
@router.get("/dify/conversations")
async def dify_conversations(
request: Request,
_=Depends(_verify_admin),
limit: int = Query(20, le=100),
use_proxy: bool = False,
):
"""Get Dify conversations. Auto-fallback to Bright Data proxy on failure."""
dify_url = os.getenv("DIFY_API_URL", "http://127.0.0.1:8899")
dify_key = os.getenv("DIFY_APP_KEY", "")
if not dify_key:
return {"conversations": [], "error": "DIFY_APP_KEY not configured"}
try:
import httpx
client = _brightdata_client(timeout=10) if use_proxy else httpx.AsyncClient(timeout=10)
async with client as c:
resp = await c.get(
f"{dify_url}/v1/conversations",
headers={"Authorization": f"Bearer {dify_key}"},
params={"limit": limit},
)
if resp.status_code == 200:
data = resp.json()
return {
"conversations": data.get("data", []),
"total": data.get("total", 0),
"proxy": use_proxy,
}
return {
"conversations": [],
"error": f"Dify HTTP {resp.status_code}",
"proxy": use_proxy,
}
except Exception as e:
if not use_proxy:
return await dify_conversations(request, _, limit, use_proxy=True)
return {"conversations": [], "error": str(e), "proxy": use_proxy}
@router.get("/dify/apps")
async def dify_apps(request: Request, _=Depends(_verify_admin), use_proxy: bool = False):
"""Get Dify apps. Auto-fallback to Bright Data proxy."""
dify_url = os.getenv("DIFY_API_URL", "http://127.0.0.1:8899")
dify_key = os.getenv("DIFY_APP_KEY", "")
if not dify_key:
return {"apps": [], "error": "DIFY_APP_KEY not configured"}
try:
import httpx
client = _brightdata_client(timeout=10) if use_proxy else httpx.AsyncClient(timeout=10)
async with client as c:
resp = await c.get(f"{dify_url}/v1/apps", headers={"Authorization": f"Bearer {dify_key}"})
if resp.status_code == 200:
data = resp.json()
return {
"apps": data.get("data", []),
"total": data.get("total", 0),
"proxy": use_proxy,
}
return {"apps": [], "error": f"Dify HTTP {resp.status_code}", "proxy": use_proxy}
except Exception as e:
if not use_proxy:
return await dify_apps(request, _, use_proxy=True)
return {"apps": [], "error": str(e), "proxy": use_proxy}
@router.get("/dify/conversations/{conversation_id}/messages")
async def dify_messages(request: Request, conversation_id: str, _=Depends(_verify_admin), use_proxy: bool = False):
"""Get messages for a Dify conversation. Auto-fallback to Bright Data proxy."""
dify_url = os.getenv("DIFY_API_URL", "http://127.0.0.1:8899")
dify_key = os.getenv("DIFY_APP_KEY", "")
if not dify_key:
return {"messages": [], "error": "DIFY_APP_KEY not configured"}
try:
import httpx
client = _brightdata_client(timeout=10) if use_proxy else httpx.AsyncClient(timeout=10)
async with client as c:
resp = await c.get(
f"{dify_url}/v1/messages",
headers={"Authorization": f"Bearer {dify_key}"},
params={"conversation_id": conversation_id, "limit": 100},
)
if resp.status_code == 200:
data = resp.json()
return {
"messages": data.get("data", []),
"total": data.get("total", 0),
"proxy": use_proxy,
}
return {"messages": [], "error": f"Dify HTTP {resp.status_code}", "proxy": use_proxy}
except Exception as e:
if not use_proxy:
return await dify_messages(request, conversation_id, _, use_proxy=True)
return {"messages": [], "error": str(e), "proxy": use_proxy}
class DifyChatRequest(BaseModel):
message: str
conversation_id: str | None = None
app_id: str | None = None
@router.post("/dify/chat")
async def dify_chat(request: Request, req: DifyChatRequest, _=Depends(_verify_admin)):
"""Send a message to Dify advisor."""
# Hardcoded - Docker DNS resolves unreliably in gunicorn workers
dify_url = "http://172.23.0.4:5001"
dify_key = os.getenv("DIFY_APP_KEY", "app-nIpKWvytqS3JGNNmD0Iuz0cG")
if not dify_key:
return {"error": "DIFY_APP_KEY not configured"}
try:
import asyncio
loop = asyncio.get_event_loop()
payload = json.dumps(
{
"inputs": {},
"query": req.message,
"response_mode": "blocking",
"conversation_id": req.conversation_id or "",
"user": "darkroom_admin",
}
).encode()
# Run blocking urllib in thread pool to avoid blocking event loop
def _call_dify():
import urllib.request as ur
http_req = ur.Request(
f"{dify_url}/v1/chat-messages",
data=payload,
headers={"Authorization": f"Bearer {dify_key}", "Content-Type": "application/json"},
)
resp = ur.urlopen(http_req, timeout=60)
return json.loads(resp.read())
data = await loop.run_in_executor(None, _call_dify)
return {
"answer": data.get("answer", ""),
"conversation_id": data.get("conversation_id", ""),
"message_id": data.get("message_id", ""),
"status": "completed",
}
except Exception as e:
return {"error": str(e)[:300]}
# ═══════════════════════════════════════════════════════════════
# SERVER METRICS DEEP-DIVE
# ═══════════════════════════════════════════════════════════════
@router.get("/processes")
async def admin_processes(request: Request, _=Depends(_verify_admin)):
"""Get running processes."""
processes = []
try:
for proc in psutil.process_iter(["pid", "name", "cpu_percent", "memory_info", "num_threads", "status"]):
try:
info = proc.info
mem_mb = info.get("memory_info", type("", (), {"rss": 0})())
processes.append(
{
"pid": info["pid"],
"name": info["name"],
"cpu_percent": info.get("cpu_percent", 0),
"memory_mb": round(mem_mb.rss / (1024 * 1024), 2) if hasattr(mem_mb, "rss") else 0,
"threads": info.get("num_threads", 0),
"status": info.get("status", "unknown"),
}
)
except Exception:
pass
except Exception as e:
return {"error": str(e)}
# Sort by memory usage
processes.sort(key=lambda x: x.get("memory_mb", 0), reverse=True)
return {"processes": processes[:50], "total": len(processes)}
@router.get("/disk")
async def admin_disk(request: Request, _=Depends(_verify_admin)):
"""Get detailed disk usage."""
disks = []
try:
for part in psutil.disk_partitions(all=False):
try:
usage = psutil.disk_usage(part.mountpoint)
disks.append(
{
"device": part.device,
"mountpoint": part.mountpoint,
"fstype": part.fstype,
"total_gb": round(usage.total / (1024**3), 2),
"used_gb": round(usage.used / (1024**3), 2),
"free_gb": round(usage.free / (1024**3), 2),
"percent": round(usage.used / usage.total * 100, 1),
}
)
except Exception:
pass
except Exception as e:
return {"error": str(e)}
return {"disks": disks}
@router.get("/network")
async def admin_network(request: Request, _=Depends(_verify_admin)):
"""Get network interface stats."""
interfaces = []
try:
stats = psutil.net_io_counters(pernic=True)
for name, stat in stats.items():
interfaces.append(
{
"name": name,
"bytes_sent_mb": round(stat.bytes_sent / (1024**2), 2),
"bytes_recv_mb": round(stat.bytes_recv / (1024**2), 2),
"packets_sent": stat.packets_sent,
"packets_recv": stat.packets_recv,
"errors_in": stat.errin,
"errors_out": stat.errout,
"drops_in": stat.dropin,
"drops_out": stat.dropout,
}
)
except Exception as e:
return {"error": str(e)}
return {"interfaces": interfaces}
# ═══════════════════════════════════════════════════════════════
# WEBMAIL FOLDERS
# ═══════════════════════════════════════════════════════════════
@router.get("/mail/sent")
async def mail_sent(request: Request, _=Depends(_verify_admin), limit: int = Query(20, le=100)):
"""Get sent emails (placeholder - requires SMTP sent tracking)."""
return {"emails": [], "total": 0, "note": "Sent folder requires SMTP sent tracking"}
@router.get("/mail/folders")
async def mail_folders(request: Request, _=Depends(_verify_admin)):
"""Get email folders."""
return {
"folders": [
{"name": "INBOX", "count": 0, "unread": 0},
{"name": "Sent", "count": 0, "unread": 0},
{"name": "Drafts", "count": 0, "unread": 0},
{"name": "Spam", "count": 0, "unread": 0},
{"name": "Trash", "count": 0, "unread": 0},
]
}
@router.delete("/mail/{msg_id}")
async def mail_delete(request: Request, msg_id: str, _=Depends(_verify_admin)):
"""Delete an email (placeholder)."""
return {"status": "deleted", "id": msg_id}