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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user