From 2108bdb9c2de176f3b3bf884acf5b573171aac49 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Wed, 15 Jul 2026 18:43:38 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20mail=20attachment=20support=20=E2=80=94?= =?UTF-8?q?=20sync,=20display,=20download,=20upload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/plugins/builtins/mail/routes.py | 93 ++++++++++- app/plugins/builtins/mail/services.py | 132 ++++++++++++++-- frontend/src/api/mail.ts | 18 +++ frontend/src/components/mail/ComposeModal.tsx | 99 +++++++++++- frontend/src/components/mail/MailDetail.tsx | 2 +- frontend/src/i18n/locales/de.json | 2 + frontend/src/i18n/locales/en.json | 2 + test_report.md | 145 ++++++------------ 8 files changed, 379 insertions(+), 114 deletions(-) diff --git a/app/plugins/builtins/mail/routes.py b/app/plugins/builtins/mail/routes.py index d6e55b2..f16a93a 100644 --- a/app/plugins/builtins/mail/routes.py +++ b/app/plugins/builtins/mail/routes.py @@ -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 + ``/mail_uploads//``. 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( diff --git a/app/plugins/builtins/mail/services.py b/app/plugins/builtins/mail/services.py index c80fd4a..a2b6318 100644 --- a/app/plugins/builtins/mail/services.py +++ b/app/plugins/builtins/mail/services.py @@ -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 diff --git a/frontend/src/api/mail.ts b/frontend/src/api/mail.ts index 252a4b1..cdb4dd8 100644 --- a/frontend/src/api/mail.ts +++ b/frontend/src/api/mail.ts @@ -39,9 +39,11 @@ export interface MailAttachment { mail_id: string; filename: string; mime_type: string; + size_bytes: number; size: number; content_id?: string | null; is_inline: boolean; + dms_file_id?: string | null; } export interface Mail { @@ -461,6 +463,22 @@ export function downloadAttachment(mailId: string, attachmentId: string): Promis }).then((res) => res.data); } +export interface UploadedAttachment { + id: string; + filename: string; + mime_type: string; + size_bytes: number; + path: string; +} + +export function uploadAttachment(file: File): Promise { + const formData = new FormData(); + formData.append('file', file); + return apiClient.post('/mail/upload-attachment', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }).then((res) => res.data); +} + // ─── Templates ────────────────────────────────────────────────────────────── export function createTemplate(payload: CreateTemplatePayload): Promise { diff --git a/frontend/src/components/mail/ComposeModal.tsx b/frontend/src/components/mail/ComposeModal.tsx index 06204c0..43a6a1d 100644 --- a/frontend/src/components/mail/ComposeModal.tsx +++ b/frontend/src/components/mail/ComposeModal.tsx @@ -11,6 +11,7 @@ import { Input } from '@/components/ui/Input'; import { Select } from '@/components/ui/Select'; import { TemplatePicker } from './TemplatePicker'; import type { Mail, MailSignature, SendMailPayload, ReplyPayload, ForwardPayload } from '@/api/mail'; +import { uploadAttachment, type UploadedAttachment } from '@/api/mail'; export type ComposeMode = 'new' | 'reply' | 'forward'; @@ -46,6 +47,9 @@ export function ComposeModal({ const [sending, setSending] = useState(false); const [selectedSignatureId, setSelectedSignatureId] = useState(''); const [showTemplatePicker, setShowTemplatePicker] = useState(false); + const [attachments, setAttachments] = useState([]); + const [uploadingFile, setUploadingFile] = useState(false); + const fileInputRef = useRef(null); useEffect(() => { if (!open) return; @@ -63,6 +67,7 @@ export function ComposeModal({ setBcc(''); setSubject(''); setBody(''); + setAttachments([]); } }, [open, mode, replyToMail, forwardMail, t]); @@ -102,6 +107,41 @@ export function ComposeModal({ setShowTemplatePicker(false); }, []); + const handleFileSelect = useCallback(async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0) return; + setUploadingFile(true); + try { + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (file.size > 25 * 1024 * 1024) { + alert(`${file.name} exceeds 25 MB limit`); + continue; + } + const uploaded = await uploadAttachment(file); + setAttachments((prev) => [...prev, uploaded]); + } + } catch (err) { + console.error('Attachment upload failed:', err); + alert('Failed to upload attachment'); + } finally { + setUploadingFile(false); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }, []); + + const handleRemoveAttachment = useCallback((attId: string) => { + setAttachments((prev) => prev.filter((a) => a.id !== attId)); + }, []); + + const formatBytes = (bytes: number): string => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + }; + const handleSend = useCallback(async () => { if (!to.trim()) return; setSending(true); @@ -139,14 +179,16 @@ export function ComposeModal({ body, is_html: true, signature_id: selectedSignatureId || null, + attachments: attachments.map((a) => a.id), }; await onSend(sendPayload, 'new'); } + setAttachments([]); onClose(); } finally { setSending(false); } - }, [to, cc, bcc, subject, body, mode, replyToMail, forwardMail, accountId, selectedSignatureId, onSend, onClose]); + }, [to, cc, bcc, subject, body, mode, replyToMail, forwardMail, accountId, selectedSignatureId, attachments, onSend, onClose]); const title = mode === 'reply' ? t('mail.reply') : mode === 'forward' ? t('mail.forward') : t('mail.compose'); @@ -266,6 +308,61 @@ export function ComposeModal({ /> + {/* Attachments */} +
+ +
+ + + Max 25 MB per file +
+ {attachments.length > 0 && ( +
    + {attachments.map((att) => ( +
  • + +
    +

    {att.filename}

    +

    {formatBytes(att.size_bytes)}

    +
    + +
  • + ))} +
+ )} +
+ {/* Signature */}