diff --git a/KnowledgeBaseCline.md b/KnowledgeBaseCline.md index d2662f1..feecac8 100644 --- a/KnowledgeBaseCline.md +++ b/KnowledgeBaseCline.md @@ -48,4 +48,3 @@ Ho docker installato, compose v2 (docker cmpose senza trattino) Il progetto si trova in `/opt/pechub` -Il servizio deve essere esposto su 0.0.0.0 e prevedere l'esposizione anche col dominio pechub.it \ No newline at end of file diff --git a/backend/app/api/v1/messages.py b/backend/app/api/v1/messages.py index 407c2e3..36370dd 100644 --- a/backend/app/api/v1/messages.py +++ b/backend/app/api/v1/messages.py @@ -860,6 +860,64 @@ async def get_attachment_preview_url( } +# ─── Feature 7b: Serve allegato inline per preview ─────────────────────────── + +@router.get("/{message_id}/attachments/{attachment_id}/inline") +async def inline_attachment( + message_id: uuid.UUID, + attachment_id: uuid.UUID, + current_user: CurrentUser, + db: DB, +): + """ + Serve l'allegato con Content-Disposition: inline per la preview nel browser. + + Legge il file da MinIO internamente e lo restituisce attraverso il backend, + evitando i problemi di accessibilita' con i presigned URL di MinIO in ambienti Docker. + Supporta PDF e immagini; per altri tipi restituisce 404. + """ + await _resolve_message(message_id, current_user, db) + + result = await db.execute( + select(Attachment).where( + Attachment.id == attachment_id, + Attachment.message_id == message_id, + ) + ) + attachment = result.scalar_one_or_none() + if not attachment: + raise NotFoundError(f"Allegato {attachment_id} non trovato") + + content_type = attachment.content_type or "application/octet-stream" + filename = attachment.filename.replace('"', "'") + + try: + from miniopy_async import Minio + + client = Minio( + endpoint=settings.minio_endpoint, + access_key=settings.minio_access_key, + secret_key=settings.minio_secret_key, + secure=settings.minio_use_ssl, + ) + response = await client.get_object(settings.minio_bucket, attachment.storage_path) + data = await response.content.read() + response.close() + return StreamingResponse( + iter([data]), + media_type=content_type, + headers={ + "Content-Disposition": f'inline; filename="{filename}"', + "Content-Length": str(len(data)), + }, + ) + except Exception as e: + from app.core.logging import get_logger + logger = get_logger(__name__) + logger.error(f"Errore inline allegato {attachment_id}: {e}") + raise NotFoundError("File non disponibile al momento") + + # ─── Feature 8: Stampa/export HTML ──────────────────────────────────────────── @router.get("/{message_id}/print") diff --git a/frontend/src/components/ComposeModal/ComposeModal.tsx b/frontend/src/components/ComposeModal/ComposeModal.tsx new file mode 100644 index 0000000..f944907 --- /dev/null +++ b/frontend/src/components/ComposeModal/ComposeModal.tsx @@ -0,0 +1,598 @@ +import { useState, useRef, useMemo, useEffect } from 'react' +import { useForm, useFieldArray } from 'react-hook-form' +import { + Send, + X, + Plus, + AlertCircle, + Paperclip, + Upload, + Filter, + Minus, + Maximize2, + Minimize2, +} from 'lucide-react' +import { useQuery, useMutation } from '@tanstack/react-query' +import toast from 'react-hot-toast' +import { Button } from '@/components/ui/Button' +import { Input } from '@/components/ui/Input' +import { Label } from '@/components/ui/Label' +import { RichTextEditor } from '@/components/RichTextEditor/RichTextEditor' +import { sendApi } from '@/api/send.api' +import { mailboxesApi } from '@/api/mailboxes.api' +import { virtualBoxesApi } from '@/api/virtual_boxes.api' +import { getErrorMessage } from '@/api/client' +import { useComposeStore } from '@/store/compose.store' +import type { MessageResponse } from '@/types/api.types' + +// ─── Tipi ───────────────────────────────────────────────────────────────────── + +interface MailboxSelectItem { + id: string + email_address: string + display_name: string | null + status: string + fromVbox?: boolean +} + +interface ComposeFormValues { + mailbox_id: string + to_addresses: { value: string }[] + cc_addresses: { value: string }[] + subject: string +} + +// ─── Utility ────────────────────────────────────────────────────────────────── + +const MAX_FILE_SIZE = 20 * 1024 * 1024 + +function formatFileSize(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` +} + +function htmlToText(html: string): string { + if (!html || html === '

') return '' + const div = document.createElement('div') + div.innerHTML = html + return div.textContent || div.innerText || '' +} + +function buildInitialBody(replyTo?: MessageResponse | null, forwardOf?: MessageResponse | null): string { + if (replyTo) { + const date = new Date(replyTo.received_at || replyTo.created_at).toLocaleDateString('it-IT') + return [ + '

', + '

', + '
', + `

In risposta al messaggio del ${date}

`, + `

Da: ${replyTo.from_address || ''}

`, + `

A: ${replyTo.to_addresses?.join(', ') || ''}

`, + `

Oggetto: ${replyTo.subject || ''}

`, + ].join('') + } + if (forwardOf) { + const date = new Date(forwardOf.received_at || forwardOf.sent_at || forwardOf.created_at).toLocaleDateString('it-IT') + const bodyContent = forwardOf.body_text + ? `
${forwardOf.body_text}
` + : '' + return [ + '

', + '

', + '
', + `

Messaggio inoltrato

`, + `

Da: ${forwardOf.from_address || ''}

`, + `

A: ${forwardOf.to_addresses?.join(', ') || ''}

`, + `

Data: ${date}

`, + `

Oggetto: ${forwardOf.subject || ''}

`, + bodyContent, + ].join('') + } + return '' +} + +// ─── Form interno (rimontato ogni volta che cambia il messaggio) ─────────────── + +interface ComposeFormProps { + replyTo: MessageResponse | null + forwardOf: MessageResponse | null + onClose: () => void +} + +function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) { + const fileInputRef = useRef(null) + const [showCc, setShowCc] = useState(false) + const [attachments, setAttachments] = useState([]) + const [bodyHtml, setBodyHtml] = useState(() => + buildInitialBody(replyTo, forwardOf) + ) + + const { + register, + control, + handleSubmit, + formState: { errors }, + } = useForm({ + defaultValues: { + mailbox_id: replyTo?.mailbox_id || forwardOf?.mailbox_id || '', + to_addresses: replyTo ? [{ value: replyTo.from_address || '' }] : [{ value: '' }], + cc_addresses: [], + subject: replyTo + ? `Re: ${replyTo.subject || ''}` + : forwardOf + ? `Fwd: ${forwardOf.subject || ''}` + : '', + }, + }) + + const { fields: toFields, append: appendTo, remove: removeTo } = useFieldArray({ control, name: 'to_addresses' }) + const { fields: ccFields, append: appendCc, remove: removeCc } = useFieldArray({ control, name: 'cc_addresses' }) + + const { data: mailboxesData, isLoading: mailboxesLoading } = useQuery({ + queryKey: ['mailboxes'], + queryFn: () => mailboxesApi.list(), + }) + + const { data: vboxMailboxes = [], isLoading: vboxMailboxesLoading } = useQuery({ + queryKey: ['virtual-boxes', 'my-mailboxes'], + queryFn: () => virtualBoxesApi.getMyMailboxes(), + }) + + const sendMutation = useMutation({ + mutationFn: (args: { data: Parameters[0]; files: File[] }) => + sendApi.sendMultipart(args.data, args.files), + onSuccess: (job) => { + toast.success(`PEC inviata! Job ID: ${job.id.slice(0, 8)}...`) + onClose() + }, + onError: (error) => { + toast.error(getErrorMessage(error)) + }, + }) + + const activeCaselle = useMemo((): MailboxSelectItem[] => { + const regularActive: MailboxSelectItem[] = ( + mailboxesData?.items.filter((m) => m.status === 'active') || [] + ).map((m) => ({ + id: m.id, + email_address: m.email_address, + display_name: m.display_name, + status: m.status, + fromVbox: false, + })) + + const regularIds = new Set(regularActive.map((m) => m.id)) + + const vboxActive: MailboxSelectItem[] = vboxMailboxes + .filter((m) => m.status === 'active' && !regularIds.has(m.id)) + .map((m) => ({ + id: m.id, + email_address: m.email_address, + display_name: m.display_name, + status: m.status, + fromVbox: true, + })) + + return [...regularActive, ...vboxActive] + }, [mailboxesData, vboxMailboxes]) + + const isLoadingMailboxes = mailboxesLoading || vboxMailboxesLoading + + const handleFileAdd = (files: FileList | null) => { + if (!files) return + const valid: File[] = [] + for (const file of Array.from(files)) { + if (file.size > MAX_FILE_SIZE) { + toast.error(`"${file.name}" supera il limite di 20 MB`) + } else { + valid.push(file) + } + } + if (valid.length) setAttachments((prev) => [...prev, ...valid]) + } + + const removeAttachment = (idx: number) => { + setAttachments((prev) => prev.filter((_, i) => i !== idx)) + } + + const onSubmit = async (formData: ComposeFormValues) => { + const toAddresses = formData.to_addresses.map((t) => t.value.trim()).filter((v) => v.length > 0) + if (toAddresses.length === 0) { + toast.error('Inserisci almeno un destinatario') + return + } + await sendMutation.mutateAsync({ + data: { + mailbox_id: formData.mailbox_id, + to_addresses: toAddresses, + cc_addresses: formData.cc_addresses.map((c) => c.value.trim()).filter((v) => v.length > 0), + subject: formData.subject, + body_text: htmlToText(bodyHtml), + body_html: bodyHtml, + reply_to_message_id: replyTo?.id, + }, + files: attachments, + }) + } + + return ( +
+ {/* Corpo scrollabile */} +
+ {/* Avviso informativo */} +
+ +

+ L'invio avviene tramite il server PEC della casella selezionata. +

+
+ + {/* Allegati del messaggio inoltrato */} + {forwardOf && forwardOf.has_attachments && ( +
+ +

+ Il messaggio originale contiene allegati. Scaricali e aggiungili qui manualmente. +

+
+ )} + + {/* Casella mittente */} +
+ + {isLoadingMailboxes ? ( +
+ ) : activeCaselle.length === 0 ? ( +
+ Nessuna casella PEC attiva disponibile. +
+ ) : ( + <> + + {activeCaselle.some((m) => m.fromVbox) && ( +

+ + Le caselle con sono accessibili tramite Virtual Box +

+ )} + + )} + {errors.mailbox_id && ( +

{errors.mailbox_id.message}

+ )} +
+ + {/* Destinatari A: */} +
+
+ + +
+
+ {toFields.map((field, idx) => ( +
+
+ + {toFields.length > 1 && ( + + )} +
+ {errors.to_addresses?.[idx]?.value && ( +

{errors.to_addresses[idx]?.value?.message}

+ )} +
+ ))} +
+
+ + {/* CC */} +
+ {!showCc ? ( + + ) : ( + <> +
+ + +
+ {ccFields.map((field, idx) => ( +
+
+ + +
+ {errors.cc_addresses?.[idx]?.value && ( +

{errors.cc_addresses[idx]?.value?.message}

+ )} +
+ ))} + + )} +
+ + {/* Oggetto */} +
+ + + {errors.subject && ( +

{errors.subject.message}

+ )} +
+ + {/* Corpo */} +
+ + +
+ + {/* Allegati */} +
+
+ + Max 20 MB +
+ {attachments.length > 0 && ( +
+ {attachments.map((file, idx) => ( +
+ + {file.name} + {formatFileSize(file.size)} + +
+ ))} +
+ )} +
fileInputRef.current?.click()} + onDragOver={(e) => { e.preventDefault(); e.stopPropagation() }} + onDrop={(e) => { e.preventDefault(); handleFileAdd(e.dataTransfer.files) }} + > + +

Clicca o trascina i file qui

+
+ handleFileAdd(e.target.files)} + /> +
+
+ + {/* Footer azioni */} +
+ + +
+ + ) +} + +// ─── Componente principale flottante ────────────────────────────────────────── + +export function ComposeModal() { + const { isOpen, mode, replyTo, forwardOf, closeCompose, setMode } = useComposeStore() + + // Chiudi con ESC (solo quando non minimizzato) + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isOpen && mode !== 'minimized') { + closeCompose() + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [isOpen, mode, closeCompose]) + + if (!isOpen) return null + + const title = replyTo ? 'Rispondi a PEC' : forwardOf ? 'Inoltra PEC' : 'Nuova PEC' + const subtitle = replyTo?.subject || forwardOf?.subject || null + + // Stile inline per il posizionamento (piu' affidabile delle classi Tailwind dinamiche) + const containerStyle = (() => { + if (mode === 'fullscreen') { + return { + position: 'fixed' as const, + top: '1rem', + right: '1rem', + bottom: '1rem', + left: '1rem', + } + } + if (mode === 'minimized') { + return { + position: 'fixed' as const, + bottom: 0, + right: '1.5rem', + width: '320px', + height: '48px', + } + } + // normal + return { + position: 'fixed' as const, + bottom: 0, + right: '1.5rem', + width: '560px', + height: '580px', + } + })() + + return ( +
+ {/* Barra del titolo */} +
{ + if (mode === 'minimized') setMode('normal') + }} + > +
+ {title} + {subtitle && mode !== 'normal' && ( + {subtitle} + )} +
+ +
e.stopPropagation()} + > + {/* Minimizza */} + + + {/* Fullscreen / Ripristina */} + {mode !== 'minimized' && ( + + )} + + {/* Chiudi */} + +
+
+ + {/* Corpo (nascosto quando minimizzato) */} + {mode !== 'minimized' && ( +
+ {/* + Key basata su replyTo/forwardOf: garantisce che il form venga rimontato + (e quindi resettato) ogni volta che si apre per un messaggio diverso. + */} + +
+ )} +
+ ) +} diff --git a/frontend/src/components/Layout/AppLayout.tsx b/frontend/src/components/Layout/AppLayout.tsx index 233aa79..be41106 100644 --- a/frontend/src/components/Layout/AppLayout.tsx +++ b/frontend/src/components/Layout/AppLayout.tsx @@ -1,6 +1,7 @@ import { Outlet, Navigate } from 'react-router-dom' import { Toaster } from 'react-hot-toast' import { Sidebar } from './Sidebar' +import { ComposeModal } from '@/components/ComposeModal/ComposeModal' import { useAuth } from '@/hooks/useAuth' import { useWebSocket } from '@/hooks/useWebSocket' import { useEffect } from 'react' @@ -59,6 +60,9 @@ export function AppLayout() { }, }} /> + + {/* Finestra di composizione PEC flottante (stile Gmail) */} +
) } diff --git a/frontend/src/pages/Compose/ComposePage.tsx b/frontend/src/pages/Compose/ComposePage.tsx index a08faa7..f76da4e 100644 --- a/frontend/src/pages/Compose/ComposePage.tsx +++ b/frontend/src/pages/Compose/ComposePage.tsx @@ -259,6 +259,7 @@ export function ComposePage() {
{/* Avviso informativo */} @@ -335,33 +336,39 @@ export function ComposePage() {
{toFields.map((field, idx) => ( -
- - {toFields.length > 1 && ( - +
+
+ + {toFields.length > 1 && ( + + )} +
+ {errors.to_addresses?.[idx]?.value && ( +

+ {errors.to_addresses[idx]?.value?.message} +

)}
))}
- {errors.to_addresses?.[0]?.value && ( -

- {errors.to_addresses[0].value.message} -

- )}
{/* CC */} @@ -389,20 +396,32 @@ export function ComposePage() { {ccFields.map((field, idx) => ( -
- - +
+
+ + +
+ {errors.cc_addresses?.[idx]?.value && ( +

+ {errors.cc_addresses[idx]?.value?.message} +

+ )}
))} diff --git a/frontend/src/pages/MessageDetail/MessageDetailPage.tsx b/frontend/src/pages/MessageDetail/MessageDetailPage.tsx index 170cc35..88f2b68 100644 --- a/frontend/src/pages/MessageDetail/MessageDetailPage.tsx +++ b/frontend/src/pages/MessageDetail/MessageDetailPage.tsx @@ -37,6 +37,7 @@ import { deadlinesApi } from '@/api/deadlines.api' import { formatDate, formatBytes } from '@/lib/utils' import { getErrorMessage } from '@/api/client' import apiClient from '@/api/client' +import { useComposeStore } from '@/store/compose.store' // ─── Thread section (Feature 3) ────────────────────────────────────────────── @@ -100,13 +101,23 @@ interface AttachmentPreviewProps { } function AttachmentPreviewModal({ messageId, attachmentId, filename, contentType, onClose }: AttachmentPreviewProps) { - const { data, isLoading } = useQuery({ - queryKey: ['attachment-preview', messageId, attachmentId], - queryFn: () => messagesApi.getAttachmentPreviewUrl(messageId, attachmentId), + const isPreviewable = contentType.startsWith('image/') || contentType === 'application/pdf' + + const { data: blobUrl, isLoading, isError } = useQuery({ + queryKey: ['attachment-inline', messageId, attachmentId], + queryFn: async () => { + const response = await apiClient.get( + `/messages/${messageId}/attachments/${attachmentId}/inline`, + { responseType: 'blob' }, + ) + return window.URL.createObjectURL(new Blob([response.data], { type: contentType })) + }, + enabled: isPreviewable, + staleTime: 5 * 60 * 1000, }) return ( -
+
{filename} @@ -118,24 +129,27 @@ function AttachmentPreviewModal({ messageId, attachmentId, filename, contentType {isLoading && (
)} - {!isLoading && data && ( + {isError && ( +
+ +

Errore nel caricamento del file.

+
+ )} + {!isLoading && !isError && blobUrl && ( <> - {data.previewable && data.url ? ( - <> - {contentType.startsWith('image/') ? ( - {filename} - ) : contentType === 'application/pdf' ? ( -