feat: mail attachment support — sync, display, download, upload
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user