Visualizzazione ricevute

This commit is contained in:
2026-03-25 18:02:50 +01:00
parent 03be5d0e32
commit f5fb537fed
4 changed files with 225 additions and 5 deletions
+133
View File
@@ -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,
+20
View File
@@ -104,4 +104,24 @@ export const messagesApi = {
getReceipts: (id: string) =>
apiClient.get<MessageResponse[]>(`/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<void> => {
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)
},
}
@@ -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}`)}
/>
))}
</div>
@@ -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 = (
<div className="flex items-start gap-3">
{/* Indicatore timeline */}
<div className="mt-1 flex flex-col items-center">
<div
className={`h-3 w-3 rounded-full border-2 ${
isRoot ? 'border-primary bg-primary/20' : 'border-muted-foreground bg-muted'
isRoot
? 'border-primary bg-primary/20'
: isClickable
? 'border-blue-500 bg-blue-100'
: 'border-muted-foreground bg-muted'
}`}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium truncate">{label}</span>
<span
className={`text-sm font-medium truncate ${
isClickable ? 'text-blue-600 group-hover:underline' : ''
}`}
>
{label}
</span>
{state && !isRoot && <PecStateBadge state={state} />}
{type && type !== 'posta_certificata' && <PecTypeBadge type={type} />}
{isClickable && (
<ExternalLink className="h-3.5 w-3.5 text-blue-400 group-hover:text-blue-600 flex-shrink-0" />
)}
</div>
{date && (
<p className="text-xs text-muted-foreground mt-0.5">{formatDate(date)}</p>
@@ -107,4 +127,19 @@ function ReceiptNode({ label, date, state, type, isRoot }: ReceiptNodeProps) {
</div>
</div>
)
if (isClickable) {
return (
<button
type="button"
onClick={onClick}
className="w-full text-left group rounded-md px-2 py-1 -mx-2 hover:bg-muted/50 transition-colors cursor-pointer"
title="Apri dettaglio ricevuta"
>
{content}
</button>
)
}
return <div>{content}</div>
}
@@ -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
</Button>
)}
{/* Scarica pacchetto completo ZIP (sempre visibile) */}
<Button
variant="outline"
size="sm"
onClick={handleDownloadPackage}
disabled={isDownloadingPackage}
title="Scarica pacchetto completo (ZIP con tutti i file originali)"
>
{isDownloadingPackage ? (
<div className="h-4 w-4 mr-1 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<PackageOpen className="h-4 w-4 mr-1" />
)}
Scarica
</Button>
</div>
</div>