Phase 0 Complete: Tasks 0.7-0.20

- 0.7: UI-Design-Richtlinien (docs/ui-design-guidelines.md, 535 lines)
- 0.8: Theme-Customization Backend (4 theme fields, migration 0023)
- 0.9: Theme-Customization Frontend (SettingsTheme.tsx, themeStore.ts, live preview)
- 0.10: RBAC-Audit (4 plugins secured, 53 routes with require_permission)
- 0.11: LiteLLM-Cleanup (llm_client.py migrated from httpx to litellm)
- 0.12: KI-Agent-Framework docs (plugin-development-guide.md, agent_capabilities field)
- 0.13: Heartbeat configurable (ProactiveSettings, migration 0024, frontend UI)
- 0.14: Unified Search Field-Level RBAC (resolve_permissions + filter_fields_by_permission)
- 0.15: Undo/History-System (EntityHistory model, service, routes, migration 0025, HistoryViewer)
- 0.16: Storage Backend (LocalStorage + S3Storage, DMS/attachments/mail updated)
- 0.17: Import/Export unified Contact fields (firstname, surname, email_1, phone_1)
- 0.18: .gitignore & Config-Cleanup (webui→frontend, python-jose removed, .env untracked)
- 0.19: Mail-Salt Security-Fix (per-account random salt, migration 0026)
- 0.20: AGPL replaced (PyMuPDF→pypdf, OnlyOffice→Collabora, LICENSE + THIRD_PARTY_LICENSES.md)
This commit is contained in:
Agent Zero
2026-07-23 08:42:26 +02:00
parent 3d06cb2353
commit ec81940178
65 changed files with 3061 additions and 277 deletions
+28 -40
View File
@@ -8,9 +8,9 @@ so that GET /search, /threads, /templates etc. are not shadowed by GET /{mail_id
from __future__ import annotations
import json
import os
import uuid
from datetime import UTC, datetime
from pathlib import Path
from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import StreamingResponse
@@ -18,6 +18,7 @@ from sqlalchemy import and_, asc, desc, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.storage import get_storage_backend
from app.deps import require_permission
from app.plugins.builtins.mail.models import (
ContactPgpKey,
@@ -109,32 +110,31 @@ def _parse_uuid(val: str, field: str = "id") -> uuid.UUID:
) from None
def _resolve_attachment_paths(attachment_ids: list[str]) -> list[dict]:
async def _resolve_attachment_paths(attachment_ids: list[str]) -> list[dict]:
"""Resolve temporary attachment upload IDs to file paths on disk.
Each uploaded attachment is stored under
``<storage>/mail_uploads/<temp_id>/<filename>``. We scan the
directory for the single file inside and return its metadata.
"""
import os
resolved: list[dict] = []
base_dir = os.environ.get("STORAGE_PATH", "/tmp")
storage = get_storage_backend()
for att_id in attachment_ids:
upload_dir = os.path.join(base_dir, "mail_uploads", att_id)
if not os.path.isdir(upload_dir):
prefix = f"mail_uploads/{att_id}/"
files = await storage.list_files(prefix)
if not files:
continue
for fname in os.listdir(upload_dir):
fpath = os.path.join(upload_dir, fname)
if os.path.isfile(fpath):
resolved.append(
{
"path": fpath,
"filename": fname,
"mime_type": "application/octet-stream",
}
)
break
for fpath in files:
fname = os.path.basename(fpath)
abs_path = await storage.get_url(fpath)
resolved.append(
{
"path": abs_path,
"filename": fname,
"mime_type": "application/octet-stream",
}
)
break
return resolved
@@ -752,19 +752,10 @@ async def upload_attachment(
safe_filename = _sanitize_filename(file.filename or "attachment")
mime_type = file.content_type or "application/octet-stream"
# Store in a temp directory keyed by the temp_id
import os
temp_dir = os.path.join(
os.environ.get("STORAGE_PATH", "/tmp"), "mail_uploads", temp_id
)
os.makedirs(temp_dir, exist_ok=True)
file_path = os.path.join(temp_dir, safe_filename)
import aiofiles
async with aiofiles.open(file_path, "wb") as f:
await f.write(content)
# Store via storage backend in a path keyed by the temp_id
storage = get_storage_backend()
file_path = f"mail_uploads/{temp_id}/{safe_filename}"
await storage.save(file_path, content)
return {
"id": temp_id,
@@ -828,7 +819,7 @@ async def send_mail(
in_reply_to=data.in_reply_to,
references_header=data.references_header,
signature=signature,
attachment_paths=_resolve_attachment_paths(data.attachments),
attachment_paths=await _resolve_attachment_paths(data.attachments),
)
if result.get("status") == "error":
raise HTTPException(
@@ -1423,19 +1414,16 @@ async def download_attachment(
).scalar_one_or_none()
if not attachment:
raise HTTPException(404, detail={"detail": "Attachment not found", "code": "not_found"})
if not Path(attachment.storage_path).exists():
storage = get_storage_backend()
if not await storage.exists(attachment.storage_path):
raise HTTPException(
404, detail={"detail": "File not found on disk", "code": "file_missing"}
)
import aiofiles
content = await storage.read(attachment.storage_path)
async def file_stream():
async with aiofiles.open(attachment.storage_path, "rb") as f:
while True:
chunk = await f.read(8192)
if not chunk:
break
yield chunk
yield content
return StreamingResponse(
file_stream(),