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 (
+
+ )
+}
+
+// ─── 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 */}
+ setMode(mode === 'minimized' ? 'normal' : 'minimized')}
+ className="p-1.5 rounded hover:bg-white/20 transition-colors"
+ >
+
+
+
+ {/* Fullscreen / Ripristina */}
+ {mode !== 'minimized' && (
+ setMode(mode === 'fullscreen' ? 'normal' : 'fullscreen')}
+ className="p-1.5 rounded hover:bg-white/20 transition-colors"
+ >
+ {mode === 'fullscreen' ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ {/* 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() {
{toFields.map((field, idx) => (
-
-
- {toFields.length > 1 && (
-
removeTo(idx)}
- >
-
-
+
+
+
+ {toFields.length > 1 && (
+ removeTo(idx)}
+ >
+
+
+ )}
+
+ {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) => (
-
-
-
removeCc(idx)}
- >
-
-
+
+
+
+ removeCc(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/') ? (
-
- ) : contentType === 'application/pdf' ? (
-
- ) : null}
- >
- ) : (
-
-
-
Anteprima non disponibile per questo tipo di file.
-
- )}
+ {contentType.startsWith('image/') ? (
+
+ ) : contentType === 'application/pdf' ? (
+
+ ) : null}
>
)}
+ {!isLoading && !isError && !isPreviewable && (
+
+
+
Anteprima non disponibile per questo tipo di file.
+
+ )}
@@ -148,6 +162,7 @@ export function MessageDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
+ const openCompose = useComposeStore((s) => s.openCompose)
const [showTagSelector, setShowTagSelector] = useState(false)
const [isDownloadingPackage, setIsDownloadingPackage] = useState(false)
@@ -161,6 +176,12 @@ export function MessageDetailPage() {
// Feature 7: Preview allegati
const [previewAtt, setPreviewAtt] = useState<{ id: string; filename: string; contentType: string } | null>(null)
+ // Modal stampa con allegati
+ const [showPrintModal, setShowPrintModal] = useState(false)
+ const [printBody, setPrintBody] = useState(true)
+ const [printAttIds, setPrintAttIds] = useState
>(new Set())
+ const [printModalPreviewAtt, setPrintModalPreviewAtt] = useState<{ id: string; filename: string; contentType: string } | null>(null)
+
// Carica messaggio
const {
data: message,
@@ -254,6 +275,79 @@ export function MessageDetailPage() {
onError: (error) => toast.error(getErrorMessage(error)),
})
+ // Stampa diretta (senza modal)
+ const handlePrintDirect = async () => {
+ if (!message) return
+ setIsPrinting(true)
+ try {
+ const response = await apiClient.get(`/messages/${message.id}/print`, { responseType: 'blob' })
+ const html = await response.data.text()
+ const w = window.open('', '_blank')
+ if (w) {
+ w.document.write(html)
+ w.document.close()
+ setTimeout(() => w.print(), 500)
+ }
+ } catch (e) {
+ toast.error('Errore apertura stampa')
+ } finally {
+ setIsPrinting(false)
+ }
+ }
+
+ // Stampa con selezione dal modal
+ const handlePrintFromModal = async () => {
+ if (!message) return
+ setShowPrintModal(false)
+ setIsPrinting(true)
+ try {
+ // 1. Stampa corpo email se selezionato
+ if (printBody) {
+ const response = await apiClient.get(`/messages/${message.id}/print`, { responseType: 'blob' })
+ const html = await response.data.text()
+ const w = window.open('', '_blank')
+ if (w) {
+ w.document.write(html)
+ w.document.close()
+ setTimeout(() => w.print(), 500)
+ }
+ }
+
+ // 2. Per ogni allegato selezionato, apre in nuova finestra tramite blob URL
+ const selectedAtts = attachments.filter((a) => printAttIds.has(a.id))
+ for (const att of selectedAtts) {
+ const isPrintable = att.content_type?.startsWith('image/') || att.content_type === 'application/pdf'
+ if (!isPrintable) {
+ toast(`Allegato "${att.filename}" non stampabile direttamente: scaricarlo e stamparlo manualmente.`, { icon: 'i' })
+ continue
+ }
+ try {
+ const resp = await apiClient.get(
+ `/messages/${message.id}/attachments/${att.id}/inline`,
+ { responseType: 'blob' },
+ )
+ const blobUrl = window.URL.createObjectURL(new Blob([resp.data], { type: att.content_type || 'application/octet-stream' }))
+ const attWin = window.open(blobUrl, '_blank')
+ if (attWin) {
+ setTimeout(() => {
+ try { attWin.print() } catch (_) { /* ignora */ }
+ window.URL.revokeObjectURL(blobUrl)
+ }, 1500)
+ } else {
+ window.URL.revokeObjectURL(blobUrl)
+ toast.error(`Impossibile aprire "${att.filename}": controlla che i popup non siano bloccati.`)
+ }
+ } catch (_) {
+ toast.error(`Errore apertura allegato "${att.filename}"`)
+ }
+ }
+ } catch (e) {
+ toast.error('Errore apertura stampa')
+ } finally {
+ setIsPrinting(false)
+ }
+ }
+
// Download allegato autenticato
const handleDownloadAttachment = async (att: { id: string; filename: string }) => {
try {
@@ -444,11 +538,7 @@ export function MessageDetailPage() {
- navigate('/compose', {
- state: { replyTo: message },
- })
- }
+ onClick={() => openCompose({ replyTo: message })}
>
Rispondi
@@ -460,11 +550,7 @@ export function MessageDetailPage() {
- navigate('/compose', {
- state: { forwardOf: message },
- })
- }
+ onClick={() => openCompose({ forwardOf: message })}
>
Inoltra
@@ -492,22 +578,17 @@ export function MessageDetailPage() {
variant="outline"
size="sm"
isLoading={isPrinting}
- onClick={async () => {
+ onClick={() => {
if (!message) return
- setIsPrinting(true)
- try {
- const response = await apiClient.get(`/messages/${message.id}/print`, { responseType: 'blob' })
- const html = await response.data.text()
- const w = window.open('', '_blank')
- if (w) {
- w.document.write(html)
- w.document.close()
- setTimeout(() => w.print(), 500)
- }
- } catch (e) {
- toast.error('Errore apertura stampa')
- } finally {
- setIsPrinting(false)
+ if (attachments.length > 0) {
+ // Apre modal di selezione stampa
+ setPrintBody(true)
+ setPrintAttIds(new Set())
+ setPrintModalPreviewAtt(null)
+ setShowPrintModal(true)
+ } else {
+ // Nessun allegato: stampa direttamente come prima
+ handlePrintDirect()
}
}}
title="Stampa / Salva come PDF"
@@ -851,6 +932,132 @@ export function MessageDetailPage() {
/>
)}
+ {/* Modal stampa con selezione allegati */}
+ {showPrintModal && (
+
+
+ {/* Header */}
+
+
+
+ Stampa messaggio
+
+
setShowPrintModal(false)}
+ className="p-1 rounded hover:bg-muted"
+ >
+
+
+
+
+ {/* Body */}
+
+
Seleziona cosa includere nella stampa:
+
+ {/* Checkbox corpo email */}
+
+ setPrintBody(e.target.checked)}
+ className="h-4 w-4 rounded border-gray-300 accent-primary"
+ />
+
+
Corpo della email
+
Intestazione, mittente, destinatari e testo del messaggio
+
+
+
+ {/* Separatore */}
+
+
+
+ Allegati ({attachments.length})
+
+
+ {attachments.map((att) => {
+ const isPreviewable = att.content_type?.startsWith('image/') || att.content_type === 'application/pdf'
+ const isPrintable = isPreviewable
+ return (
+
+
{
+ setPrintAttIds((prev) => {
+ const next = new Set(prev)
+ if (e.target.checked) next.add(att.id)
+ else next.delete(att.id)
+ return next
+ })
+ }}
+ className="h-4 w-4 rounded border-gray-300 accent-primary flex-shrink-0"
+ />
+
+
{att.filename}
+
+ {att.content_type || 'Tipo sconosciuto'} • {formatBytes(att.size_bytes)}
+ {!isPrintable && (
+ (stampa non diretta)
+ )}
+
+
+ {/* Pulsante anteprima all'interno del modal */}
+ {isPreviewable && (
+
setPrintModalPreviewAtt({ id: att.id, filename: att.filename, contentType: att.content_type || '' })}
+ className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-primary flex-shrink-0"
+ title="Visualizza allegato"
+ >
+
+
+ )}
+
+ )
+ })}
+
+
+
+ {/* Nota per allegati non stampabili */}
+ {attachments.some((a) => !a.content_type?.startsWith('image/') && a.content_type !== 'application/pdf') && (
+
+ Gli allegati contrassegnati con "stampa non diretta" (es. .eml, .xml) non possono essere aperti direttamente dal browser. Scaricali e stampali manualmente.
+
+ )}
+
+
+ {/* Footer */}
+
+
setShowPrintModal(false)}>
+ Annulla
+
+
+
+ Stampa
+
+
+
+
+ {/* Anteprima allegato aperta dall'interno del modal (z-index superiore) */}
+ {printModalPreviewAtt && (
+
setPrintModalPreviewAtt(null)}
+ />
+ )}
+
+ )}
+
{/* Dialog gestione tag */}
{showTagSelector && (
void
+ closeCompose: () => void
+ setMode: (mode: ComposeMode) => void
+}
+
+export const useComposeStore = create()((set) => ({
+ isOpen: false,
+ mode: 'normal',
+ replyTo: null,
+ forwardOf: null,
+
+ openCompose: (opts) =>
+ set({
+ isOpen: true,
+ mode: 'normal',
+ replyTo: opts?.replyTo ?? null,
+ forwardOf: opts?.forwardOf ?? null,
+ }),
+
+ closeCompose: () =>
+ set({
+ isOpen: false,
+ mode: 'normal',
+ replyTo: null,
+ forwardOf: null,
+ }),
+
+ setMode: (mode) => set({ mode }),
+}))