diff --git a/backend/app/api/v1/messages.py b/backend/app/api/v1/messages.py index 289a8e0..137b3e2 100644 --- a/backend/app/api/v1/messages.py +++ b/backend/app/api/v1/messages.py @@ -526,6 +526,139 @@ async def download_attachment( raise NotFoundError("File non disponibile al momento") +@router.get("/{message_id}/download-package") +async def download_package( + message_id: uuid.UUID, + current_user: CurrentUser, + db: DB, +) -> StreamingResponse: + """ + Scarica un archivio ZIP con tutti i file originali della PEC. + + Per messaggi inbound: allegati del messaggio (postacert.eml, daticert.xml, ecc.) + e il raw EML originale. + + Per messaggi outbound: allegati del messaggio + raw EML di ogni ricevuta collegata + (accettazione, consegna, ecc.). + """ + import io + import zipfile as _zipfile + + from miniopy_async import Minio + + # Verifica accesso + message = await _resolve_message(message_id, current_user, db) + + client = Minio( + endpoint=settings.minio_endpoint, + access_key=settings.minio_access_key, + secret_key=settings.minio_secret_key, + secure=settings.minio_use_ssl, + ) + + buf = io.BytesIO() + + async def _read_minio(path: str) -> bytes: + try: + resp = await client.get_object(settings.minio_bucket, path) + data = await resp.content.read() + resp.close() + return data + except Exception: + return b"" + + with _zipfile.ZipFile(buf, mode="w", compression=_zipfile.ZIP_DEFLATED) as zf: + # ── Allegati del messaggio principale ────────────────────────────── + att_result = await db.execute( + select(Attachment) + .where(Attachment.message_id == message.id) + .order_by(Attachment.created_at) + ) + main_attachments = list(att_result.scalars().all()) + + for att in main_attachments: + data = await _read_minio(att.storage_path) + if data: + zf.writestr(att.filename, data) + + # ── Raw EML del messaggio principale ────────────────────────────── + if message.raw_eml_path: + data = await _read_minio(message.raw_eml_path) + if data: + # Nome file: messaggio_originale.eml oppure il basename del path + eml_name = message.raw_eml_path.rsplit("/", 1)[-1] + if not eml_name.endswith(".eml"): + eml_name = "messaggio_originale.eml" + # Evita duplicati con gli allegati gia' inseriti + existing = {info.filename for info in zf.infolist()} + if eml_name not in existing: + zf.writestr(eml_name, data) + + # ── Ricevute (solo per outbound) ─────────────────────────────────── + if message.direction == "outbound": + receipts_result = await db.execute( + select(Message) + .where(Message.parent_message_id == message.id) + .order_by(Message.received_at.asc().nullslast(), Message.created_at.asc()) + ) + receipts = list(receipts_result.scalars().all()) + + for receipt in receipts: + # Tipo ricevuta come prefisso cartella + pec_type = receipt.pec_type or "ricevuta" + folder = f"ricevute/{pec_type}" + + # Allegati della ricevuta + r_att_result = await db.execute( + select(Attachment) + .where(Attachment.message_id == receipt.id) + .order_by(Attachment.created_at) + ) + r_attachments = list(r_att_result.scalars().all()) + + for att in r_attachments: + data = await _read_minio(att.storage_path) + if data: + zip_path = f"{folder}/{att.filename}" + # Gestisce duplicati aggiungendo un contatore + existing = {info.filename for info in zf.infolist()} + final_path = zip_path + counter = 1 + while final_path in existing: + name, _, ext = att.filename.rpartition(".") + final_path = f"{folder}/{name}_{counter}.{ext}" if ext else f"{folder}/{att.filename}_{counter}" + counter += 1 + zf.writestr(final_path, data) + + # Raw EML della ricevuta + if receipt.raw_eml_path: + data = await _read_minio(receipt.raw_eml_path) + if data: + eml_name = receipt.raw_eml_path.rsplit("/", 1)[-1] + if not eml_name.endswith(".eml"): + eml_name = f"{pec_type}.eml" + zip_path = f"{folder}/{eml_name}" + existing = {info.filename for info in zf.infolist()} + if zip_path not in existing: + zf.writestr(zip_path, data) + + buf.seek(0) + zip_bytes = buf.getvalue() + + # Nome del file ZIP basato sull'oggetto della mail + safe_subject = (message.subject or "pec").replace("/", "_").replace("\\", "_")[:50] + zip_filename = f"pec_{safe_subject}.zip" + + return StreamingResponse( + iter([zip_bytes]), + media_type="application/zip", + headers={ + "Content-Disposition": f'attachment; filename="{zip_filename}"', + "Content-Length": str(len(zip_bytes)), + }, + ) + + @router.get("/{message_id}/receipts", response_model=list[MessageResponse]) async def list_receipts( message_id: uuid.UUID, diff --git a/frontend/src/api/messages.api.ts b/frontend/src/api/messages.api.ts index e30c106..a034d79 100644 --- a/frontend/src/api/messages.api.ts +++ b/frontend/src/api/messages.api.ts @@ -104,4 +104,24 @@ export const messagesApi = { getReceipts: (id: string) => apiClient.get(`/messages/${id}/receipts`).then((r) => r.data), + + /** + * Scarica il pacchetto ZIP completo della PEC (postacert.eml, daticert.xml, + * ricevute di accettazione/consegna per le mail outbound). + */ + downloadPackage: async (messageId: string, subject?: string | null): Promise => { + const response = await apiClient.get( + `/messages/${messageId}/download-package`, + { responseType: 'blob' }, + ) + const blobUrl = window.URL.createObjectURL(new Blob([response.data], { type: 'application/zip' })) + const anchor = document.createElement('a') + anchor.href = blobUrl + const safeSubject = (subject || 'pec').replace(/[/\\]/g, '_').slice(0, 50) + anchor.setAttribute('download', `pec_${safeSubject}.zip`) + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + window.URL.revokeObjectURL(blobUrl) + }, } diff --git a/frontend/src/components/ReceiptTree/ReceiptTree.tsx b/frontend/src/components/ReceiptTree/ReceiptTree.tsx index 44f7e3a..601b89b 100644 --- a/frontend/src/components/ReceiptTree/ReceiptTree.tsx +++ b/frontend/src/components/ReceiptTree/ReceiptTree.tsx @@ -1,5 +1,6 @@ -import { ChevronDown, ChevronRight, Mail } from 'lucide-react' +import { ChevronDown, ChevronRight, Mail, ExternalLink } from 'lucide-react' import { useState } from 'react' +import { useNavigate } from 'react-router-dom' import { PecTypeBadge, PecStateBadge } from '@/components/PecBadge/PecBadge' import { formatDate } from '@/lib/utils' import type { MessageResponse } from '@/types/api.types' @@ -12,9 +13,11 @@ interface ReceiptTreeProps { /** * Visualizza la gerarchia delle ricevute PEC collegate a un messaggio. * Mostra in ordine cronologico: accettazione → consegna (o anomalia). + * Le ricevute sono cliccabili e navigano al dettaglio del messaggio ricevuta. */ export function ReceiptTree({ message, receipts }: ReceiptTreeProps) { const [expanded, setExpanded] = useState(true) + const navigate = useNavigate() if (receipts.length === 0) { if (message.direction === 'outbound') { @@ -66,6 +69,7 @@ export function ReceiptTree({ message, receipts }: ReceiptTreeProps) { date={receipt.received_at || receipt.created_at} type={receipt.pec_type} messageId={receipt.id} + onClick={() => navigate(`/messages/${receipt.id}`)} /> ))} @@ -81,25 +85,41 @@ interface ReceiptNodeProps { type?: MessageResponse['pec_type'] messageId?: string isRoot?: boolean + onClick?: () => void } -function ReceiptNode({ label, date, state, type, isRoot }: ReceiptNodeProps) { - return ( +function ReceiptNode({ label, date, state, type, isRoot, onClick }: ReceiptNodeProps) { + const isClickable = !!onClick + + const content = (
{/* Indicatore timeline */}
- {label} + + {label} + {state && !isRoot && } {type && type !== 'posta_certificata' && } + {isClickable && ( + + )}
{date && (

{formatDate(date)}

@@ -107,4 +127,19 @@ function ReceiptNode({ label, date, state, type, isRoot }: ReceiptNodeProps) {
) + + if (isClickable) { + return ( + + ) + } + + return
{content}
} diff --git a/frontend/src/pages/MessageDetail/MessageDetailPage.tsx b/frontend/src/pages/MessageDetail/MessageDetailPage.tsx index c4dfb7a..84ff5cc 100644 --- a/frontend/src/pages/MessageDetail/MessageDetailPage.tsx +++ b/frontend/src/pages/MessageDetail/MessageDetailPage.tsx @@ -15,6 +15,7 @@ import { Trash2, RotateCcw, MailX, + PackageOpen, } from 'lucide-react' import toast from 'react-hot-toast' import { Button } from '@/components/ui/Button' @@ -33,6 +34,7 @@ export function MessageDetailPage() { const queryClient = useQueryClient() const [showTagSelector, setShowTagSelector] = useState(false) + const [isDownloadingPackage, setIsDownloadingPackage] = useState(false) // Carica messaggio const { @@ -136,6 +138,20 @@ export function MessageDetailPage() { } } + // Download pacchetto completo ZIP + const handleDownloadPackage = async () => { + if (!message) return + setIsDownloadingPackage(true) + try { + await messagesApi.downloadPackage(message.id, message.subject) + toast.success('Pacchetto scaricato') + } catch (error) { + toast.error(getErrorMessage(error)) + } finally { + setIsDownloadingPackage(false) + } + } + // Imposta tag del messaggio const setLabelsMutation = useMutation({ mutationFn: (labelIds: string[]) => @@ -313,6 +329,22 @@ export function MessageDetailPage() { Rispondi )} + + {/* Scarica pacchetto completo ZIP (sempre visibile) */} +