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
+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