mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
Visualizzazione ricevute
This commit is contained in:
@@ -526,6 +526,139 @@ async def download_attachment(
|
|||||||
raise NotFoundError("File non disponibile al momento")
|
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])
|
@router.get("/{message_id}/receipts", response_model=list[MessageResponse])
|
||||||
async def list_receipts(
|
async def list_receipts(
|
||||||
message_id: uuid.UUID,
|
message_id: uuid.UUID,
|
||||||
|
|||||||
@@ -104,4 +104,24 @@ export const messagesApi = {
|
|||||||
|
|
||||||
getReceipts: (id: string) =>
|
getReceipts: (id: string) =>
|
||||||
apiClient.get<MessageResponse[]>(`/messages/${id}/receipts`).then((r) => r.data),
|
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 { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { PecTypeBadge, PecStateBadge } from '@/components/PecBadge/PecBadge'
|
import { PecTypeBadge, PecStateBadge } from '@/components/PecBadge/PecBadge'
|
||||||
import { formatDate } from '@/lib/utils'
|
import { formatDate } from '@/lib/utils'
|
||||||
import type { MessageResponse } from '@/types/api.types'
|
import type { MessageResponse } from '@/types/api.types'
|
||||||
@@ -12,9 +13,11 @@ interface ReceiptTreeProps {
|
|||||||
/**
|
/**
|
||||||
* Visualizza la gerarchia delle ricevute PEC collegate a un messaggio.
|
* Visualizza la gerarchia delle ricevute PEC collegate a un messaggio.
|
||||||
* Mostra in ordine cronologico: accettazione → consegna (o anomalia).
|
* 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) {
|
export function ReceiptTree({ message, receipts }: ReceiptTreeProps) {
|
||||||
const [expanded, setExpanded] = useState(true)
|
const [expanded, setExpanded] = useState(true)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
if (receipts.length === 0) {
|
if (receipts.length === 0) {
|
||||||
if (message.direction === 'outbound') {
|
if (message.direction === 'outbound') {
|
||||||
@@ -66,6 +69,7 @@ export function ReceiptTree({ message, receipts }: ReceiptTreeProps) {
|
|||||||
date={receipt.received_at || receipt.created_at}
|
date={receipt.received_at || receipt.created_at}
|
||||||
type={receipt.pec_type}
|
type={receipt.pec_type}
|
||||||
messageId={receipt.id}
|
messageId={receipt.id}
|
||||||
|
onClick={() => navigate(`/messages/${receipt.id}`)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -81,25 +85,41 @@ interface ReceiptNodeProps {
|
|||||||
type?: MessageResponse['pec_type']
|
type?: MessageResponse['pec_type']
|
||||||
messageId?: string
|
messageId?: string
|
||||||
isRoot?: boolean
|
isRoot?: boolean
|
||||||
|
onClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReceiptNode({ label, date, state, type, isRoot }: ReceiptNodeProps) {
|
function ReceiptNode({ label, date, state, type, isRoot, onClick }: ReceiptNodeProps) {
|
||||||
return (
|
const isClickable = !!onClick
|
||||||
|
|
||||||
|
const content = (
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{/* Indicatore timeline */}
|
{/* Indicatore timeline */}
|
||||||
<div className="mt-1 flex flex-col items-center">
|
<div className="mt-1 flex flex-col items-center">
|
||||||
<div
|
<div
|
||||||
className={`h-3 w-3 rounded-full border-2 ${
|
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>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<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} />}
|
{state && !isRoot && <PecStateBadge state={state} />}
|
||||||
{type && type !== 'posta_certificata' && <PecTypeBadge type={type} />}
|
{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>
|
</div>
|
||||||
{date && (
|
{date && (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">{formatDate(date)}</p>
|
<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>
|
||||||
</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,
|
Trash2,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
MailX,
|
MailX,
|
||||||
|
PackageOpen,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
@@ -33,6 +34,7 @@ export function MessageDetailPage() {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const [showTagSelector, setShowTagSelector] = useState(false)
|
const [showTagSelector, setShowTagSelector] = useState(false)
|
||||||
|
const [isDownloadingPackage, setIsDownloadingPackage] = useState(false)
|
||||||
|
|
||||||
// Carica messaggio
|
// Carica messaggio
|
||||||
const {
|
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
|
// Imposta tag del messaggio
|
||||||
const setLabelsMutation = useMutation({
|
const setLabelsMutation = useMutation({
|
||||||
mutationFn: (labelIds: string[]) =>
|
mutationFn: (labelIds: string[]) =>
|
||||||
@@ -313,6 +329,22 @@ export function MessageDetailPage() {
|
|||||||
Rispondi
|
Rispondi
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user