feat: mail attachment support — sync, display, download, upload

This commit is contained in:
Agent Zero
2026-07-15 18:43:38 +02:00
parent de74429f4e
commit 2108bdb9c2
8 changed files with 379 additions and 114 deletions
+92 -1
View File
@@ -12,7 +12,7 @@ import uuid
from datetime import UTC, datetime
from pathlib import Path
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import StreamingResponse
from sqlalchemy import and_, desc, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -58,8 +58,13 @@ from app.plugins.builtins.mail.schemas import (
VacationConfig,
)
from app.plugins.builtins.mail.services import (
MAX_ATTACHMENT_SIZE,
_attachment_storage_path,
_sanitize_filename,
_save_attachment_to_storage,
account_to_response,
apply_rules_to_mail,
attachment_to_response,
create_mail_account,
encrypt_password,
folder_to_response,
@@ -94,6 +99,35 @@ def _parse_uuid(val: str, field: str = "id") -> uuid.UUID:
) from None
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")
for att_id in attachment_ids:
upload_dir = os.path.join(base_dir, "mail_uploads", att_id)
if not os.path.isdir(upload_dir):
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
return resolved
async def _get_account(
db: AsyncSession, account_id: uuid.UUID, tenant_id: uuid.UUID, user_id: uuid.UUID
) -> MailAccount:
@@ -521,6 +555,62 @@ async def delete_folder(
await db.delete(folder)
# ─── Attachment Upload (F-MAIL-04) ───
@router.post("/upload-attachment")
async def upload_attachment(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Upload a file to be attached to an outgoing email.
Returns a temporary attachment ID that can be passed in the
`attachments` list of the send-mail request.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
# Read file content and check size
content = await file.read()
if len(content) > MAX_ATTACHMENT_SIZE:
raise HTTPException(
413,
detail={
"detail": f"File exceeds maximum size of {MAX_ATTACHMENT_SIZE // (1024 * 1024)} MB",
"code": "file_too_large",
},
)
# Generate a temporary attachment ID and store the file
temp_id = str(uuid.uuid4())
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)
return {
"id": temp_id,
"filename": safe_filename,
"mime_type": mime_type,
"size_bytes": len(content),
"path": file_path,
}
# ─── Send (F-MAIL-02) ───
@@ -574,6 +664,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),
)
if result.get("status") == "error":
raise HTTPException(