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(
+120 -12
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import base64
import json
import mimetypes
import os
import re
import uuid
@@ -12,6 +13,7 @@ from email import message_from_bytes
from email.message import EmailMessage
from email.utils import formataddr, formatdate, make_msgid
import aiofiles
import aioimaplib
import aiosmtplib
import nh3
@@ -22,6 +24,7 @@ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from sqlalchemy import and_, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.plugins.builtins.mail.models import (
Mail,
MailAccount,
@@ -35,6 +38,64 @@ from app.plugins.builtins.mail.models import (
VacationSentLog,
)
# ─── Attachment Storage Helpers ───
MAX_ATTACHMENT_SIZE = 25 * 1024 * 1024 # 25 MB
def _sanitize_filename(filename: str) -> str:
"""Sanitize a filename to prevent path traversal attacks."""
# Remove any path components — keep only the basename
filename = os.path.basename(filename or "attachment")
# Replace potentially dangerous characters
filename = re.sub(r"[^a-zA-Z0-9._-]", "_", filename)
# Ensure non-empty
if not filename:
filename = "attachment"
# Limit length
if len(filename) > 200:
name, ext = os.path.splitext(filename)
filename = name[:200 - len(ext)] + ext
return filename
def _attachment_storage_path(mail_id: uuid.UUID, filename: str) -> str:
"""Build the on-disk storage path for a mail attachment."""
safe_name = _sanitize_filename(filename)
return os.path.join(
settings.storage_path,
"mail_attachments",
str(mail_id),
safe_name,
)
async def _save_attachment_to_storage(
mail_id: uuid.UUID, filename: str, content: bytes
) -> str:
"""Save attachment content to disk and return the storage path."""
storage_path = _attachment_storage_path(mail_id, filename)
os.makedirs(os.path.dirname(storage_path), exist_ok=True)
async with aiofiles.open(storage_path, "wb") as f:
await f.write(content)
return storage_path
def attachment_to_response(att: MailAttachment) -> dict:
"""Convert a MailAttachment ORM object to a response dict."""
return {
"id": str(att.id),
"mail_id": str(att.mail_id),
"filename": att.filename,
"mime_type": att.mime_type,
"size_bytes": att.size_bytes,
"size": att.size_bytes, # alias for frontend compatibility
"content_id": att.content_id,
"is_inline": bool(att.content_id),
"dms_file_id": str(att.dms_file_id) if att.dms_file_id else None,
}
# ─── AES-256 Encryption (Fernet) ───
MAIL_ENCRYPTION_KEY = os.environ.get("MAIL_ENCRYPTION_KEY", "leocrm-mail-encryption-key-2024")
@@ -520,11 +581,17 @@ async def imap_sync_account(
if payload:
body_html = payload.decode("utf-8", errors="replace")
elif part.get_filename():
payload_bytes = part.get_payload(decode=True) or b""
content_id = part.get("Content-ID", None)
if content_id:
content_id = content_id.strip("<>")
attachments.append(
{
"filename": part.get_filename(),
"mime_type": ct,
"size": len(part.get_payload(decode=True) or b""),
"size": len(payload_bytes),
"content": payload_bytes,
"content_id": content_id,
}
)
else:
@@ -595,8 +662,30 @@ async def imap_sync_account(
received_at=received_at,
)
db.add(mail)
await db.flush()
synced_count += 1
# Save attachments to storage and DB
for att_data in attachments:
try:
storage_path = await _save_attachment_to_storage(
mail.id, att_data["filename"], att_data["content"]
)
attachment = MailAttachment(
tenant_id=tenant_id,
mail_id=mail.id,
filename=_sanitize_filename(att_data["filename"]),
mime_type=att_data["mime_type"],
size_bytes=att_data["size"],
storage_path=storage_path,
content_id=att_data.get("content_id"),
)
db.add(attachment)
except Exception:
# Skip attachments that fail to save
continue
await db.flush()
# Update folder counts
total = (
await db.execute(
@@ -662,10 +751,17 @@ async def send_mail_via_smtp(
in_reply_to: str | None = None,
references_header: str | None = None,
signature: MailSignature | None = None,
attachment_paths: list[dict] | None = None,
) -> dict:
"""Send an email via SMTP using aiosmtplib."""
"""Send an email via SMTP using aiosmtplib.
Args:
attachment_paths: list of dicts with keys 'path', 'filename', 'mime_type'
pointing to files on disk to attach.
"""
cc_addrs = cc_addrs or []
bcc_addrs = bcc_addrs or []
attachment_paths = attachment_paths or []
# Apply signature if provided
if signature and signature.body_html:
@@ -696,6 +792,27 @@ async def send_mail_via_smtp(
else:
msg.set_content(body_text, subtype="plain")
# Add attachments to the message
for att_info in attachment_paths:
file_path = att_info.get("path", "")
filename = att_info.get("filename", os.path.basename(file_path))
mime_type = att_info.get("mime_type", "application/octet-stream")
if not file_path or not os.path.exists(file_path):
continue
with open(file_path, "rb") as f: # noqa: ASYNC230
content = f.read()
# Determine maintype/subtype from mime_type
if "/" in mime_type:
maintype, subtype = mime_type.split("/", 1)
else:
maintype, subtype = "application", "octet-stream"
msg.add_attachment(
content,
maintype=maintype,
subtype=subtype,
filename=filename,
)
# Send via SMTP
password = await get_account_password(account)
try:
@@ -1115,16 +1232,7 @@ def mail_to_response(
"labels": [],
}
if attachments:
resp["attachments"] = [
{
"id": str(a.id),
"filename": a.filename,
"mime_type": a.mime_type,
"size_bytes": a.size_bytes,
"dms_file_id": str(a.dms_file_id) if a.dms_file_id else None,
}
for a in attachments
]
resp["attachments"] = [attachment_to_response(a) for a in attachments]
if labels:
resp["labels"] = [
{"id": str(lbl.id), "name": lbl.name, "color": lbl.color} for lbl in labels