diff --git a/.gitignore b/.gitignore index 6c4d8e0..4d26537 100644 --- a/.gitignore +++ b/.gitignore @@ -161,3 +161,4 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +KnowledgeBaseCline.md diff --git a/KnowledgeBaseCline.md b/KnowledgeBaseCline.md index 27e3624..d2662f1 100644 --- a/KnowledgeBaseCline.md +++ b/KnowledgeBaseCline.md @@ -30,7 +30,7 @@ Porta: 465 SSL: Sì -Se devi, effettua i test di invio solo al destinatario matteo1801@spidmail.it +Quando necessario, effettua i test di invio solo al destinatario matteo1801@spidmail.it Tutto il frontend deve essere in italiano diff --git a/backend/alembic/versions/0007_add_trash.py b/backend/alembic/versions/0007_add_trash.py new file mode 100644 index 0000000..d784a44 --- /dev/null +++ b/backend/alembic/versions/0007_add_trash.py @@ -0,0 +1,31 @@ +"""add is_trashed and trashed_at to messages + +Revision ID: 0007 +Revises: 0006 +Create Date: 2026-03-25 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '0007' +down_revision = '0006' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + 'messages', + sa.Column('is_trashed', sa.Boolean(), nullable=False, server_default=sa.text('false')), + ) + op.add_column( + 'messages', + sa.Column('trashed_at', sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column('messages', 'trashed_at') + op.drop_column('messages', 'is_trashed') diff --git a/backend/app/api/v1/messages.py b/backend/app/api/v1/messages.py index f2a012f..289a8e0 100644 --- a/backend/app/api/v1/messages.py +++ b/backend/app/api/v1/messages.py @@ -4,7 +4,8 @@ Router messaggi PEC. Fornisce: - GET /messages – lista messaggi con filtri (inbox/sent/search/...) - GET /messages/{id} – singolo messaggio - - PATCH /messages/{id} – aggiorna flags (is_read, is_starred, is_archived) + - PATCH /messages/{id} – aggiorna flags (is_read, is_starred, is_archived, is_trashed) + - PATCH /messages/bulk – aggiorna in blocco piu messaggi - GET /messages/{id}/attachments – lista allegati - GET /messages/{id}/attachments/{att_id}/download – scarica allegato da MinIO - GET /messages/{id}/receipts – ricevute (messaggi figlio) @@ -58,7 +59,7 @@ def _apply_vbox_rule(q, field: str, operator: str, value: str): elif field == "from_address": col = Message.from_address elif field == "to_address": - # to_addresses è ARRAY(Text) – converte in stringa per il confronto + # to_addresses e ARRAY(Text) – converte in stringa per il confronto arr_text = func.array_to_string(Message.to_addresses, ",") if operator == "contains": return q.where(arr_text.ilike(f"%{value}%")) @@ -94,7 +95,7 @@ async def _get_visible_mailbox_ids( ) -> Optional[list[uuid.UUID]]: """ Per utenti non-admin restituisce la lista di mailbox_id accessibili. - Restituisce None se l'utente è admin (accesso illimitato al tenant). + Restituisce None se l'utente e admin (accesso illimitato al tenant). """ if user.is_admin: return None # nessun filtro per admin @@ -111,10 +112,10 @@ async def _resolve_message( ) -> Message: """Carica il messaggio e verifica i permessi di accesso. - L'accesso è consentito se: - 1. L'utente è admin del tenant, oppure + L'accesso e consentito se: + 1. L'utente e admin del tenant, oppure 2. L'utente ha un permesso diretto can_read sulla casella, oppure - 3. L'utente è assegnato a una Virtual Box attiva che include la casella. + 3. L'utente e assegnato a una Virtual Box attiva che include la casella. """ result = await db.execute( select(Message) @@ -182,6 +183,7 @@ async def list_messages( is_read: Optional[bool] = Query(None), is_starred: Optional[bool] = Query(None), is_archived: Optional[bool] = Query(False), + is_trashed: Optional[bool] = Query(False), search: Optional[str] = Query(None, max_length=200), pec_type: Optional[str] = Query(None), # Paginazione @@ -192,6 +194,7 @@ async def list_messages( Elenca i messaggi PEC con filtri opzionali. - `is_archived=False` (default) esclude i messaggi archiviati. + - `is_trashed=False` (default) esclude i messaggi nel cestino. - `search` cerca su subject, from_address, to_addresses. - `vbox_id` filtra per Virtual Box assegnata all'utente corrente. """ @@ -278,6 +281,9 @@ async def list_messages( if is_archived is not None: q = q.where(Message.is_archived == is_archived) + if is_trashed is not None: + q = q.where(Message.is_trashed == is_trashed) + if search: term = f"%{search}%" q = q.where( @@ -325,7 +331,7 @@ async def bulk_update_messages( db: DB, ) -> MessageBulkUpdateResponse: """ - Aggiorna in blocco i flag operativi (is_starred, is_archived) di più messaggi. + Aggiorna in blocco i flag operativi (is_starred, is_archived, is_trashed) di piu messaggi. Restituisce il numero di messaggi aggiornati e la lista aggiornata. I messaggi non trovati o non accessibili vengono silenziosamente ignorati. @@ -352,6 +358,8 @@ async def bulk_update_messages( now = datetime.now(timezone.utc) for message in messages: + if data.is_read is not None: + message.is_read = data.is_read if data.is_starred is not None: message.is_starred = data.is_starred if data.is_archived is not None: @@ -360,6 +368,12 @@ async def bulk_update_messages( message.archived_at = now elif not data.is_archived: message.archived_at = None + if data.is_trashed is not None: + message.is_trashed = data.is_trashed + if data.is_trashed and not message.trashed_at: + message.trashed_at = now + elif not data.is_trashed: + message.trashed_at = None await db.commit() @@ -399,7 +413,7 @@ async def update_message( ) -> MessageResponse: """ Aggiorna i flag operativi di un messaggio: - is_read, is_starred, is_archived. + is_read, is_starred, is_archived, is_trashed. """ message = await _resolve_message(message_id, current_user, db) @@ -413,6 +427,12 @@ async def update_message( message.archived_at = datetime.now(timezone.utc) elif not data.is_archived: message.archived_at = None + if data.is_trashed is not None: + message.is_trashed = data.is_trashed + if data.is_trashed and not message.trashed_at: + message.trashed_at = datetime.now(timezone.utc) + elif not data.is_trashed: + message.trashed_at = None await db.commit() # Re-query con selectinload per evitare MissingGreenlet sui labels @@ -422,7 +442,13 @@ async def update_message( .options(selectinload(Message.labels)) ) message = refreshed.scalar_one() - return MessageResponse.model_validate(messa + return MessageResponse.model_validate(message) + + +@router.get("/{message_id}/attachments", response_model=list[AttachmentResponse]) +async def list_attachments( + message_id: uuid.UUID, + current_user: CurrentUser, db: DB, ) -> list[AttachmentResponse]: """Elenca gli allegati di un messaggio.""" @@ -473,7 +499,7 @@ async def download_attachment( secure=settings.minio_use_ssl, ) - # storage_path è del tipo "tenant_id/attachments/filename" + # storage_path e del tipo "tenant_id/attachments/filename" storage_path = attachment.storage_path response = await client.get_object(settings.minio_bucket, storage_path) diff --git a/backend/app/models/message.py b/backend/app/models/message.py index 3af1989..b037b01 100644 --- a/backend/app/models/message.py +++ b/backend/app/models/message.py @@ -91,6 +91,8 @@ class Message(Base): is_starred: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + is_trashed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + trashed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) raw_eml_path: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/app/schemas/message.py b/backend/app/schemas/message.py index a3a37bc..5c4c799 100644 --- a/backend/app/schemas/message.py +++ b/backend/app/schemas/message.py @@ -49,6 +49,8 @@ class MessageResponse(BaseModel): is_starred: bool = False is_archived: bool = False archived_at: Optional[datetime] = None + is_trashed: bool = False + trashed_at: Optional[datetime] = None raw_eml_path: Optional[str] = None created_at: datetime updated_at: datetime @@ -84,12 +86,15 @@ class MessageUpdateRequest(BaseModel): is_read: Optional[bool] = None is_starred: Optional[bool] = None is_archived: Optional[bool] = None + is_trashed: Optional[bool] = None class MessageBulkUpdateRequest(BaseModel): ids: list[uuid.UUID] + is_read: Optional[bool] = None is_starred: Optional[bool] = None is_archived: Optional[bool] = None + is_trashed: Optional[bool] = None class MessageBulkUpdateResponse(BaseModel): diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index cdee678..699f397 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -1,4 +1,4 @@ -is""" +""" Servizio Notifiche Multi-canale – CRUD canali, regole, log. Nota: la cifratura AES-256-GCM di config_enc avviene qui usando diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3d79137..eda1ddb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -48,12 +48,14 @@ export default function App() { } /> } /> } /> + } /> {/* Vista per singola casella PEC */} } /> } /> } /> } /> + } /> {/* Vista per Virtual Box assegnata */} } /> diff --git a/frontend/src/api/messages.api.ts b/frontend/src/api/messages.api.ts index 0750c8f..e30c106 100644 --- a/frontend/src/api/messages.api.ts +++ b/frontend/src/api/messages.api.ts @@ -16,13 +16,16 @@ export interface MessageFilters { is_read?: boolean is_starred?: boolean is_archived?: boolean + is_trashed?: boolean search?: string } export interface MessageBulkUpdatePayload { ids: string[] + is_read?: boolean is_starred?: boolean is_archived?: boolean + is_trashed?: boolean } export interface MessageBulkUpdateResponse { @@ -60,7 +63,17 @@ export const messagesApi = { .patch(`/messages/${id}`, { is_archived: false }) .then((r) => r.data), - /** Aggiorna in blocco is_starred e/o is_archived su più messaggi */ + trash: (id: string) => + apiClient + .patch(`/messages/${id}`, { is_trashed: true }) + .then((r) => r.data), + + untrash: (id: string) => + apiClient + .patch(`/messages/${id}`, { is_trashed: false }) + .then((r) => r.data), + + /** Aggiorna in blocco is_starred e/o is_archived e/o is_trashed su più messaggi */ bulkUpdate: (payload: MessageBulkUpdatePayload) => apiClient .patch('/messages/bulk', payload) diff --git a/frontend/src/components/Layout/Sidebar.tsx b/frontend/src/components/Layout/Sidebar.tsx index 7c11e62..827f72f 100644 --- a/frontend/src/components/Layout/Sidebar.tsx +++ b/frontend/src/components/Layout/Sidebar.tsx @@ -49,6 +49,7 @@ import { Star, Archive, Building2, + Trash2, } from 'lucide-react' import { cn } from '@/lib/utils' import { useAuth } from '@/hooks/useAuth' @@ -266,6 +267,25 @@ export function Sidebar() { {!collapsed && Archiviati} + + {/* Cestino globale */} + + cn( + 'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors', + isActive + ? 'bg-blue-600 text-white' + : 'text-gray-300 hover:bg-gray-700 hover:text-white', + collapsed && 'justify-center px-2', + ) + } + title={collapsed ? 'Cestino (tutte le caselle)' : undefined} + > + + {!collapsed && Cestino} + @@ -625,6 +645,21 @@ function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle }: MailboxNav Archiviati + + + cn( + 'flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-medium transition-colors', + isActive + ? 'bg-blue-600 text-white' + : 'text-gray-400 hover:bg-gray-700 hover:text-white', + ) + } + > + + Cestino + )} diff --git a/frontend/src/pages/Inbox/InboxPage.tsx b/frontend/src/pages/Inbox/InboxPage.tsx index 4a99c74..e28bc30 100644 --- a/frontend/src/pages/Inbox/InboxPage.tsx +++ b/frontend/src/pages/Inbox/InboxPage.tsx @@ -1,17 +1,18 @@ /** - * InboxPage – visualizza la posta in arrivo, inviata, preferiti o archiviata. + * InboxPage – visualizza la posta in arrivo, inviata, preferiti, archiviata o cestino. * - * Può operare in quattro modalità (viewMode): - * - 'inbox' → Posta in Arrivo (solo inbound, is_archived=false) - * - 'sent' → Posta Inviata (solo outbound, is_archived=false) - * - 'starred' → Preferiti (tutte le direzioni, is_starred=true) - * - 'archived' → Archiviati (tutte le direzioni, is_archived=true) + * Modalita' (viewMode): + * - 'inbox' → Posta in Arrivo (solo inbound, is_archived=false, is_trashed=false) + * - 'sent' → Posta Inviata (solo outbound, is_archived=false, is_trashed=false) + * - 'starred' → Preferiti (is_starred=true, is_trashed=false) + * - 'archived' → Archiviati (is_archived=true, is_trashed=false) + * - 'trash' → Cestino (is_trashed=true) * - * Funzionalità: + * Funzionalita': * - Selezione singola e multipla tramite checkbox - * - Barra azioni bulk (stella, rimuovi stella, archivia, rimuovi archivio, assegna tag) - * - Pulsanti azione rapida su hover di ogni riga (stella, archivia) - * - Badge tag colorati per ogni messaggio + * - Barra azioni bulk (stella, archivia, cestino, segna da leggere, tag) + * - Pulsanti azione rapida su hover di ogni riga + * - "Segna come da leggere" e "Sposta nel cestino" solo fuori dalle Virtual Box */ import { useEffect, useState, useCallback } from 'react' import { useNavigate, useParams } from 'react-router-dom' @@ -32,6 +33,9 @@ import { Square, X, Tag, + Trash2, + RotateCcw, + MailX, } from 'lucide-react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import toast from 'react-hot-toast' @@ -51,10 +55,9 @@ import { getErrorMessage } from '@/api/client' // ─── Props ──────────────────────────────────────────────────────────────────── -export type InboxViewMode = 'inbox' | 'sent' | 'starred' | 'archived' +export type InboxViewMode = 'inbox' | 'sent' | 'starred' | 'archived' | 'trash' interface InboxPageProps { - /** Modalità vista */ viewMode: InboxViewMode } @@ -64,10 +67,11 @@ export function InboxPage({ viewMode }: InboxPageProps) { const navigate = useNavigate() const queryClient = useQueryClient() - // mailboxId è presente solo nei percorsi /mailbox/:mailboxId/... - // vboxId è presente solo nei percorsi /virtual-box/:vboxId/... const { mailboxId, vboxId } = useParams<{ mailboxId?: string; vboxId?: string }>() + // true = stiamo navigando in una casella reale (non virtual box) + const isMailboxMode = !vboxId + // ── Stato filtri locale ────────────────────────────────────────────────────── const [searchInput, setSearchInput] = useState('') const [debouncedSearch, setDebouncedSearch] = useState('') @@ -82,7 +86,6 @@ export function InboxPage({ viewMode }: InboxPageProps) { // ── Stato dialog tag bulk ──────────────────────────────────────────────────── const [showTagSelector, setShowTagSelector] = useState(false) - // Resetta lo stato UI ogni volta che si cambia casella, virtual box o cartella useEffect(() => { setSearchInput('') setDebouncedSearch('') @@ -92,7 +95,6 @@ export function InboxPage({ viewMode }: InboxPageProps) { setSelectedIds(new Set()) }, [mailboxId, vboxId, viewMode]) - // Debounce della ricerca useEffect(() => { const timer = setTimeout(() => { setDebouncedSearch(searchInput) @@ -112,7 +114,6 @@ export function InboxPage({ viewMode }: InboxPageProps) { ? mailboxesData?.items.find((m) => m.id === mailboxId) : undefined - // ── Virtual Box corrente (per breadcrumb) ──────────────────────────────────── const { data: myVboxes = [] } = useQuery({ queryKey: ['virtual-boxes', 'my'], queryFn: () => virtualBoxesApi.myVirtualBoxes(), @@ -141,6 +142,7 @@ export function InboxPage({ viewMode }: InboxPageProps) { is_read: isReadFilter, is_starred: isStarredFilter, is_archived: false, + is_trashed: false, } case 'sent': return { @@ -148,17 +150,25 @@ export function InboxPage({ viewMode }: InboxPageProps) { direction: 'outbound' as const, is_starred: isStarredFilter, is_archived: false, + is_trashed: false, } case 'starred': return { ...base, is_starred: true, is_archived: false, + is_trashed: false, } case 'archived': return { ...base, is_archived: true, + is_trashed: false, + } + case 'trash': + return { + ...base, + is_trashed: true, } } })() @@ -178,7 +188,6 @@ export function InboxPage({ viewMode }: InboxPageProps) { const total = messagesData?.total || 0 const totalPages = Math.ceil(total / PAGE_SIZE) - // ── Invalida query messaggi dopo operazioni ────────────────────────────────── const invalidateMessages = useCallback(() => { queryClient.invalidateQueries({ queryKey: ['messages'] }) }, [queryClient]) @@ -200,6 +209,25 @@ export function InboxPage({ viewMode }: InboxPageProps) { }, }) + // ── Segna come DA leggere (unread) ─────────────────────────────────────────── + const markUnreadMutation = useMutation({ + mutationFn: (id: string) => messagesApi.markUnread(id), + onSuccess: (updatedMsg) => { + queryClient.setQueryData( + ['messages', queryFilters], + (old: { items: MessageResponse[]; total: number } | undefined) => { + if (!old) return old + return { + ...old, + items: old.items.map((m) => (m.id === updatedMsg.id ? updatedMsg : m)), + } + }, + ) + toast.success('Messaggio segnato come da leggere') + }, + onError: (error) => toast.error(getErrorMessage(error)), + }) + // ── Toggle stella singolo ──────────────────────────────────────────────────── const toggleStarMutation = useMutation({ mutationFn: ({ id, starred }: { id: string; starred: boolean }) => @@ -209,7 +237,6 @@ export function InboxPage({ viewMode }: InboxPageProps) { ['messages', queryFilters], (old: { items: MessageResponse[]; total: number } | undefined) => { if (!old) return old - // In vista "starred" rimuoviamo il messaggio se è stato rimosso dai preferiti if (viewMode === 'starred' && !updatedMsg.is_starred) { return { ...old, items: old.items.filter((m) => m.id !== updatedMsg.id), total: old.total - 1 } } @@ -226,7 +253,6 @@ export function InboxPage({ viewMode }: InboxPageProps) { mutationFn: ({ id, archived }: { id: string; archived: boolean }) => archived ? messagesApi.archive(id) : messagesApi.unarchive(id), onSuccess: (updatedMsg, { archived }) => { - // Rimuove il messaggio dalla lista corrente (ha cambiato "stanza") queryClient.setQueryData( ['messages', queryFilters], (old: { items: MessageResponse[]; total: number } | undefined) => { @@ -240,6 +266,24 @@ export function InboxPage({ viewMode }: InboxPageProps) { onError: (error) => toast.error(getErrorMessage(error)), }) + // ── Cestino/Ripristina singolo ──────────────────────────────────────────────── + const trashMutation = useMutation({ + mutationFn: ({ id, trashed }: { id: string; trashed: boolean }) => + trashed ? messagesApi.trash(id) : messagesApi.untrash(id), + onSuccess: (updatedMsg, { trashed }) => { + queryClient.setQueryData( + ['messages', queryFilters], + (old: { items: MessageResponse[]; total: number } | undefined) => { + if (!old) return old + return { ...old, items: old.items.filter((m) => m.id !== updatedMsg.id), total: old.total - 1 } + }, + ) + toast.success(trashed ? 'Messaggio spostato nel cestino' : 'Messaggio ripristinato dal cestino') + invalidateMessages() + }, + onError: (error) => toast.error(getErrorMessage(error)), + }) + // ── Azioni bulk ────────────────────────────────────────────────────────────── const bulkMutation = useMutation({ mutationFn: messagesApi.bulkUpdate, @@ -247,10 +291,13 @@ export function InboxPage({ viewMode }: InboxPageProps) { invalidateMessages() setSelectedIds(new Set()) const n = result.updated - if (payload.is_starred === true) toast.success(`${n} ${n === 1 ? 'messaggio aggiunto' : 'messaggi aggiunti'} ai preferiti`) + if (payload.is_read === false) toast.success(`${n} ${n === 1 ? 'messaggio segnato' : 'messaggi segnati'} come da leggere`) + else if (payload.is_starred === true) toast.success(`${n} ${n === 1 ? 'messaggio aggiunto' : 'messaggi aggiunti'} ai preferiti`) else if (payload.is_starred === false) toast.success(`${n} ${n === 1 ? 'messaggio rimosso' : 'messaggi rimossi'} dai preferiti`) else if (payload.is_archived === true) toast.success(`${n} ${n === 1 ? 'messaggio archiviato' : 'messaggi archiviati'}`) else if (payload.is_archived === false) toast.success(`${n} ${n === 1 ? 'messaggio ripristinato' : 'messaggi ripristinati'}`) + else if (payload.is_trashed === true) toast.success(`${n} ${n === 1 ? 'messaggio spostato nel cestino' : 'messaggi spostati nel cestino'}`) + else if (payload.is_trashed === false) toast.success(`${n} ${n === 1 ? 'messaggio ripristinato dal cestino' : 'messaggi ripristinati dal cestino'}`) }, onError: (error) => toast.error(getErrorMessage(error)), }) @@ -280,6 +327,8 @@ export function InboxPage({ viewMode }: InboxPageProps) { }) } + const handleBulkMarkUnread = () => + bulkMutation.mutate({ ids: Array.from(selectedIds), is_read: false }) const handleBulkStar = () => bulkMutation.mutate({ ids: Array.from(selectedIds), is_starred: true }) const handleBulkUnstar = () => @@ -288,6 +337,10 @@ export function InboxPage({ viewMode }: InboxPageProps) { bulkMutation.mutate({ ids: Array.from(selectedIds), is_archived: true }) const handleBulkUnarchive = () => bulkMutation.mutate({ ids: Array.from(selectedIds), is_archived: false }) + const handleBulkTrash = () => + bulkMutation.mutate({ ids: Array.from(selectedIds), is_trashed: true }) + const handleBulkUntrash = () => + bulkMutation.mutate({ ids: Array.from(selectedIds), is_trashed: false }) // ── Selezione ──────────────────────────────────────────────────────────────── const handleToggleSelect = (id: string, e: React.MouseEvent) => { @@ -336,7 +389,9 @@ export function InboxPage({ viewMode }: InboxPageProps) { ? 'Posta Inviata' : viewMode === 'starred' ? 'Preferiti' - : 'Archiviati' + : viewMode === 'archived' + ? 'Archiviati' + : 'Cestino' const FolderIcon = viewMode === 'inbox' ? Inbox @@ -344,7 +399,9 @@ export function InboxPage({ viewMode }: InboxPageProps) { ? Send : viewMode === 'starred' ? Star - : Archive + : viewMode === 'archived' + ? Archive + : Trash2 const selectedCount = selectedIds.size const allSelected = messages.length > 0 && selectedCount === messages.length @@ -396,10 +453,12 @@ export function InboxPage({ viewMode }: InboxPageProps) { Aggiorna - + {viewMode !== 'trash' && ( + + )} @@ -443,10 +502,9 @@ export function InboxPage({ viewMode }: InboxPageProps) { - {/* ── Barra azioni bulk (appare quando ci sono selezioni) ── */} + {/* ── Barra azioni bulk ── */} {someSelected && (
- {/* Contatore + deseleziona */}
+ )} + + {/* Stella */} + {viewMode !== 'starred' && viewMode !== 'trash' && ( + {/* Cestino / Ripristina cestino (solo modalita' casella) */} + {isMailboxMode && viewMode !== 'trash' && ( + + )} + + {isMailboxMode && viewMode === 'trash' && ( + + )} + + {/* Tag */} + {viewMode !== 'trash' && ( + + )}
)} @@ -558,17 +659,18 @@ export function InboxPage({ viewMode }: InboxPageProps) { {debouncedSearch || isReadFilter !== undefined || isStarredFilter !== undefined ? 'Prova a modificare i filtri di ricerca' : viewMode === 'inbox' - ? 'La posta in arrivo è vuota' + ? 'La posta in arrivo e vuota' : viewMode === 'sent' ? 'Nessun messaggio inviato' : viewMode === 'starred' ? 'Nessun messaggio nei preferiti' - : 'Nessun messaggio archiviato'} + : viewMode === 'archived' + ? 'Nessun messaggio archiviato' + : 'Il cestino e vuoto'}

) : (
- {/* Riga "seleziona tutto" */} {messages.length > 0 && (
- {/* Tag badges */} {message.labels && message.labels.length > 0 && (
e.stopPropagation()}> @@ -784,9 +899,9 @@ function MessageRow({ )}
- {/* ── Azioni rapide (visibili su hover) + indicatori fissi ── */} + {/* ── Azioni rapide (visibili su hover) ── */}
- {/* Pulsante stella (rapido, su hover o se stellata) */} + {/* Stella */} - {/* Pulsante archivia/ripristina (rapido, su hover) */} - + {/* Segna come da leggere (solo in modalita' casella, solo messaggi gia' letti) */} + {isMailboxMode && message.is_read && viewMode !== 'trash' && ( + + )} + + {/* Archivia/Ripristina (non nel cestino) */} + {viewMode !== 'trash' && ( + + )} + + {/* Cestino / Ripristina dal cestino (solo in modalita' casella) */} + {isMailboxMode && ( + + )} {/* Indicatore allegati */} {message.has_attachments && ( diff --git a/frontend/src/pages/MessageDetail/MessageDetailPage.tsx b/frontend/src/pages/MessageDetail/MessageDetailPage.tsx index c927521..c4dfb7a 100644 --- a/frontend/src/pages/MessageDetail/MessageDetailPage.tsx +++ b/frontend/src/pages/MessageDetail/MessageDetailPage.tsx @@ -12,6 +12,9 @@ import { Mail, Send, Tag, + Trash2, + RotateCcw, + MailX, } from 'lucide-react' import toast from 'react-hot-toast' import { Button } from '@/components/ui/Button' @@ -29,7 +32,6 @@ export function MessageDetailPage() { const navigate = useNavigate() const queryClient = useQueryClient() - // Dialog tag const [showTagSelector, setShowTagSelector] = useState(false) // Carica messaggio @@ -62,7 +64,6 @@ export function MessageDetailPage() { mutationFn: (starred: boolean) => messagesApi.toggleStar(id!, starred), onSuccess: (updated) => { queryClient.setQueryData(['message', id], updated) - // Invalida le query messaggi per aggiornare le viste Preferiti queryClient.invalidateQueries({ queryKey: ['messages'] }) toast.success(updated.is_starred ? 'Aggiunto ai preferiti' : 'Rimosso dai preferiti') }, @@ -80,15 +81,6 @@ export function MessageDetailPage() { onError: (error) => toast.error(getErrorMessage(error)), }) - // Download allegato autenticato - const handleDownloadAttachment = async (att: { id: string; filename: string }) => { - try { - await messagesApi.downloadAttachment(message?.id ?? id!, att.id, att.filename) - } catch (error) { - toast.error(getErrorMessage(error)) - } - } - // Ripristina dall'archivio const unarchiveMutation = useMutation({ mutationFn: () => messagesApi.unarchive(id!), @@ -100,17 +92,59 @@ export function MessageDetailPage() { onError: (error) => toast.error(getErrorMessage(error)), }) + // Segna come da leggere + const markUnreadMutation = useMutation({ + mutationFn: () => messagesApi.markUnread(id!), + onSuccess: (updated) => { + queryClient.setQueryData(['message', id], updated) + queryClient.invalidateQueries({ queryKey: ['messages'] }) + toast.success('Messaggio segnato come da leggere') + navigate(-1) + }, + onError: (error) => toast.error(getErrorMessage(error)), + }) + + // Sposta nel cestino + const trashMutation = useMutation({ + mutationFn: () => messagesApi.trash(id!), + onSuccess: (updated) => { + queryClient.setQueryData(['message', id], updated) + queryClient.invalidateQueries({ queryKey: ['messages'] }) + toast.success('Messaggio spostato nel cestino') + navigate(-1) + }, + onError: (error) => toast.error(getErrorMessage(error)), + }) + + // Ripristina dal cestino + const untrashMutation = useMutation({ + mutationFn: () => messagesApi.untrash(id!), + onSuccess: (updated) => { + queryClient.setQueryData(['message', id], updated) + queryClient.invalidateQueries({ queryKey: ['messages'] }) + toast.success('Messaggio ripristinato dal cestino') + }, + onError: (error) => toast.error(getErrorMessage(error)), + }) + + // Download allegato autenticato + const handleDownloadAttachment = async (att: { id: string; filename: string }) => { + try { + await messagesApi.downloadAttachment(message?.id ?? id!, att.id, att.filename) + } catch (error) { + toast.error(getErrorMessage(error)) + } + } + // Imposta tag del messaggio const setLabelsMutation = useMutation({ mutationFn: (labelIds: string[]) => labelsApi.setMessageLabels(id!, { label_ids: labelIds }), onSuccess: (updatedLabels) => { - // Aggiorna la cache del messaggio con i nuovi label queryClient.setQueryData(['message', id], (old: typeof message) => { if (!old) return old return { ...old, labels: updatedLabels } }) - // Invalida la lista messaggi per aggiornare i badge nella inbox queryClient.invalidateQueries({ queryKey: ['messages'] }) setShowTagSelector(false) toast.success('Tag aggiornati') @@ -118,7 +152,7 @@ export function MessageDetailPage() { onError: (error) => toast.error(getErrorMessage(error)), }) - // Rimuove singolo tag (click su × nel badge) + // Rimuove singolo tag const removeLabelMutation = useMutation({ mutationFn: (labelId: string) => labelsApi.removeMessageLabels(id!, { label_ids: [labelId] }), @@ -197,8 +231,21 @@ export function MessageDetailPage() { /> - {/* Archivia (se non ancora archiviato) */} - {!message.is_archived && ( + {/* Segna come da leggere (solo se gia' letto) */} + {message.is_read && !message.is_trashed && ( + + )} + + {/* Archivia (se non ancora archiviato e non nel cestino) */} + {!message.is_archived && !message.is_trashed && ( + )} + + {/* Ripristina dal cestino (se nel cestino) */} + {message.is_trashed && ( + + )} + + {/* Rispondi (solo per messaggi inbound PEC certificata, non nel cestino) */} + {message.direction === 'inbound' && message.pec_type === 'posta_certificata' && !message.is_trashed && (
+ {/* Banner "Nel Cestino" */} + {message.is_trashed && ( +
+
+ + Questo messaggio si trova nel cestino. +
+ +
+ )} + {/* Banner "Archiviato" */} - {message.is_archived && ( + {message.is_archived && !message.is_trashed && (
@@ -421,7 +515,7 @@ export function MessageDetailPage() {
- Questo è un messaggio automatico di tipo{' '} + Questo e un messaggio automatico di tipo{' '} diff --git a/frontend/src/types/api.types.ts b/frontend/src/types/api.types.ts index 8ba1a63..69e8f72 100644 --- a/frontend/src/types/api.types.ts +++ b/frontend/src/types/api.types.ts @@ -260,6 +260,8 @@ export interface MessageResponse { is_starred: boolean is_archived: boolean archived_at: string | null + is_trashed: boolean + trashed_at: string | null raw_eml_path: string | null created_at: string updated_at: string