feat: mail attachment support — sync, display, download, upload
This commit is contained in:
@@ -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