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 datetime import UTC, datetime
from pathlib import Path 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 fastapi.responses import StreamingResponse
from sqlalchemy import and_, desc, func, or_, select from sqlalchemy import and_, desc, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -58,8 +58,13 @@ from app.plugins.builtins.mail.schemas import (
VacationConfig, VacationConfig,
) )
from app.plugins.builtins.mail.services import ( from app.plugins.builtins.mail.services import (
MAX_ATTACHMENT_SIZE,
_attachment_storage_path,
_sanitize_filename,
_save_attachment_to_storage,
account_to_response, account_to_response,
apply_rules_to_mail, apply_rules_to_mail,
attachment_to_response,
create_mail_account, create_mail_account,
encrypt_password, encrypt_password,
folder_to_response, folder_to_response,
@@ -94,6 +99,35 @@ def _parse_uuid(val: str, field: str = "id") -> uuid.UUID:
) from None ) 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( async def _get_account(
db: AsyncSession, account_id: uuid.UUID, tenant_id: uuid.UUID, user_id: uuid.UUID db: AsyncSession, account_id: uuid.UUID, tenant_id: uuid.UUID, user_id: uuid.UUID
) -> MailAccount: ) -> MailAccount:
@@ -521,6 +555,62 @@ async def delete_folder(
await db.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) ─── # ─── Send (F-MAIL-02) ───
@@ -574,6 +664,7 @@ async def send_mail(
in_reply_to=data.in_reply_to, in_reply_to=data.in_reply_to,
references_header=data.references_header, references_header=data.references_header,
signature=signature, signature=signature,
attachment_paths=_resolve_attachment_paths(data.attachments),
) )
if result.get("status") == "error": if result.get("status") == "error":
raise HTTPException( raise HTTPException(
+120 -12
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import base64 import base64
import json import json
import mimetypes
import os import os
import re import re
import uuid import uuid
@@ -12,6 +13,7 @@ from email import message_from_bytes
from email.message import EmailMessage from email.message import EmailMessage
from email.utils import formataddr, formatdate, make_msgid from email.utils import formataddr, formatdate, make_msgid
import aiofiles
import aioimaplib import aioimaplib
import aiosmtplib import aiosmtplib
import nh3 import nh3
@@ -22,6 +24,7 @@ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from sqlalchemy import and_, or_, select, text from sqlalchemy import and_, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.plugins.builtins.mail.models import ( from app.plugins.builtins.mail.models import (
Mail, Mail,
MailAccount, MailAccount,
@@ -35,6 +38,64 @@ from app.plugins.builtins.mail.models import (
VacationSentLog, 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) ─── # ─── AES-256 Encryption (Fernet) ───
MAIL_ENCRYPTION_KEY = os.environ.get("MAIL_ENCRYPTION_KEY", "leocrm-mail-encryption-key-2024") 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: if payload:
body_html = payload.decode("utf-8", errors="replace") body_html = payload.decode("utf-8", errors="replace")
elif part.get_filename(): 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( attachments.append(
{ {
"filename": part.get_filename(), "filename": part.get_filename(),
"mime_type": ct, "mime_type": ct,
"size": len(part.get_payload(decode=True) or b""), "size": len(payload_bytes),
"content": payload_bytes,
"content_id": content_id,
} }
) )
else: else:
@@ -595,8 +662,30 @@ async def imap_sync_account(
received_at=received_at, received_at=received_at,
) )
db.add(mail) db.add(mail)
await db.flush()
synced_count += 1 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 # Update folder counts
total = ( total = (
await db.execute( await db.execute(
@@ -662,10 +751,17 @@ async def send_mail_via_smtp(
in_reply_to: str | None = None, in_reply_to: str | None = None,
references_header: str | None = None, references_header: str | None = None,
signature: MailSignature | None = None, signature: MailSignature | None = None,
attachment_paths: list[dict] | None = None,
) -> dict: ) -> 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 [] cc_addrs = cc_addrs or []
bcc_addrs = bcc_addrs or [] bcc_addrs = bcc_addrs or []
attachment_paths = attachment_paths or []
# Apply signature if provided # Apply signature if provided
if signature and signature.body_html: if signature and signature.body_html:
@@ -696,6 +792,27 @@ async def send_mail_via_smtp(
else: else:
msg.set_content(body_text, subtype="plain") 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 # Send via SMTP
password = await get_account_password(account) password = await get_account_password(account)
try: try:
@@ -1115,16 +1232,7 @@ def mail_to_response(
"labels": [], "labels": [],
} }
if attachments: if attachments:
resp["attachments"] = [ resp["attachments"] = [attachment_to_response(a) for a in 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
]
if labels: if labels:
resp["labels"] = [ resp["labels"] = [
{"id": str(lbl.id), "name": lbl.name, "color": lbl.color} for lbl in labels {"id": str(lbl.id), "name": lbl.name, "color": lbl.color} for lbl in labels
+18
View File
@@ -39,9 +39,11 @@ export interface MailAttachment {
mail_id: string; mail_id: string;
filename: string; filename: string;
mime_type: string; mime_type: string;
size_bytes: number;
size: number; size: number;
content_id?: string | null; content_id?: string | null;
is_inline: boolean; is_inline: boolean;
dms_file_id?: string | null;
} }
export interface Mail { export interface Mail {
@@ -461,6 +463,22 @@ export function downloadAttachment(mailId: string, attachmentId: string): Promis
}).then((res) => res.data); }).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<UploadedAttachment> {
const formData = new FormData();
formData.append('file', file);
return apiClient.post<UploadedAttachment>('/mail/upload-attachment', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}).then((res) => res.data);
}
// ─── Templates ────────────────────────────────────────────────────────────── // ─── Templates ──────────────────────────────────────────────────────────────
export function createTemplate(payload: CreateTemplatePayload): Promise<MailTemplate> { export function createTemplate(payload: CreateTemplatePayload): Promise<MailTemplate> {
+98 -1
View File
@@ -11,6 +11,7 @@ import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select'; import { Select } from '@/components/ui/Select';
import { TemplatePicker } from './TemplatePicker'; import { TemplatePicker } from './TemplatePicker';
import type { Mail, MailSignature, SendMailPayload, ReplyPayload, ForwardPayload } from '@/api/mail'; import type { Mail, MailSignature, SendMailPayload, ReplyPayload, ForwardPayload } from '@/api/mail';
import { uploadAttachment, type UploadedAttachment } from '@/api/mail';
export type ComposeMode = 'new' | 'reply' | 'forward'; export type ComposeMode = 'new' | 'reply' | 'forward';
@@ -46,6 +47,9 @@ export function ComposeModal({
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [selectedSignatureId, setSelectedSignatureId] = useState(''); const [selectedSignatureId, setSelectedSignatureId] = useState('');
const [showTemplatePicker, setShowTemplatePicker] = useState(false); const [showTemplatePicker, setShowTemplatePicker] = useState(false);
const [attachments, setAttachments] = useState<UploadedAttachment[]>([]);
const [uploadingFile, setUploadingFile] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -63,6 +67,7 @@ export function ComposeModal({
setBcc(''); setBcc('');
setSubject(''); setSubject('');
setBody(''); setBody('');
setAttachments([]);
} }
}, [open, mode, replyToMail, forwardMail, t]); }, [open, mode, replyToMail, forwardMail, t]);
@@ -102,6 +107,41 @@ export function ComposeModal({
setShowTemplatePicker(false); setShowTemplatePicker(false);
}, []); }, []);
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
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 () => { const handleSend = useCallback(async () => {
if (!to.trim()) return; if (!to.trim()) return;
setSending(true); setSending(true);
@@ -139,14 +179,16 @@ export function ComposeModal({
body, body,
is_html: true, is_html: true,
signature_id: selectedSignatureId || null, signature_id: selectedSignatureId || null,
attachments: attachments.map((a) => a.id),
}; };
await onSend(sendPayload, 'new'); await onSend(sendPayload, 'new');
} }
setAttachments([]);
onClose(); onClose();
} finally { } finally {
setSending(false); 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'); const title = mode === 'reply' ? t('mail.reply') : mode === 'forward' ? t('mail.forward') : t('mail.compose');
@@ -266,6 +308,61 @@ export function ComposeModal({
/> />
</div> </div>
{/* Attachments */}
<div data-testid="compose-attachments">
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('mail.attachments')}</label>
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileSelect}
className="hidden"
data-testid="compose-file-input"
/>
<Button
variant="secondary"
size="sm"
type="button"
onClick={() => fileInputRef.current?.click()}
isLoading={uploadingFile}
icon={
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
}
>
{t('mail.addAttachment')}
</Button>
<span className="text-xs text-secondary-400">Max 25 MB per file</span>
</div>
{attachments.length > 0 && (
<ul className="mt-2 space-y-1">
{attachments.map((att) => (
<li key={att.id} className="flex items-center gap-3 p-2 rounded-md bg-secondary-50">
<svg className="w-5 h-5 text-secondary-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm text-secondary-800 truncate">{att.filename}</p>
<p className="text-xs text-secondary-400">{formatBytes(att.size_bytes)}</p>
</div>
<button
type="button"
onClick={() => handleRemoveAttachment(att.id)}
className="p-1 rounded hover:bg-secondary-200 text-secondary-500"
aria-label={t('common.remove')}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</li>
))}
</ul>
)}
</div>
{/* Signature */} {/* Signature */}
<Select <Select
label={t('mail.signature')} label={t('mail.signature')}
+1 -1
View File
@@ -200,7 +200,7 @@ export function MailDetail({
</svg> </svg>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm text-secondary-800 truncate">{att.filename}</p> <p className="text-sm text-secondary-800 truncate">{att.filename}</p>
<p className="text-xs text-secondary-400">{formatBytes(att.size)}</p> <p className="text-xs text-secondary-400">{formatBytes(att.size_bytes)}</p>
</div> </div>
<Button <Button
variant="secondary" variant="secondary"
+2
View File
@@ -41,6 +41,7 @@
"common": { "common": {
"save": "Speichern", "save": "Speichern",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"remove": "Entfernen",
"delete": "Löschen", "delete": "Löschen",
"edit": "Bearbeiten", "edit": "Bearbeiten",
"create": "Erstellen", "create": "Erstellen",
@@ -427,6 +428,7 @@
"subjectPlaceholder": "Betrereff eingeben...", "subjectPlaceholder": "Betrereff eingeben...",
"body": "Text", "body": "Text",
"attachments": "Anhänge", "attachments": "Anhänge",
"addAttachment": "Anhang hinzufügen",
"download": "Herunterladen", "download": "Herunterladen",
"compose": "Verfassen", "compose": "Verfassen",
"send": "Senden", "send": "Senden",
+2
View File
@@ -41,6 +41,7 @@
"common": { "common": {
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
"remove": "Remove",
"delete": "Delete", "delete": "Delete",
"edit": "Edit", "edit": "Edit",
"create": "Create", "create": "Create",
@@ -427,6 +428,7 @@
"subjectPlaceholder": "Enter subject...", "subjectPlaceholder": "Enter subject...",
"body": "Body", "body": "Body",
"attachments": "Attachments", "attachments": "Attachments",
"addAttachment": "Add Attachment",
"download": "Download", "download": "Download",
"compose": "Compose", "compose": "Compose",
"send": "Send", "send": "Send",
+46 -99
View File
@@ -1,112 +1,59 @@
# Test Report — Report Generator Core Plugin # Test Report — Mail Attachment Support
## Date: 2026-07-08 **Date:** 2026-07-15
**Task:** Mail attachment support — sync, display, download, upload
## Task: Build Report Generator Core Plugin for LeoCRM ## Changes Summary
## Files Created ### Backend
1. `app/plugins/builtins/report_generator/__init__.py` — Plugin package init (5 lines) - **services.py**: Added `_sanitize_filename`, `_attachment_storage_path`, `_save_attachment_to_storage`, `attachment_to_response` helpers. Modified IMAP sync to save attachment content to disk + create `MailAttachment` DB records. Modified `send_mail_via_smtp` to accept `attachment_paths` and attach files to `EmailMessage`. Updated `mail_to_response` to use `attachment_to_response`.
2. `app/plugins/builtins/report_generator/plugin.py` — Plugin manifest + class (29 lines) - **routes.py**: Added `POST /api/v1/mail/upload-attachment` endpoint (multipart/form-data). Added `_resolve_attachment_paths` helper. Modified `send_mail` route to resolve attachment IDs and pass to `send_mail_via_smtp`.
3. `app/plugins/builtins/report_generator/models.py` — ReportTemplate + ReportInstance models (63 lines)
4. `app/plugins/builtins/report_generator/schemas.py` — Pydantic schemas (52 lines)
5. `app/plugins/builtins/report_generator/routes.py` — 8 API endpoints (415 lines)
6. `app/plugins/builtins/report_generator/migrations/0001_initial.sql` — PostgreSQL migration (32 lines)
7. `app/plugins/builtins/__init__.py` — Updated to include ReportGeneratorPlugin import
## Tests Run ### Frontend
- **api/mail.ts**: Updated `MailAttachment` interface (added `size_bytes`, `dms_file_id`). Added `uploadAttachment` function and `UploadedAttachment` interface.
- **MailDetail.tsx**: Fixed `att.size``att.size_bytes` for formatBytes call.
- **ComposeModal.tsx**: Added file input (multiple), attachment upload via `uploadAttachment`, attachment list with remove buttons, attachment IDs passed in send payload.
- **i18n locales**: Added `mail.addAttachment` and `common.remove` keys to en.json and de.json.
### 1. AST Syntax Check ## Test Results
**Command:** `python -c 'import ast; ast.parse(...)'`
**Result:** All 6 Python files passed AST syntax check. ### Python Syntax Check
``` ```
OK: app/plugins/builtins/report_generator/__init__.py /opt/venv/bin/python -c "import py_compile; py_compile.compile('app/plugins/builtins/mail/services.py', doraise=True); py_compile.compile('app/plugins/builtins/mail/routes.py', doraise=True)"
OK: app/plugins/builtins/report_generator/plugin.py → Python syntax OK
OK: app/plugins/builtins/report_generator/models.py
OK: app/plugins/builtins/report_generator/schemas.py
OK: app/plugins/builtins/report_generator/routes.py
OK: app/plugins/builtins/__init__.py
``` ```
### 2. Import Verification ### Frontend TypeScript Check
**Command:** `python -c 'from app.plugins.builtins.report_generator.plugin import ReportGeneratorPlugin; ...'`
**Result:** All imports resolve correctly.
``` ```
Base + TenantMixin OK npx tsc --noEmit 2>&1 | grep -E 'mail|Mail|Compose|MailDetail'
Schemas OK → No errors in mail files
TemplateCreate validation OK: {'name': 'test', 'description': '', 'template_type': 'jinja2', 'content': '{{ data }}', 'output_format': 'csv'} ```
Models OK Pre-existing errors in SettingsPlugins.tsx and SettingsRoles.tsx (unrelated to this task).
ReportTemplate table: report_templates
ReportInstance table: report_instances ### Frontend Tests (vitest)
| Test File | Result |
|-----------|--------|
| ComposeModal.test.tsx | ✅ 10/10 passed |
| MailSettings.test.tsx | ✅ 10/10 passed |
| MailPage.test.tsx | ⚠️ 6 passed, 8 failed (PRE-EXISTING — confirmed via git stash) |
### Backend Tests (pytest)
```
pytest tests/test_mail.py
→ ImportError: No module named 'redis' (pre-existing environment issue)
``` ```
### 3. Jinja2 Template Rendering ## Smoke Test Description
**Result:** Template rendered successfully.
```
Jinja2 render OK: 'Name,Value\nAlice,100\nBob,200'
```
### 4. CSV Generation (Python stdlib csv) 1. **IMAP Sync**: Attachment content is now captured during IMAP sync (`part.get_payload(decode=True)`), saved to `{storage_path}/mail_attachments/{mail_id}/{filename}`, and `MailAttachment` records are created in the DB.
**Result:** CSV file generated with UTF-8 BOM. 2. **Download**: Existing `GET /{mail_id}/attachments/{att_id}` route streams the file from disk with correct Content-Type and Content-Disposition headers.
``` 3. **Upload**: New `POST /upload-attachment` endpoint accepts multipart/form-data, validates 25MB max size, saves to temp directory, returns attachment ID.
CSV generation OK, size: 44 bytes 4. **Send with attachments**: `send_mail` route resolves attachment IDs to file paths, passes them to `send_mail_via_smtp` which adds them to the `EmailMessage` via `msg.add_attachment()`.
``` 5. **Frontend display**: MailDetail shows attachments with icon, filename, size (using `size_bytes`), and download button.
6. **Frontend compose**: ComposeModal has file input button, uploads files via `uploadAttachment`, shows list with remove option, passes attachment IDs in send payload.
### 5. Excel Generation (openpyxl) ## Security
**Result:** Excel workbook created. - Filenames sanitized via `_sanitize_filename` (removes path components, replaces dangerous characters)
``` - Max 25MB per attachment enforced in both upload endpoint and frontend
Excel generation OK, size: 4874 bytes - No path traversal possible (basename extraction + character replacement)
```
### 6. JSON Generation
**Result:** JSON parsed and returned.
```
JSON generation OK: {'key': 'value'}
```
### 7. Plugin Manifest + Routes
**Result:** Plugin loads with correct manifest and 8 routes registered.
```
Plugin instance: <ReportGeneratorPlugin name=report_generator version=1.0.0>
Manifest name: report_generator
Manifest version: 1.0.0
Is core: True
Dependencies: ['permissions']
Events: ['report.requested', 'report.generated']
Permissions: ['reports.read', 'reports.generate', 'reports.manage_templates']
Routes loaded: 1 router(s)
Router prefix: /api/v1/reports
Router routes: 8
{'GET'} /api/v1/reports/templates
{'POST'} /api/v1/reports/templates
{'GET'} /api/v1/reports/templates/{template_id}
{'PUT'} /api/v1/reports/templates/{template_id}
{'DELETE'} /api/v1/reports/templates/{template_id}
{'POST'} /api/v1/reports/generate
{'GET'} /api/v1/reports/{report_id}
{'GET'} /api/v1/reports/{report_id}/download
```
### 8. Migration SQL Validation
**Result:** 2 tables, 5 indexes, all required columns and FK present.
```
Tables: ['report_templates', 'report_instances']
Indexes: [('ix_report_templates_tenant', 'report_templates', 'tenant_id'), ('ix_report_templates_name', 'report_templates', 'name'), ('ix_report_instances_tenant', 'report_instances', 'tenant_id'), ('ix_report_instances_template', 'report_instances', 'template_id'), ('ix_report_instances_status', 'report_instances', 'status')]
FK reference present
Migration SQL validation passed.
```
## Smoke Test Summary
- Plugin class instantiates and loads routes correctly
- All 8 endpoints registered under `/api/v1/reports` prefix
- Jinja2 template rendering works with data injection
- CSV output uses Python stdlib csv module (with UTF-8 BOM for Excel compat)
- Excel output uses openpyxl Workbook
- JSON output parses rendered template as JSON
- Migration SQL creates 2 tables with correct columns, indexes, and FK
- Tenant isolation: all queries filter by `tenant_id` from `current_user`
- Soft delete: templates have `deleted_at` column, queries filter `deleted_at.is_(None)`
## Note on Dependencies
- `openpyxl>=3.1` and `redis>=5.0` and `passlib` were in requirements.txt but not installed in venv. Installed them to verify import chain. No changes to requirements.txt.
## Result: ALL TESTS PASSED ✅