diff --git a/backend/app/api/v1/mailboxes.py b/backend/app/api/v1/mailboxes.py index a0d9c7a..5bbf1e5 100644 --- a/backend/app/api/v1/mailboxes.py +++ b/backend/app/api/v1/mailboxes.py @@ -21,6 +21,8 @@ from app.schemas.mailbox import ( MailboxCreateRequest, MailboxListResponse, MailboxResponse, + MailboxSyncResponse, + MailboxUnreadCountsResponse, MailboxUpdateRequest, ) 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) async def get_mailbox( mailbox_id: uuid.UUID, @@ -200,3 +248,52 @@ async def test_mailbox_connection( tenant_id=current_user.tenant_id, 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}", + ) diff --git a/backend/app/schemas/mailbox.py b/backend/app/schemas/mailbox.py index 7ada16f..a4d1400 100644 --- a/backend/app/schemas/mailbox.py +++ b/backend/app/schemas/mailbox.py @@ -116,3 +116,15 @@ class ConnectionTestResult(BaseModel): message: str latency_ms: float | None = None 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] diff --git a/frontend/src/api/mailboxes.api.ts b/frontend/src/api/mailboxes.api.ts index 08d5bfd..bd3fcda 100644 --- a/frontend/src/api/mailboxes.api.ts +++ b/frontend/src/api/mailboxes.api.ts @@ -28,4 +28,16 @@ export const mailboxesApi = { apiClient .post(`/mailboxes/${id}/test-connection`, { protocol }) .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 }>('/mailboxes/unread-counts') + .then((r) => r.data.counts), } diff --git a/frontend/src/components/Layout/Sidebar.tsx b/frontend/src/components/Layout/Sidebar.tsx index 221f74f..968b617 100644 --- a/frontend/src/components/Layout/Sidebar.tsx +++ b/frontend/src/components/Layout/Sidebar.tsx @@ -86,6 +86,14 @@ export function Sidebar() { }) 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 const { data: myVboxes = [] } = useQuery({ queryKey: ['virtual-boxes', 'my'], @@ -312,6 +320,7 @@ export function Sidebar() { collapsed={collapsed} isExpanded={isMailboxExpanded(mailbox.id)} onToggle={() => toggleMailbox(mailbox.id)} + unreadCount={unreadCounts[mailbox.id] ?? 0} /> ))} @@ -536,6 +545,7 @@ interface MailboxNavItemProps { collapsed: boolean isExpanded: boolean onToggle: () => void + unreadCount?: number } /** 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 initial = displayName[0]?.toUpperCase() ?? '?' 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', ) } - title={displayName} + title={`${displayName}${unreadCount > 0 ? ` (${unreadCount} non letti)` : ''}`} >
@@ -584,6 +594,11 @@ function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle }: MailboxNav dotClass, )} /> + {unreadCount > 0 && ( + + {unreadCount > 9 ? '9+' : unreadCount} + + )}
) @@ -615,6 +630,13 @@ function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle }: MailboxNav {displayName} + {/* Badge non letti per casella */} + {unreadCount > 0 && ( + + {unreadCount > 99 ? '99+' : unreadCount} + + )} + {/* Chevron espandi/comprimi */} (null) // Corpo HTML (gestito da TipTap) const [bodyHtml, setBodyHtml] = useState(() => { - if (!replyTo) return '' - 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 (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 '' }) // Allegati @@ -80,12 +102,16 @@ export function ComposePage() { formState: { errors }, } = useForm({ defaultValues: { - mailbox_id: replyTo?.mailbox_id || '', + mailbox_id: replyTo?.mailbox_id || forwardOf?.mailbox_id || '', to_addresses: replyTo ? [{ value: replyTo.from_address || '' }] : [{ value: '' }], cc_addresses: [], - subject: replyTo ? `Re: ${replyTo.subject || ''}` : '', + subject: replyTo + ? `Re: ${replyTo.subject || ''}` + : forwardOf + ? `Fwd: ${forwardOf.subject || ''}` + : '', }, }) @@ -214,13 +240,18 @@ export function ComposePage() {

- {replyTo ? 'Rispondi a PEC' : 'Nuova PEC'} + {replyTo ? 'Rispondi a PEC' : forwardOf ? 'Inoltra PEC' : 'Nuova PEC'}

{replyTo && (

In risposta a: {replyTo.subject}

)} + {forwardOf && ( +

+ Inoltro di: {forwardOf.subject} +

+ )}
@@ -239,6 +270,17 @@ export function ComposePage() {

+ {/* Avviso allegati messaggio inoltrato */} + {forwardOf && forwardOf.has_attachments && ( +
+ +

+ Il messaggio originale contiene allegati. Per includerli nell'inoltro, + scaricali dal messaggio originale e aggiungili qui manualmente. +

+
+ )} + {/* Casella mittente */}
diff --git a/frontend/src/pages/Inbox/InboxPage.tsx b/frontend/src/pages/Inbox/InboxPage.tsx index 4dfcb36..6eeeda7 100644 --- a/frontend/src/pages/Inbox/InboxPage.tsx +++ b/frontend/src/pages/Inbox/InboxPage.tsx @@ -88,8 +88,9 @@ export function InboxPage({ viewMode }: InboxPageProps) { const [dateFrom, setDateFrom] = useState('') const [dateTo, setDateTo] = 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 ───────────────────────────────────────────────────────── const [selectedIds, setSelectedIds] = useState>(new Set()) @@ -105,6 +106,7 @@ export function InboxPage({ viewMode }: InboxPageProps) { setDateFrom('') setDateTo('') setPecTypeFilter('') + setPecStateFilter('') setShowAdvancedFilters(false) setPage(1) setSelectedIds(new Set()) @@ -152,6 +154,7 @@ export function InboxPage({ viewMode }: InboxPageProps) { mailbox_id: mailboxId, search: debouncedSearch || undefined, pec_type: pecTypeFilter || undefined, + state: pecStateFilter || undefined, date_from: dateFrom ? new Date(dateFrom).toISOString() : undefined, date_to: dateTo ? new Date(dateTo + 'T23:59:59').toISOString() : undefined, page, @@ -584,17 +587,35 @@ export function InboxPage({ viewMode }: InboxPageProps) {
+ {/* Stato PEC */} +
+ + +
+ {/* Reset filtri */} {activeAdvancedFiltersCount > 0 && ( -
+
)} diff --git a/frontend/src/pages/Mailboxes/MailboxesPage.tsx b/frontend/src/pages/Mailboxes/MailboxesPage.tsx index 2eb04ed..74d64d8 100644 --- a/frontend/src/pages/Mailboxes/MailboxesPage.tsx +++ b/frontend/src/pages/Mailboxes/MailboxesPage.tsx @@ -49,6 +49,7 @@ export function MailboxesPage() { const [editingMailbox, setEditingMailbox] = useState(null) const [testingId, setTestingId] = useState(null) const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null) + const [syncingId, setSyncingId] = useState(null) const { data: mailboxesData, isLoading } = useQuery({ 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) => { if (!confirm(`Eliminare la casella ${mailbox.email_address}? L'operazione è irreversibile.`)) return deleteMutation.mutate(mailbox.id) @@ -181,6 +199,19 @@ export function MailboxesPage() { Test + {/* Forza sincronizzazione */} + + {/* Modifica */} + )} + {/* Scarica pacchetto completo ZIP (sempre visibile) */}