Fix vari frontend

This commit is contained in:
2026-03-27 14:20:59 +01:00
parent bb2060c1ae
commit ab6db28449
8 changed files with 276 additions and 22 deletions
+97
View File
@@ -21,6 +21,8 @@ from app.schemas.mailbox import (
MailboxCreateRequest, MailboxCreateRequest,
MailboxListResponse, MailboxListResponse,
MailboxResponse, MailboxResponse,
MailboxSyncResponse,
MailboxUnreadCountsResponse,
MailboxUpdateRequest, MailboxUpdateRequest,
) )
from app.services.mailbox_service import MailboxService from app.services.mailbox_service import MailboxService
@@ -123,6 +125,52 @@ async def list_mailboxes(
) )
@router.get("/unread-counts", response_model=MailboxUnreadCountsResponse)
async def get_unread_counts(
current_user: CurrentUser,
db: DB,
) -> MailboxUnreadCountsResponse:
"""
Restituisce il numero di messaggi non letti per ciascuna casella accessibile.
Usato dalla sidebar per mostrare i badge per casella.
- Admin: conta su tutte le caselle del tenant.
- Operatori: solo le caselle con permesso can_read.
"""
from sqlalchemy import func, select
from app.models.message import Message
# Determina le caselle visibili
if current_user.is_admin:
visible_ids = None # nessun filtro
else:
from app.services.permission_service import PermissionService
perm_svc = PermissionService(db)
visible_ids = await perm_svc.get_visible_mailboxes(current_user)
if not visible_ids:
return MailboxUnreadCountsResponse(counts={})
q = (
select(Message.mailbox_id, func.count().label("cnt"))
.where(
Message.tenant_id == current_user.tenant_id,
Message.is_read == False, # noqa: E712
Message.direction == "inbound",
Message.is_trashed == False, # noqa: E712
Message.is_archived == False, # noqa: E712
Message.parent_message_id.is_(None),
)
.group_by(Message.mailbox_id)
)
if visible_ids is not None:
from app.models.mailbox import Mailbox
q = q.where(Message.mailbox_id.in_(visible_ids))
rows = (await db.execute(q)).all()
counts = {str(row.mailbox_id): row.cnt for row in rows}
return MailboxUnreadCountsResponse(counts=counts)
@router.get("/{mailbox_id}", response_model=MailboxResponse) @router.get("/{mailbox_id}", response_model=MailboxResponse)
async def get_mailbox( async def get_mailbox(
mailbox_id: uuid.UUID, mailbox_id: uuid.UUID,
@@ -200,3 +248,52 @@ async def test_mailbox_connection(
tenant_id=current_user.tenant_id, tenant_id=current_user.tenant_id,
data=data, data=data,
) )
@router.post(
"/{mailbox_id}/sync",
response_model=MailboxSyncResponse,
)
async def force_sync_mailbox(
mailbox_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> MailboxSyncResponse:
"""
Forza una sincronizzazione IMAP immediata della casella.
Accoda il job sync_mailbox nel worker tramite arq/Redis.
Utile dopo un errore di connessione o per forzare un aggiornamento.
Richiede ruolo **admin**.
"""
from app.core.exceptions import NotFoundError
svc = _svc(db)
mailbox = await svc.get_mailbox(mailbox_id, current_user.tenant_id)
if mailbox.status == "deleted":
raise NotFoundError("Casella non trovata o eliminata")
try:
from arq.connections import RedisSettings, create_pool as arq_create_pool
from app.config import get_settings
cfg = get_settings()
arq_settings = RedisSettings.from_dsn(cfg.redis_url)
arq_redis = await arq_create_pool(arq_settings)
await arq_redis.enqueue_job("sync_mailbox", str(mailbox_id))
await arq_redis.aclose()
except Exception as exc:
from app.core.logging import get_logger
logger = get_logger(__name__)
logger.error(f"[force_sync] Impossibile accodare job per {mailbox_id}: {exc}")
return MailboxSyncResponse(
status="error",
mailbox_id=mailbox_id,
message=f"Impossibile accodare il job: {exc}",
)
return MailboxSyncResponse(
status="enqueued",
mailbox_id=mailbox_id,
message=f"Sincronizzazione avviata per {mailbox.email_address}",
)
+12
View File
@@ -116,3 +116,15 @@ class ConnectionTestResult(BaseModel):
message: str message: str
latency_ms: float | None = None latency_ms: float | None = None
capabilities: list[str] | None = None # Solo per IMAP capabilities: list[str] | None = None # Solo per IMAP
class MailboxSyncResponse(BaseModel):
"""Risposta all'accodamento di un job di sincronizzazione manuale."""
status: str
mailbox_id: uuid.UUID
message: str
class MailboxUnreadCountsResponse(BaseModel):
"""Conteggio messaggi non letti per casella."""
counts: dict[str, int]
+12
View File
@@ -28,4 +28,16 @@ export const mailboxesApi = {
apiClient apiClient
.post<ConnectionTestResult>(`/mailboxes/${id}/test-connection`, { protocol }) .post<ConnectionTestResult>(`/mailboxes/${id}/test-connection`, { protocol })
.then((r) => r.data), .then((r) => r.data),
/** Forza una sincronizzazione IMAP manuale (accoda job arq). */
forceSync: (id: string) =>
apiClient
.post<{ status: string; mailbox_id: string; message: string }>(`/mailboxes/${id}/sync`)
.then((r) => r.data),
/** Restituisce il conteggio messaggi non letti per ciascuna casella. */
getUnreadCounts: () =>
apiClient
.get<{ counts: Record<string, number> }>('/mailboxes/unread-counts')
.then((r) => r.data.counts),
} }
+24 -2
View File
@@ -86,6 +86,14 @@ export function Sidebar() {
}) })
const mailboxes = mailboxesData?.items ?? [] const mailboxes = mailboxesData?.items ?? []
// Conteggio messaggi non letti per singola casella
const { data: unreadCounts = {} } = useQuery({
queryKey: ['mailboxes', 'unread-counts'],
queryFn: () => mailboxesApi.getUnreadCounts(),
staleTime: 60 * 1000,
refetchInterval: 120 * 1000,
})
// Virtual Box assegnate all'utente corrente // Virtual Box assegnate all'utente corrente
const { data: myVboxes = [] } = useQuery({ const { data: myVboxes = [] } = useQuery({
queryKey: ['virtual-boxes', 'my'], queryKey: ['virtual-boxes', 'my'],
@@ -312,6 +320,7 @@ export function Sidebar() {
collapsed={collapsed} collapsed={collapsed}
isExpanded={isMailboxExpanded(mailbox.id)} isExpanded={isMailboxExpanded(mailbox.id)}
onToggle={() => toggleMailbox(mailbox.id)} onToggle={() => toggleMailbox(mailbox.id)}
unreadCount={unreadCounts[mailbox.id] ?? 0}
/> />
))} ))}
</div> </div>
@@ -536,6 +545,7 @@ interface MailboxNavItemProps {
collapsed: boolean collapsed: boolean
isExpanded: boolean isExpanded: boolean
onToggle: () => void onToggle: () => void
unreadCount?: number
} }
/** Colore del pallino di stato casella */ /** Colore del pallino di stato casella */
@@ -554,7 +564,7 @@ function statusDot(status: MailboxResponse['status']): string {
} }
} }
function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle }: MailboxNavItemProps) { function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle, unreadCount = 0 }: MailboxNavItemProps) {
const displayName = mailbox.display_name || mailbox.email_address const displayName = mailbox.display_name || mailbox.email_address
const initial = displayName[0]?.toUpperCase() ?? '?' const initial = displayName[0]?.toUpperCase() ?? '?'
const dotClass = statusDot(mailbox.status) const dotClass = statusDot(mailbox.status)
@@ -572,7 +582,7 @@ function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle }: MailboxNav
: 'text-gray-300 hover:bg-gray-700 hover:text-white', : 'text-gray-300 hover:bg-gray-700 hover:text-white',
) )
} }
title={displayName} title={`${displayName}${unreadCount > 0 ? ` (${unreadCount} non letti)` : ''}`}
> >
<div className="relative"> <div className="relative">
<div className="h-6 w-6 rounded-full bg-gray-600 flex items-center justify-center text-xs font-semibold"> <div className="h-6 w-6 rounded-full bg-gray-600 flex items-center justify-center text-xs font-semibold">
@@ -584,6 +594,11 @@ function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle }: MailboxNav
dotClass, dotClass,
)} )}
/> />
{unreadCount > 0 && (
<span className="absolute -top-1.5 -right-1.5 h-3.5 min-w-[14px] px-0.5 rounded-full bg-blue-500 text-white text-[9px] font-bold flex items-center justify-center leading-none">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</div> </div>
</NavLink> </NavLink>
) )
@@ -615,6 +630,13 @@ function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle }: MailboxNav
{displayName} {displayName}
</span> </span>
{/* Badge non letti per casella */}
{unreadCount > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-[16px] px-1 rounded-full bg-blue-500 text-white text-[10px] font-bold flex-shrink-0">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
{/* Chevron espandi/comprimi */} {/* Chevron espandi/comprimi */}
<ChevronDown <ChevronDown
className={cn( className={cn(
+58 -16
View File
@@ -50,24 +50,46 @@ export function ComposePage() {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const replyTo = location.state?.replyTo as MessageResponse | undefined const replyTo = location.state?.replyTo as MessageResponse | undefined
const forwardOf = location.state?.forwardOf as MessageResponse | undefined
const [showCc, setShowCc] = useState(false) const [showCc, setShowCc] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
// Corpo HTML (gestito da TipTap) // Corpo HTML (gestito da TipTap)
const [bodyHtml, setBodyHtml] = useState<string>(() => { const [bodyHtml, setBodyHtml] = useState<string>(() => {
if (!replyTo) return '' if (replyTo) {
const date = new Date( const date = new Date(
replyTo.received_at || replyTo.created_at replyTo.received_at || replyTo.created_at
).toLocaleDateString('it-IT') ).toLocaleDateString('it-IT')
return [ return [
'<p></p>', '<p></p>',
'<p></p>', '<p></p>',
'<hr>', '<hr>',
`<p><strong>In risposta al messaggio del ${date}</strong></p>`, `<p><strong>In risposta al messaggio del ${date}</strong></p>`,
`<p>Da: ${replyTo.from_address || ''}</p>`, `<p>Da: ${replyTo.from_address || ''}</p>`,
`<p>A: ${replyTo.to_addresses?.join(', ') || ''}</p>`, `<p>A: ${replyTo.to_addresses?.join(', ') || ''}</p>`,
`<p>Oggetto: ${replyTo.subject || ''}</p>`, `<p>Oggetto: ${replyTo.subject || ''}</p>`,
].join('') ].join('')
}
if (forwardOf) {
const date = new Date(
forwardOf.received_at || forwardOf.sent_at || forwardOf.created_at
).toLocaleDateString('it-IT')
const bodyContent = forwardOf.body_text
? `<pre style="white-space:pre-wrap;font-family:inherit">${forwardOf.body_text}</pre>`
: ''
return [
'<p></p>',
'<p></p>',
'<hr>',
`<p><strong>Messaggio inoltrato</strong></p>`,
`<p>Da: ${forwardOf.from_address || ''}</p>`,
`<p>A: ${forwardOf.to_addresses?.join(', ') || ''}</p>`,
`<p>Data: ${date}</p>`,
`<p>Oggetto: ${forwardOf.subject || ''}</p>`,
bodyContent,
].join('')
}
return ''
}) })
// Allegati // Allegati
@@ -80,12 +102,16 @@ export function ComposePage() {
formState: { errors }, formState: { errors },
} = useForm<ComposeFormValues>({ } = useForm<ComposeFormValues>({
defaultValues: { defaultValues: {
mailbox_id: replyTo?.mailbox_id || '', mailbox_id: replyTo?.mailbox_id || forwardOf?.mailbox_id || '',
to_addresses: replyTo to_addresses: replyTo
? [{ value: replyTo.from_address || '' }] ? [{ value: replyTo.from_address || '' }]
: [{ value: '' }], : [{ value: '' }],
cc_addresses: [], cc_addresses: [],
subject: replyTo ? `Re: ${replyTo.subject || ''}` : '', subject: replyTo
? `Re: ${replyTo.subject || ''}`
: forwardOf
? `Fwd: ${forwardOf.subject || ''}`
: '',
}, },
}) })
@@ -214,13 +240,18 @@ export function ComposePage() {
</Button> </Button>
<div> <div>
<h1 className="text-lg font-semibold"> <h1 className="text-lg font-semibold">
{replyTo ? 'Rispondi a PEC' : 'Nuova PEC'} {replyTo ? 'Rispondi a PEC' : forwardOf ? 'Inoltra PEC' : 'Nuova PEC'}
</h1> </h1>
{replyTo && ( {replyTo && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
In risposta a: {replyTo.subject} In risposta a: {replyTo.subject}
</p> </p>
)} )}
{forwardOf && (
<p className="text-xs text-muted-foreground">
Inoltro di: {forwardOf.subject}
</p>
)}
</div> </div>
</div> </div>
@@ -239,6 +270,17 @@ export function ComposePage() {
</p> </p>
</div> </div>
{/* Avviso allegati messaggio inoltrato */}
{forwardOf && forwardOf.has_attachments && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 flex items-start gap-2">
<Paperclip className="h-4 w-4 text-amber-600 mt-0.5 flex-shrink-0" />
<p className="text-sm text-amber-700">
Il messaggio originale contiene allegati. Per includerli nell'inoltro,
scaricali dal messaggio originale e aggiungili qui manualmente.
</p>
</div>
)}
{/* Casella mittente */} {/* Casella mittente */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="mailbox_id">Casella mittente *</Label> <Label htmlFor="mailbox_id">Casella mittente *</Label>
+25 -4
View File
@@ -88,8 +88,9 @@ export function InboxPage({ viewMode }: InboxPageProps) {
const [dateFrom, setDateFrom] = useState('') const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('') const [dateTo, setDateTo] = useState('')
const [pecTypeFilter, setPecTypeFilter] = useState('') const [pecTypeFilter, setPecTypeFilter] = useState('')
const [pecStateFilter, setPecStateFilter] = useState('')
const activeAdvancedFiltersCount = [dateFrom, dateTo, pecTypeFilter].filter(Boolean).length const activeAdvancedFiltersCount = [dateFrom, dateTo, pecTypeFilter, pecStateFilter].filter(Boolean).length
// ── Stato selezione ───────────────────────────────────────────────────────── // ── Stato selezione ─────────────────────────────────────────────────────────
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()) const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
@@ -105,6 +106,7 @@ export function InboxPage({ viewMode }: InboxPageProps) {
setDateFrom('') setDateFrom('')
setDateTo('') setDateTo('')
setPecTypeFilter('') setPecTypeFilter('')
setPecStateFilter('')
setShowAdvancedFilters(false) setShowAdvancedFilters(false)
setPage(1) setPage(1)
setSelectedIds(new Set()) setSelectedIds(new Set())
@@ -152,6 +154,7 @@ export function InboxPage({ viewMode }: InboxPageProps) {
mailbox_id: mailboxId, mailbox_id: mailboxId,
search: debouncedSearch || undefined, search: debouncedSearch || undefined,
pec_type: pecTypeFilter || undefined, pec_type: pecTypeFilter || undefined,
state: pecStateFilter || undefined,
date_from: dateFrom ? new Date(dateFrom).toISOString() : undefined, date_from: dateFrom ? new Date(dateFrom).toISOString() : undefined,
date_to: dateTo ? new Date(dateTo + 'T23:59:59').toISOString() : undefined, date_to: dateTo ? new Date(dateTo + 'T23:59:59').toISOString() : undefined,
page, page,
@@ -584,17 +587,35 @@ export function InboxPage({ viewMode }: InboxPageProps) {
</select> </select>
</div> </div>
{/* Stato PEC */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">Stato PEC</label>
<select
className="w-full h-8 px-2 rounded-md border bg-background text-xs focus:outline-none focus:ring-2 focus:ring-ring"
value={pecStateFilter}
onChange={(e) => setPecStateFilter(e.target.value)}
>
<option value="">Tutti gli stati</option>
<option value="received">Ricevuto</option>
<option value="sent">Inviato</option>
<option value="accepted">Accettato</option>
<option value="delivered">Consegnato</option>
<option value="anomaly">Anomalia</option>
<option value="failed">Fallito</option>
</select>
</div>
{/* Reset filtri */} {/* Reset filtri */}
{activeAdvancedFiltersCount > 0 && ( {activeAdvancedFiltersCount > 0 && (
<div className="flex items-end"> <div className="flex items-end col-span-2 md:col-span-1">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-8 text-xs text-muted-foreground w-full" className="h-8 text-xs text-muted-foreground w-full"
onClick={() => { setDateFrom(''); setDateTo(''); setPecTypeFilter('') }} onClick={() => { setDateFrom(''); setDateTo(''); setPecTypeFilter(''); setPecStateFilter('') }}
> >
<X className="h-3.5 w-3.5 mr-1" /> <X className="h-3.5 w-3.5 mr-1" />
Azzera Azzera filtri
</Button> </Button>
</div> </div>
)} )}
@@ -49,6 +49,7 @@ export function MailboxesPage() {
const [editingMailbox, setEditingMailbox] = useState<MailboxResponse | null>(null) const [editingMailbox, setEditingMailbox] = useState<MailboxResponse | null>(null)
const [testingId, setTestingId] = useState<string | null>(null) const [testingId, setTestingId] = useState<string | null>(null)
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null) const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
const [syncingId, setSyncingId] = useState<string | null>(null)
const { data: mailboxesData, isLoading } = useQuery({ const { data: mailboxesData, isLoading } = useQuery({
queryKey: ['mailboxes'], queryKey: ['mailboxes'],
@@ -78,6 +79,23 @@ export function MailboxesPage() {
} }
} }
const handleForceSync = async (mailbox: MailboxResponse) => {
setSyncingId(mailbox.id)
try {
const result = await mailboxesApi.forceSync(mailbox.id)
if (result.status === 'enqueued') {
toast.success(result.message)
queryClient.invalidateQueries({ queryKey: ['mailboxes'] })
} else {
toast.error(result.message)
}
} catch (e) {
toast.error(getErrorMessage(e))
} finally {
setSyncingId(null)
}
}
const handleDelete = async (mailbox: MailboxResponse) => { const handleDelete = async (mailbox: MailboxResponse) => {
if (!confirm(`Eliminare la casella ${mailbox.email_address}? L'operazione è irreversibile.`)) return if (!confirm(`Eliminare la casella ${mailbox.email_address}? L'operazione è irreversibile.`)) return
deleteMutation.mutate(mailbox.id) deleteMutation.mutate(mailbox.id)
@@ -181,6 +199,19 @@ export function MailboxesPage() {
Test Test
</Button> </Button>
{/* Forza sincronizzazione */}
<Button
variant="outline"
size="sm"
onClick={() => handleForceSync(mailbox)}
isLoading={syncingId === mailbox.id}
title="Forza sincronizzazione IMAP immediata"
disabled={mailbox.status === 'deleted'}
>
<RefreshCw className="h-4 w-4 mr-1" />
Sync
</Button>
{/* Modifica */} {/* Modifica */}
<Button <Button
variant="outline" variant="outline"
@@ -8,6 +8,7 @@ import {
ArchiveX, ArchiveX,
Download, Download,
Reply, Reply,
Forward,
Paperclip, Paperclip,
Mail, Mail,
Send, Send,
@@ -330,6 +331,22 @@ export function MessageDetailPage() {
</Button> </Button>
)} )}
{/* Inoltra (disponibile per qualsiasi messaggio PEC certificata, non nel cestino) */}
{message.pec_type === 'posta_certificata' && !message.is_trashed && (
<Button
variant="outline"
size="sm"
onClick={() =>
navigate('/compose', {
state: { forwardOf: message },
})
}
>
<Forward className="h-4 w-4 mr-1" />
Inoltra
</Button>
)}
{/* Scarica pacchetto completo ZIP (sempre visibile) */} {/* Scarica pacchetto completo ZIP (sempre visibile) */}
<Button <Button
variant="outline" variant="outline"