Brings the public tree back in line with the development repo after several weeks of drift caused by a stale publish include list. New: - In-app update path: GET /update/check compares the checkout against origin/main and POST /update/apply runs `ncp upgrade` detached (pull, rebuild, restart). The sidebar shows the version, checks on click, and offers an "update available" pill. - Projects: a project workspace groups chats and RAG documents, with per-project instructions and document retrieval scoped to the active project. Replaces the standalone Documents page. - modules/: auto-discovered feature plugins (mail, network) with their frontend counterparts and tests. - Memory curation runs in-process (synapse/memory/curator.py) on the chat model when a conversation goes idle. The separate memory service on :8001 is gone, along with the launcher lines that started it. Also: the KDE theme, panel and Promethean terminal assets, the full test suite, and VERSION 1.2.0. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
114 lines
3.8 KiB
Python
114 lines
3.8 KiB
Python
"""HTTP routes for the mail module — mounted onto the main app via modules/registry.py."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio as _asyncio
|
|
from typing import Any, Dict
|
|
|
|
from fastapi import APIRouter, Body, HTTPException
|
|
|
|
from . import backend as mail
|
|
|
|
MANIFEST = {"key": "mail", "label": "Mail", "icon": "✉️"}
|
|
|
|
router = APIRouter(prefix="/mail", tags=["mail"])
|
|
|
|
|
|
def _require_mail(account_id: str):
|
|
if not mail.is_configured(account_id):
|
|
raise HTTPException(status_code=400, detail="mail account not configured")
|
|
|
|
|
|
# --- accounts --------------------------------------------------------------------
|
|
@router.get("/accounts")
|
|
async def mail_accounts():
|
|
return {"accounts": mail.public_accounts()}
|
|
|
|
|
|
@router.post("/accounts")
|
|
async def mail_account_create(payload: Dict[str, Any] = Body(...)):
|
|
return mail.save_account(None, payload)
|
|
|
|
|
|
@router.put("/accounts/{account_id}")
|
|
async def mail_account_update(account_id: str, payload: Dict[str, Any] = Body(...)):
|
|
try:
|
|
return mail.save_account(account_id, payload)
|
|
except KeyError:
|
|
raise HTTPException(status_code=404, detail="account not found")
|
|
|
|
|
|
@router.delete("/accounts/{account_id}")
|
|
async def mail_account_delete(account_id: str):
|
|
mail.delete_account(account_id)
|
|
return {"status": "deleted"}
|
|
|
|
|
|
@router.post("/accounts/{account_id}/test")
|
|
async def mail_account_test(account_id: str):
|
|
_require_mail(account_id)
|
|
return await _asyncio.to_thread(mail.test_connection, account_id)
|
|
|
|
|
|
# --- mailbox -----------------------------------------------------------------------
|
|
@router.get("/folders")
|
|
async def mail_folders(account_id: str):
|
|
_require_mail(account_id)
|
|
try:
|
|
return {"folders": await _asyncio.to_thread(mail.list_folders, account_id)}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=502, detail=f"IMAP error: {e}")
|
|
|
|
|
|
@router.get("/messages")
|
|
async def mail_messages(account_id: str, folder: str = "INBOX", limit: int = 30, offset: int = 0):
|
|
_require_mail(account_id)
|
|
try:
|
|
msgs = await _asyncio.to_thread(mail.list_messages, account_id, folder, limit, offset)
|
|
return {"messages": msgs}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=502, detail=f"IMAP error: {e}")
|
|
|
|
|
|
@router.get("/message")
|
|
async def mail_message(account_id: str, folder: str, uid: str):
|
|
_require_mail(account_id)
|
|
try:
|
|
msg = await _asyncio.to_thread(mail.get_message, account_id, folder, uid)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=502, detail=f"IMAP error: {e}")
|
|
if not msg:
|
|
raise HTTPException(status_code=404, detail="message not found")
|
|
return msg
|
|
|
|
|
|
@router.post("/send")
|
|
async def mail_send(payload: Dict[str, Any] = Body(...)):
|
|
account_id = payload.get("account_id", "")
|
|
_require_mail(account_id)
|
|
to = (payload.get("to") or "").strip()
|
|
if not to:
|
|
raise HTTPException(status_code=400, detail="'to' is required")
|
|
try:
|
|
return await _asyncio.to_thread(
|
|
mail.send_message, account_id, to, payload.get("subject", ""), payload.get("body", ""),
|
|
payload.get("cc", ""), payload.get("from_addr", ""),
|
|
)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=502, detail=f"SMTP error: {e}")
|
|
|
|
|
|
@router.post("/seen")
|
|
async def mail_seen(payload: Dict[str, Any] = Body(...)):
|
|
account_id = payload.get("account_id", "")
|
|
_require_mail(account_id)
|
|
await _asyncio.to_thread(mail.set_seen, account_id, payload["folder"], payload["uid"], payload.get("seen", True))
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.post("/delete")
|
|
async def mail_delete(payload: Dict[str, Any] = Body(...)):
|
|
account_id = payload.get("account_id", "")
|
|
_require_mail(account_id)
|
|
await _asyncio.to_thread(mail.delete_message, account_id, payload["folder"], payload["uid"])
|
|
return {"status": "deleted"}
|