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 */}