This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Benjamin Admin 21a844cb8a fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 09:51:32 +01:00

652 lines
19 KiB
Python

"""
Unified Inbox Mail API
FastAPI router for the mail system.
"""
import logging
from typing import Optional, List
from datetime import datetime
from fastapi import APIRouter, HTTPException, Depends, Query, BackgroundTasks
from pydantic import BaseModel
from .models import (
EmailAccountCreate,
EmailAccountUpdate,
EmailAccount,
AccountTestResult,
AggregatedEmail,
EmailSearchParams,
TaskCreate,
TaskUpdate,
InboxTask,
TaskDashboardStats,
EmailComposeRequest,
EmailSendResult,
MailStats,
MailHealthCheck,
EmailAnalysisResult,
ResponseSuggestion,
TaskStatus,
TaskPriority,
EmailCategory,
)
from .mail_db import (
init_mail_tables,
create_email_account,
get_email_accounts,
get_email_account,
delete_email_account,
get_unified_inbox,
get_email,
mark_email_read,
mark_email_starred,
get_mail_stats,
log_mail_audit,
)
from .credentials import get_credentials_service
from .aggregator import get_mail_aggregator
from .ai_service import get_ai_email_service
from .task_service import get_task_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/mail", tags=["Mail"])
# =============================================================================
# Health & Init
# =============================================================================
@router.get("/health", response_model=MailHealthCheck)
async def health_check():
"""Health check for the mail system."""
# TODO: Implement full health check
return MailHealthCheck(
status="healthy",
database_connected=True,
vault_connected=True,
)
@router.post("/init")
async def initialize_mail_system():
"""Initialize mail database tables."""
success = await init_mail_tables()
if not success:
raise HTTPException(status_code=500, detail="Failed to initialize mail tables")
return {"status": "initialized"}
# =============================================================================
# Account Management
# =============================================================================
class AccountCreateRequest(BaseModel):
"""Request to create an email account."""
email: str
display_name: str
account_type: str = "personal"
imap_host: str
imap_port: int = 993
imap_ssl: bool = True
smtp_host: str
smtp_port: int = 465
smtp_ssl: bool = True
password: str
@router.post("/accounts", response_model=dict)
async def create_account(
request: AccountCreateRequest,
user_id: str = Query(..., description="User ID"),
tenant_id: str = Query(..., description="Tenant ID"),
):
"""Create a new email account."""
credentials_service = get_credentials_service()
# Store credentials securely
vault_path = await credentials_service.store_credentials(
account_id=f"{user_id}_{request.email}",
email=request.email,
password=request.password,
imap_host=request.imap_host,
imap_port=request.imap_port,
smtp_host=request.smtp_host,
smtp_port=request.smtp_port,
)
# Create account in database
account_id = await create_email_account(
user_id=user_id,
tenant_id=tenant_id,
email=request.email,
display_name=request.display_name,
account_type=request.account_type,
imap_host=request.imap_host,
imap_port=request.imap_port,
imap_ssl=request.imap_ssl,
smtp_host=request.smtp_host,
smtp_port=request.smtp_port,
smtp_ssl=request.smtp_ssl,
vault_path=vault_path,
)
if not account_id:
raise HTTPException(status_code=500, detail="Failed to create account")
# Log audit
await log_mail_audit(
user_id=user_id,
action="account_created",
entity_type="account",
entity_id=account_id,
details={"email": request.email},
tenant_id=tenant_id,
)
return {"id": account_id, "status": "created"}
@router.get("/accounts", response_model=List[dict])
async def list_accounts(
user_id: str = Query(..., description="User ID"),
tenant_id: Optional[str] = Query(None, description="Tenant ID"),
):
"""List all email accounts for a user."""
accounts = await get_email_accounts(user_id, tenant_id)
# Remove sensitive fields
for account in accounts:
account.pop("vault_path", None)
return accounts
@router.get("/accounts/{account_id}", response_model=dict)
async def get_account(
account_id: str,
user_id: str = Query(..., description="User ID"),
):
"""Get a single email account."""
account = await get_email_account(account_id, user_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
account.pop("vault_path", None)
return account
@router.delete("/accounts/{account_id}")
async def remove_account(
account_id: str,
user_id: str = Query(..., description="User ID"),
):
"""Delete an email account."""
account = await get_email_account(account_id, user_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
# Delete credentials
credentials_service = get_credentials_service()
vault_path = account.get("vault_path", "")
if vault_path:
await credentials_service.delete_credentials(account_id, vault_path)
# Delete from database (cascades to emails)
success = await delete_email_account(account_id, user_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to delete account")
await log_mail_audit(
user_id=user_id,
action="account_deleted",
entity_type="account",
entity_id=account_id,
)
return {"status": "deleted"}
@router.post("/accounts/{account_id}/test", response_model=AccountTestResult)
async def test_account_connection(
account_id: str,
user_id: str = Query(..., description="User ID"),
):
"""Test connection for an email account."""
account = await get_email_account(account_id, user_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
# Get credentials
credentials_service = get_credentials_service()
vault_path = account.get("vault_path", "")
creds = await credentials_service.get_credentials(account_id, vault_path)
if not creds:
return AccountTestResult(
success=False,
error_message="Credentials not found"
)
# Test connection
aggregator = get_mail_aggregator()
result = await aggregator.test_account_connection(
imap_host=account["imap_host"],
imap_port=account["imap_port"],
imap_ssl=account["imap_ssl"],
smtp_host=account["smtp_host"],
smtp_port=account["smtp_port"],
smtp_ssl=account["smtp_ssl"],
email_address=creds.email,
password=creds.password,
)
return result
class ConnectionTestRequest(BaseModel):
"""Request to test connection before saving account."""
email: str
imap_host: str
imap_port: int = 993
imap_ssl: bool = True
smtp_host: str
smtp_port: int = 465
smtp_ssl: bool = True
password: str
@router.post("/accounts/test-connection", response_model=AccountTestResult)
async def test_connection_before_save(request: ConnectionTestRequest):
"""
Test IMAP/SMTP connection before saving an account.
This allows the wizard to verify credentials are correct
before creating the account in the database.
"""
aggregator = get_mail_aggregator()
result = await aggregator.test_account_connection(
imap_host=request.imap_host,
imap_port=request.imap_port,
imap_ssl=request.imap_ssl,
smtp_host=request.smtp_host,
smtp_port=request.smtp_port,
smtp_ssl=request.smtp_ssl,
email_address=request.email,
password=request.password,
)
return result
@router.post("/accounts/{account_id}/sync")
async def sync_account(
account_id: str,
user_id: str = Query(..., description="User ID"),
max_emails: int = Query(100, ge=1, le=500),
background_tasks: BackgroundTasks = None,
):
"""Sync emails from an account."""
aggregator = get_mail_aggregator()
try:
new_count, total_count = await aggregator.sync_account(
account_id=account_id,
user_id=user_id,
max_emails=max_emails,
)
return {
"status": "synced",
"new_emails": new_count,
"total_emails": total_count,
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# =============================================================================
# Unified Inbox
# =============================================================================
@router.get("/inbox", response_model=List[dict])
async def get_inbox(
user_id: str = Query(..., description="User ID"),
account_ids: Optional[str] = Query(None, description="Comma-separated account IDs"),
categories: Optional[str] = Query(None, description="Comma-separated categories"),
is_read: Optional[bool] = Query(None),
is_starred: Optional[bool] = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
):
"""Get unified inbox with all accounts aggregated."""
# Parse comma-separated values
account_id_list = account_ids.split(",") if account_ids else None
category_list = categories.split(",") if categories else None
emails = await get_unified_inbox(
user_id=user_id,
account_ids=account_id_list,
categories=category_list,
is_read=is_read,
is_starred=is_starred,
limit=limit,
offset=offset,
)
return emails
@router.get("/inbox/{email_id}", response_model=dict)
async def get_email_detail(
email_id: str,
user_id: str = Query(..., description="User ID"),
):
"""Get a single email with full details."""
email_data = await get_email(email_id, user_id)
if not email_data:
raise HTTPException(status_code=404, detail="Email not found")
# Mark as read
await mark_email_read(email_id, user_id, is_read=True)
return email_data
@router.post("/inbox/{email_id}/read")
async def mark_read(
email_id: str,
user_id: str = Query(..., description="User ID"),
is_read: bool = Query(True),
):
"""Mark email as read/unread."""
success = await mark_email_read(email_id, user_id, is_read)
if not success:
raise HTTPException(status_code=500, detail="Failed to update email")
return {"status": "updated", "is_read": is_read}
@router.post("/inbox/{email_id}/star")
async def mark_starred(
email_id: str,
user_id: str = Query(..., description="User ID"),
is_starred: bool = Query(True),
):
"""Mark email as starred/unstarred."""
success = await mark_email_starred(email_id, user_id, is_starred)
if not success:
raise HTTPException(status_code=500, detail="Failed to update email")
return {"status": "updated", "is_starred": is_starred}
# =============================================================================
# Send Email
# =============================================================================
@router.post("/send", response_model=EmailSendResult)
async def send_email(
request: EmailComposeRequest,
user_id: str = Query(..., description="User ID"),
):
"""Send an email."""
aggregator = get_mail_aggregator()
result = await aggregator.send_email(
account_id=request.account_id,
user_id=user_id,
request=request,
)
if result.success:
await log_mail_audit(
user_id=user_id,
action="email_sent",
entity_type="email",
details={
"account_id": request.account_id,
"to": request.to,
"subject": request.subject,
},
)
return result
# =============================================================================
# AI Analysis
# =============================================================================
@router.post("/analyze/{email_id}", response_model=EmailAnalysisResult)
async def analyze_email(
email_id: str,
user_id: str = Query(..., description="User ID"),
):
"""Run AI analysis on an email."""
email_data = await get_email(email_id, user_id)
if not email_data:
raise HTTPException(status_code=404, detail="Email not found")
ai_service = get_ai_email_service()
result = await ai_service.analyze_email(
email_id=email_id,
sender_email=email_data.get("sender_email", ""),
sender_name=email_data.get("sender_name"),
subject=email_data.get("subject", ""),
body_text=email_data.get("body_text"),
body_preview=email_data.get("body_preview"),
)
return result
@router.get("/suggestions/{email_id}", response_model=List[ResponseSuggestion])
async def get_response_suggestions(
email_id: str,
user_id: str = Query(..., description="User ID"),
):
"""Get AI-generated response suggestions for an email."""
email_data = await get_email(email_id, user_id)
if not email_data:
raise HTTPException(status_code=404, detail="Email not found")
ai_service = get_ai_email_service()
# Use stored analysis if available
from .models import SenderType, EmailCategory as EC
sender_type = SenderType(email_data.get("sender_type", "unbekannt"))
category = EC(email_data.get("category", "sonstiges"))
suggestions = await ai_service.suggest_response(
subject=email_data.get("subject", ""),
body_text=email_data.get("body_text", ""),
sender_type=sender_type,
category=category,
)
return suggestions
# =============================================================================
# Tasks (Arbeitsvorrat)
# =============================================================================
@router.get("/tasks", response_model=List[dict])
async def list_tasks(
user_id: str = Query(..., description="User ID"),
status: Optional[str] = Query(None, description="Filter by status"),
priority: Optional[str] = Query(None, description="Filter by priority"),
include_completed: bool = Query(False),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
):
"""Get all tasks for a user."""
task_service = get_task_service()
status_enum = TaskStatus(status) if status else None
priority_enum = TaskPriority(priority) if priority else None
tasks = await task_service.get_user_tasks(
user_id=user_id,
status=status_enum,
priority=priority_enum,
include_completed=include_completed,
limit=limit,
offset=offset,
)
return tasks
@router.post("/tasks", response_model=dict)
async def create_task(
request: TaskCreate,
user_id: str = Query(..., description="User ID"),
tenant_id: str = Query(..., description="Tenant ID"),
):
"""Create a new task manually."""
task_service = get_task_service()
task_id = await task_service.create_manual_task(
user_id=user_id,
tenant_id=tenant_id,
task_data=request,
)
if not task_id:
raise HTTPException(status_code=500, detail="Failed to create task")
return {"id": task_id, "status": "created"}
@router.get("/tasks/dashboard", response_model=TaskDashboardStats)
async def get_task_dashboard(
user_id: str = Query(..., description="User ID"),
):
"""Get dashboard statistics for tasks."""
task_service = get_task_service()
return await task_service.get_dashboard_stats(user_id)
@router.get("/tasks/{task_id}", response_model=dict)
async def get_task(
task_id: str,
user_id: str = Query(..., description="User ID"),
):
"""Get a single task."""
task_service = get_task_service()
task = await task_service.get_task(task_id, user_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
@router.put("/tasks/{task_id}")
async def update_task(
task_id: str,
request: TaskUpdate,
user_id: str = Query(..., description="User ID"),
):
"""Update a task."""
task_service = get_task_service()
success = await task_service.update_task(task_id, user_id, request)
if not success:
raise HTTPException(status_code=500, detail="Failed to update task")
return {"status": "updated"}
@router.post("/tasks/{task_id}/complete")
async def complete_task(
task_id: str,
user_id: str = Query(..., description="User ID"),
):
"""Mark a task as completed."""
task_service = get_task_service()
success = await task_service.mark_completed(task_id, user_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to complete task")
return {"status": "completed"}
@router.post("/tasks/from-email/{email_id}")
async def create_task_from_email(
email_id: str,
user_id: str = Query(..., description="User ID"),
tenant_id: str = Query(..., description="Tenant ID"),
):
"""Create a task from an email (after analysis)."""
email_data = await get_email(email_id, user_id)
if not email_data:
raise HTTPException(status_code=404, detail="Email not found")
# Get deadlines from stored analysis
deadlines_raw = email_data.get("detected_deadlines", [])
from .models import DeadlineExtraction, SenderType
deadlines = []
for d in deadlines_raw:
try:
deadlines.append(DeadlineExtraction(
deadline_date=datetime.fromisoformat(d["date"]),
description=d.get("description", "Frist"),
confidence=0.8,
source_text="",
is_firm=d.get("is_firm", True),
))
except (KeyError, ValueError):
continue
sender_type = None
if email_data.get("sender_type"):
try:
sender_type = SenderType(email_data["sender_type"])
except ValueError:
pass
task_service = get_task_service()
task_id = await task_service.create_task_from_email(
user_id=user_id,
tenant_id=tenant_id,
email_id=email_id,
deadlines=deadlines,
sender_type=sender_type,
)
if not task_id:
raise HTTPException(status_code=500, detail="Failed to create task")
return {"id": task_id, "status": "created"}
# =============================================================================
# Statistics
# =============================================================================
@router.get("/stats", response_model=MailStats)
async def get_statistics(
user_id: str = Query(..., description="User ID"),
):
"""Get overall mail statistics for a user."""
stats = await get_mail_stats(user_id)
return MailStats(**stats)
# =============================================================================
# Sync All
# =============================================================================
@router.post("/sync-all")
async def sync_all_accounts(
user_id: str = Query(..., description="User ID"),
tenant_id: Optional[str] = Query(None),
):
"""Sync all email accounts for a user."""
aggregator = get_mail_aggregator()
results = await aggregator.sync_all_accounts(user_id, tenant_id)
return {"status": "synced", "results": results}