This commit is contained in:
2026-03-25 17:49:13 +01:00
parent c3ef6465d6
commit 03be5d0e32
13 changed files with 458 additions and 98 deletions
+1
View File
@@ -161,3 +161,4 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear # 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. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
KnowledgeBaseCline.md
+1 -1
View File
@@ -30,7 +30,7 @@ Porta: 465
SSL: Sì 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 Tutto il frontend deve essere in italiano
@@ -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')
+36 -10
View File
@@ -4,7 +4,8 @@ Router messaggi PEC.
Fornisce: Fornisce:
- GET /messages lista messaggi con filtri (inbox/sent/search/...) - GET /messages lista messaggi con filtri (inbox/sent/search/...)
- GET /messages/{id} singolo messaggio - 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 lista allegati
- GET /messages/{id}/attachments/{att_id}/download scarica allegato da MinIO - GET /messages/{id}/attachments/{att_id}/download scarica allegato da MinIO
- GET /messages/{id}/receipts ricevute (messaggi figlio) - 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": elif field == "from_address":
col = Message.from_address col = Message.from_address
elif field == "to_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, ",") arr_text = func.array_to_string(Message.to_addresses, ",")
if operator == "contains": if operator == "contains":
return q.where(arr_text.ilike(f"%{value}%")) return q.where(arr_text.ilike(f"%{value}%"))
@@ -94,7 +95,7 @@ async def _get_visible_mailbox_ids(
) -> Optional[list[uuid.UUID]]: ) -> Optional[list[uuid.UUID]]:
""" """
Per utenti non-admin restituisce la lista di mailbox_id accessibili. 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: if user.is_admin:
return None # nessun filtro per admin return None # nessun filtro per admin
@@ -111,10 +112,10 @@ async def _resolve_message(
) -> Message: ) -> Message:
"""Carica il messaggio e verifica i permessi di accesso. """Carica il messaggio e verifica i permessi di accesso.
L'accesso è consentito se: L'accesso e consentito se:
1. L'utente è admin del tenant, oppure 1. L'utente e admin del tenant, oppure
2. L'utente ha un permesso diretto can_read sulla casella, 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( result = await db.execute(
select(Message) select(Message)
@@ -182,6 +183,7 @@ async def list_messages(
is_read: Optional[bool] = Query(None), is_read: Optional[bool] = Query(None),
is_starred: Optional[bool] = Query(None), is_starred: Optional[bool] = Query(None),
is_archived: Optional[bool] = Query(False), is_archived: Optional[bool] = Query(False),
is_trashed: Optional[bool] = Query(False),
search: Optional[str] = Query(None, max_length=200), search: Optional[str] = Query(None, max_length=200),
pec_type: Optional[str] = Query(None), pec_type: Optional[str] = Query(None),
# Paginazione # Paginazione
@@ -192,6 +194,7 @@ async def list_messages(
Elenca i messaggi PEC con filtri opzionali. Elenca i messaggi PEC con filtri opzionali.
- `is_archived=False` (default) esclude i messaggi archiviati. - `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. - `search` cerca su subject, from_address, to_addresses.
- `vbox_id` filtra per Virtual Box assegnata all'utente corrente. - `vbox_id` filtra per Virtual Box assegnata all'utente corrente.
""" """
@@ -278,6 +281,9 @@ async def list_messages(
if is_archived is not None: if is_archived is not None:
q = q.where(Message.is_archived == is_archived) q = q.where(Message.is_archived == is_archived)
if is_trashed is not None:
q = q.where(Message.is_trashed == is_trashed)
if search: if search:
term = f"%{search}%" term = f"%{search}%"
q = q.where( q = q.where(
@@ -325,7 +331,7 @@ async def bulk_update_messages(
db: DB, db: DB,
) -> MessageBulkUpdateResponse: ) -> 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. Restituisce il numero di messaggi aggiornati e la lista aggiornata.
I messaggi non trovati o non accessibili vengono silenziosamente ignorati. I messaggi non trovati o non accessibili vengono silenziosamente ignorati.
@@ -352,6 +358,8 @@ async def bulk_update_messages(
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
for message in messages: for message in messages:
if data.is_read is not None:
message.is_read = data.is_read
if data.is_starred is not None: if data.is_starred is not None:
message.is_starred = data.is_starred message.is_starred = data.is_starred
if data.is_archived is not None: if data.is_archived is not None:
@@ -360,6 +368,12 @@ async def bulk_update_messages(
message.archived_at = now message.archived_at = now
elif not data.is_archived: elif not data.is_archived:
message.archived_at = None 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() await db.commit()
@@ -399,7 +413,7 @@ async def update_message(
) -> MessageResponse: ) -> MessageResponse:
""" """
Aggiorna i flag operativi di un messaggio: 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) message = await _resolve_message(message_id, current_user, db)
@@ -413,6 +427,12 @@ async def update_message(
message.archived_at = datetime.now(timezone.utc) message.archived_at = datetime.now(timezone.utc)
elif not data.is_archived: elif not data.is_archived:
message.archived_at = None 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() await db.commit()
# Re-query con selectinload per evitare MissingGreenlet sui labels # Re-query con selectinload per evitare MissingGreenlet sui labels
@@ -422,7 +442,13 @@ async def update_message(
.options(selectinload(Message.labels)) .options(selectinload(Message.labels))
) )
message = refreshed.scalar_one() 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, db: DB,
) -> list[AttachmentResponse]: ) -> list[AttachmentResponse]:
"""Elenca gli allegati di un messaggio.""" """Elenca gli allegati di un messaggio."""
@@ -473,7 +499,7 @@ async def download_attachment(
secure=settings.minio_use_ssl, 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 storage_path = attachment.storage_path
response = await client.get_object(settings.minio_bucket, storage_path) response = await client.get_object(settings.minio_bucket, storage_path)
+2
View File
@@ -91,6 +91,8 @@ class Message(Base):
is_starred: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_starred: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_archived: 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) 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) raw_eml_path: Mapped[str | None] = mapped_column(Text, nullable=True)
+5
View File
@@ -49,6 +49,8 @@ class MessageResponse(BaseModel):
is_starred: bool = False is_starred: bool = False
is_archived: bool = False is_archived: bool = False
archived_at: Optional[datetime] = None archived_at: Optional[datetime] = None
is_trashed: bool = False
trashed_at: Optional[datetime] = None
raw_eml_path: Optional[str] = None raw_eml_path: Optional[str] = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -84,12 +86,15 @@ class MessageUpdateRequest(BaseModel):
is_read: Optional[bool] = None is_read: Optional[bool] = None
is_starred: Optional[bool] = None is_starred: Optional[bool] = None
is_archived: Optional[bool] = None is_archived: Optional[bool] = None
is_trashed: Optional[bool] = None
class MessageBulkUpdateRequest(BaseModel): class MessageBulkUpdateRequest(BaseModel):
ids: list[uuid.UUID] ids: list[uuid.UUID]
is_read: Optional[bool] = None
is_starred: Optional[bool] = None is_starred: Optional[bool] = None
is_archived: Optional[bool] = None is_archived: Optional[bool] = None
is_trashed: Optional[bool] = None
class MessageBulkUpdateResponse(BaseModel): class MessageBulkUpdateResponse(BaseModel):
+1 -1
View File
@@ -1,4 +1,4 @@
is""" """
Servizio Notifiche Multi-canale CRUD canali, regole, log. Servizio Notifiche Multi-canale CRUD canali, regole, log.
Nota: la cifratura AES-256-GCM di config_enc avviene qui usando Nota: la cifratura AES-256-GCM di config_enc avviene qui usando
+2
View File
@@ -48,12 +48,14 @@ export default function App() {
<Route path="/sent" element={<InboxPage viewMode="sent" />} /> <Route path="/sent" element={<InboxPage viewMode="sent" />} />
<Route path="/starred" element={<InboxPage viewMode="starred" />} /> <Route path="/starred" element={<InboxPage viewMode="starred" />} />
<Route path="/archived" element={<InboxPage viewMode="archived" />} /> <Route path="/archived" element={<InboxPage viewMode="archived" />} />
<Route path="/trash" element={<InboxPage viewMode="trash" />} />
{/* Vista per singola casella PEC */} {/* Vista per singola casella PEC */}
<Route path="/mailbox/:mailboxId/inbox" element={<InboxPage viewMode="inbox" />} /> <Route path="/mailbox/:mailboxId/inbox" element={<InboxPage viewMode="inbox" />} />
<Route path="/mailbox/:mailboxId/sent" element={<InboxPage viewMode="sent" />} /> <Route path="/mailbox/:mailboxId/sent" element={<InboxPage viewMode="sent" />} />
<Route path="/mailbox/:mailboxId/starred" element={<InboxPage viewMode="starred" />} /> <Route path="/mailbox/:mailboxId/starred" element={<InboxPage viewMode="starred" />} />
<Route path="/mailbox/:mailboxId/archived" element={<InboxPage viewMode="archived" />} /> <Route path="/mailbox/:mailboxId/archived" element={<InboxPage viewMode="archived" />} />
<Route path="/mailbox/:mailboxId/trash" element={<InboxPage viewMode="trash" />} />
{/* Vista per Virtual Box assegnata */} {/* Vista per Virtual Box assegnata */}
<Route path="/virtual-box/:vboxId/inbox" element={<InboxPage viewMode="inbox" />} /> <Route path="/virtual-box/:vboxId/inbox" element={<InboxPage viewMode="inbox" />} />
+14 -1
View File
@@ -16,13 +16,16 @@ export interface MessageFilters {
is_read?: boolean is_read?: boolean
is_starred?: boolean is_starred?: boolean
is_archived?: boolean is_archived?: boolean
is_trashed?: boolean
search?: string search?: string
} }
export interface MessageBulkUpdatePayload { export interface MessageBulkUpdatePayload {
ids: string[] ids: string[]
is_read?: boolean
is_starred?: boolean is_starred?: boolean
is_archived?: boolean is_archived?: boolean
is_trashed?: boolean
} }
export interface MessageBulkUpdateResponse { export interface MessageBulkUpdateResponse {
@@ -60,7 +63,17 @@ export const messagesApi = {
.patch<MessageResponse>(`/messages/${id}`, { is_archived: false }) .patch<MessageResponse>(`/messages/${id}`, { is_archived: false })
.then((r) => r.data), .then((r) => r.data),
/** Aggiorna in blocco is_starred e/o is_archived su più messaggi */ trash: (id: string) =>
apiClient
.patch<MessageResponse>(`/messages/${id}`, { is_trashed: true })
.then((r) => r.data),
untrash: (id: string) =>
apiClient
.patch<MessageResponse>(`/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) => bulkUpdate: (payload: MessageBulkUpdatePayload) =>
apiClient apiClient
.patch<MessageBulkUpdateResponse>('/messages/bulk', payload) .patch<MessageBulkUpdateResponse>('/messages/bulk', payload)
@@ -49,6 +49,7 @@ import {
Star, Star,
Archive, Archive,
Building2, Building2,
Trash2,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useAuth } from '@/hooks/useAuth' import { useAuth } from '@/hooks/useAuth'
@@ -266,6 +267,25 @@ export function Sidebar() {
<Archive className="h-4 w-4 flex-shrink-0" /> <Archive className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Archiviati</span>} {!collapsed && <span>Archiviati</span>}
</NavLink> </NavLink>
{/* Cestino globale */}
<NavLink
to="/trash"
end
className={({ isActive }) =>
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}
>
<Trash2 className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Cestino</span>}
</NavLink>
</div> </div>
</div> </div>
@@ -625,6 +645,21 @@ function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle }: MailboxNav
<Archive className="h-3.5 w-3.5 flex-shrink-0" /> <Archive className="h-3.5 w-3.5 flex-shrink-0" />
<span>Archiviati</span> <span>Archiviati</span>
</NavLink> </NavLink>
<NavLink
to={`/mailbox/${mailbox.id}/trash`}
className={({ isActive }) =>
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',
)
}
>
<Trash2 className="h-3.5 w-3.5 flex-shrink-0" />
<span>Cestino</span>
</NavLink>
</div> </div>
)} )}
</div> </div>
+213 -64
View File
@@ -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): * Modalita' (viewMode):
* - 'inbox' → Posta in Arrivo (solo inbound, is_archived=false) * - 'inbox' → Posta in Arrivo (solo inbound, is_archived=false, is_trashed=false)
* - 'sent' → Posta Inviata (solo outbound, is_archived=false) * - 'sent' → Posta Inviata (solo outbound, is_archived=false, is_trashed=false)
* - 'starred' → Preferiti (tutte le direzioni, is_starred=true) * - 'starred' → Preferiti (is_starred=true, is_trashed=false)
* - 'archived' → Archiviati (tutte le direzioni, is_archived=true) * - 'archived' → Archiviati (is_archived=true, is_trashed=false)
* - 'trash' → Cestino (is_trashed=true)
* *
* Funzionalità: * Funzionalita':
* - Selezione singola e multipla tramite checkbox * - Selezione singola e multipla tramite checkbox
* - Barra azioni bulk (stella, rimuovi stella, archivia, rimuovi archivio, assegna tag) * - Barra azioni bulk (stella, archivia, cestino, segna da leggere, tag)
* - Pulsanti azione rapida su hover di ogni riga (stella, archivia) * - Pulsanti azione rapida su hover di ogni riga
* - Badge tag colorati per ogni messaggio * - "Segna come da leggere" e "Sposta nel cestino" solo fuori dalle Virtual Box
*/ */
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
@@ -32,6 +33,9 @@ import {
Square, Square,
X, X,
Tag, Tag,
Trash2,
RotateCcw,
MailX,
} from 'lucide-react' } from 'lucide-react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
@@ -51,10 +55,9 @@ import { getErrorMessage } from '@/api/client'
// ─── Props ──────────────────────────────────────────────────────────────────── // ─── Props ────────────────────────────────────────────────────────────────────
export type InboxViewMode = 'inbox' | 'sent' | 'starred' | 'archived' export type InboxViewMode = 'inbox' | 'sent' | 'starred' | 'archived' | 'trash'
interface InboxPageProps { interface InboxPageProps {
/** Modalità vista */
viewMode: InboxViewMode viewMode: InboxViewMode
} }
@@ -64,10 +67,11 @@ export function InboxPage({ viewMode }: InboxPageProps) {
const navigate = useNavigate() const navigate = useNavigate()
const queryClient = useQueryClient() 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 }>() const { mailboxId, vboxId } = useParams<{ mailboxId?: string; vboxId?: string }>()
// true = stiamo navigando in una casella reale (non virtual box)
const isMailboxMode = !vboxId
// ── Stato filtri locale ────────────────────────────────────────────────────── // ── Stato filtri locale ──────────────────────────────────────────────────────
const [searchInput, setSearchInput] = useState('') const [searchInput, setSearchInput] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('') const [debouncedSearch, setDebouncedSearch] = useState('')
@@ -82,7 +86,6 @@ export function InboxPage({ viewMode }: InboxPageProps) {
// ── Stato dialog tag bulk ──────────────────────────────────────────────────── // ── Stato dialog tag bulk ────────────────────────────────────────────────────
const [showTagSelector, setShowTagSelector] = useState(false) const [showTagSelector, setShowTagSelector] = useState(false)
// Resetta lo stato UI ogni volta che si cambia casella, virtual box o cartella
useEffect(() => { useEffect(() => {
setSearchInput('') setSearchInput('')
setDebouncedSearch('') setDebouncedSearch('')
@@ -92,7 +95,6 @@ export function InboxPage({ viewMode }: InboxPageProps) {
setSelectedIds(new Set()) setSelectedIds(new Set())
}, [mailboxId, vboxId, viewMode]) }, [mailboxId, vboxId, viewMode])
// Debounce della ricerca
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setDebouncedSearch(searchInput) setDebouncedSearch(searchInput)
@@ -112,7 +114,6 @@ export function InboxPage({ viewMode }: InboxPageProps) {
? mailboxesData?.items.find((m) => m.id === mailboxId) ? mailboxesData?.items.find((m) => m.id === mailboxId)
: undefined : undefined
// ── Virtual Box corrente (per breadcrumb) ────────────────────────────────────
const { data: myVboxes = [] } = useQuery({ const { data: myVboxes = [] } = useQuery({
queryKey: ['virtual-boxes', 'my'], queryKey: ['virtual-boxes', 'my'],
queryFn: () => virtualBoxesApi.myVirtualBoxes(), queryFn: () => virtualBoxesApi.myVirtualBoxes(),
@@ -141,6 +142,7 @@ export function InboxPage({ viewMode }: InboxPageProps) {
is_read: isReadFilter, is_read: isReadFilter,
is_starred: isStarredFilter, is_starred: isStarredFilter,
is_archived: false, is_archived: false,
is_trashed: false,
} }
case 'sent': case 'sent':
return { return {
@@ -148,17 +150,25 @@ export function InboxPage({ viewMode }: InboxPageProps) {
direction: 'outbound' as const, direction: 'outbound' as const,
is_starred: isStarredFilter, is_starred: isStarredFilter,
is_archived: false, is_archived: false,
is_trashed: false,
} }
case 'starred': case 'starred':
return { return {
...base, ...base,
is_starred: true, is_starred: true,
is_archived: false, is_archived: false,
is_trashed: false,
} }
case 'archived': case 'archived':
return { return {
...base, ...base,
is_archived: true, 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 total = messagesData?.total || 0
const totalPages = Math.ceil(total / PAGE_SIZE) const totalPages = Math.ceil(total / PAGE_SIZE)
// ── Invalida query messaggi dopo operazioni ──────────────────────────────────
const invalidateMessages = useCallback(() => { const invalidateMessages = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['messages'] }) queryClient.invalidateQueries({ queryKey: ['messages'] })
}, [queryClient]) }, [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 ──────────────────────────────────────────────────── // ── Toggle stella singolo ────────────────────────────────────────────────────
const toggleStarMutation = useMutation({ const toggleStarMutation = useMutation({
mutationFn: ({ id, starred }: { id: string; starred: boolean }) => mutationFn: ({ id, starred }: { id: string; starred: boolean }) =>
@@ -209,7 +237,6 @@ export function InboxPage({ viewMode }: InboxPageProps) {
['messages', queryFilters], ['messages', queryFilters],
(old: { items: MessageResponse[]; total: number } | undefined) => { (old: { items: MessageResponse[]; total: number } | undefined) => {
if (!old) return old if (!old) return old
// In vista "starred" rimuoviamo il messaggio se è stato rimosso dai preferiti
if (viewMode === 'starred' && !updatedMsg.is_starred) { if (viewMode === 'starred' && !updatedMsg.is_starred) {
return { ...old, items: old.items.filter((m) => m.id !== updatedMsg.id), total: old.total - 1 } 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 }) => mutationFn: ({ id, archived }: { id: string; archived: boolean }) =>
archived ? messagesApi.archive(id) : messagesApi.unarchive(id), archived ? messagesApi.archive(id) : messagesApi.unarchive(id),
onSuccess: (updatedMsg, { archived }) => { onSuccess: (updatedMsg, { archived }) => {
// Rimuove il messaggio dalla lista corrente (ha cambiato "stanza")
queryClient.setQueryData( queryClient.setQueryData(
['messages', queryFilters], ['messages', queryFilters],
(old: { items: MessageResponse[]; total: number } | undefined) => { (old: { items: MessageResponse[]; total: number } | undefined) => {
@@ -240,6 +266,24 @@ export function InboxPage({ viewMode }: InboxPageProps) {
onError: (error) => toast.error(getErrorMessage(error)), 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 ────────────────────────────────────────────────────────────── // ── Azioni bulk ──────────────────────────────────────────────────────────────
const bulkMutation = useMutation({ const bulkMutation = useMutation({
mutationFn: messagesApi.bulkUpdate, mutationFn: messagesApi.bulkUpdate,
@@ -247,10 +291,13 @@ export function InboxPage({ viewMode }: InboxPageProps) {
invalidateMessages() invalidateMessages()
setSelectedIds(new Set()) setSelectedIds(new Set())
const n = result.updated 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_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 === 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_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)), 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 = () => const handleBulkStar = () =>
bulkMutation.mutate({ ids: Array.from(selectedIds), is_starred: true }) bulkMutation.mutate({ ids: Array.from(selectedIds), is_starred: true })
const handleBulkUnstar = () => const handleBulkUnstar = () =>
@@ -288,6 +337,10 @@ export function InboxPage({ viewMode }: InboxPageProps) {
bulkMutation.mutate({ ids: Array.from(selectedIds), is_archived: true }) bulkMutation.mutate({ ids: Array.from(selectedIds), is_archived: true })
const handleBulkUnarchive = () => const handleBulkUnarchive = () =>
bulkMutation.mutate({ ids: Array.from(selectedIds), is_archived: false }) 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 ──────────────────────────────────────────────────────────────── // ── Selezione ────────────────────────────────────────────────────────────────
const handleToggleSelect = (id: string, e: React.MouseEvent) => { const handleToggleSelect = (id: string, e: React.MouseEvent) => {
@@ -336,7 +389,9 @@ export function InboxPage({ viewMode }: InboxPageProps) {
? 'Posta Inviata' ? 'Posta Inviata'
: viewMode === 'starred' : viewMode === 'starred'
? 'Preferiti' ? 'Preferiti'
: 'Archiviati' : viewMode === 'archived'
? 'Archiviati'
: 'Cestino'
const FolderIcon = const FolderIcon =
viewMode === 'inbox' viewMode === 'inbox'
? Inbox ? Inbox
@@ -344,7 +399,9 @@ export function InboxPage({ viewMode }: InboxPageProps) {
? Send ? Send
: viewMode === 'starred' : viewMode === 'starred'
? Star ? Star
: Archive : viewMode === 'archived'
? Archive
: Trash2
const selectedCount = selectedIds.size const selectedCount = selectedIds.size
const allSelected = messages.length > 0 && selectedCount === messages.length const allSelected = messages.length > 0 && selectedCount === messages.length
@@ -396,10 +453,12 @@ export function InboxPage({ viewMode }: InboxPageProps) {
<RefreshCw className="h-4 w-4 mr-1" /> <RefreshCw className="h-4 w-4 mr-1" />
Aggiorna Aggiorna
</Button> </Button>
<Button size="sm" onClick={() => navigate('/compose')}> {viewMode !== 'trash' && (
<Send className="h-4 w-4 mr-1" /> <Button size="sm" onClick={() => navigate('/compose')}>
Nuova PEC <Send className="h-4 w-4 mr-1" />
</Button> Nuova PEC
</Button>
)}
</div> </div>
</div> </div>
@@ -443,10 +502,9 @@ export function InboxPage({ viewMode }: InboxPageProps) {
</div> </div>
</div> </div>
{/* ── Barra azioni bulk (appare quando ci sono selezioni) ── */} {/* ── Barra azioni bulk ── */}
{someSelected && ( {someSelected && (
<div className="border-b bg-blue-50 dark:bg-blue-950/30 px-6 py-2.5 flex items-center gap-3 flex-wrap"> <div className="border-b bg-blue-50 dark:bg-blue-950/30 px-6 py-2.5 flex items-center gap-3 flex-wrap">
{/* Contatore + deseleziona */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={handleClearSelection} onClick={handleClearSelection}
@@ -462,8 +520,22 @@ export function InboxPage({ viewMode }: InboxPageProps) {
<div className="h-4 w-px bg-blue-200 dark:bg-blue-700" /> <div className="h-4 w-px bg-blue-200 dark:bg-blue-700" />
{/* Azioni: variano in base alla vista */} {/* Segna come da leggere (solo in modalita' casella, non vbox) */}
{viewMode !== 'starred' && ( {isMailboxMode && viewMode !== 'trash' && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs border-blue-200 hover:bg-blue-100 dark:border-blue-700"
onClick={handleBulkMarkUnread}
isLoading={bulkMutation.isPending}
>
<MailX className="h-3.5 w-3.5 mr-1" />
Segna da leggere
</Button>
)}
{/* Stella */}
{viewMode !== 'starred' && viewMode !== 'trash' && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -502,7 +574,7 @@ export function InboxPage({ viewMode }: InboxPageProps) {
</Button> </Button>
)} )}
{viewMode !== 'archived' && ( {viewMode !== 'archived' && viewMode !== 'trash' && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -528,16 +600,45 @@ export function InboxPage({ viewMode }: InboxPageProps) {
</Button> </Button>
)} )}
{/* Assegna tag bulk */} {/* Cestino / Ripristina cestino (solo modalita' casella) */}
<Button {isMailboxMode && viewMode !== 'trash' && (
variant="outline" <Button
size="sm" variant="outline"
className="h-8 text-xs border-blue-200 hover:bg-blue-100 dark:border-blue-700" size="sm"
onClick={() => setShowTagSelector(true)} className="h-8 text-xs border-red-200 hover:bg-red-50 dark:border-red-800 text-red-600"
> onClick={handleBulkTrash}
<Tag className="h-3.5 w-3.5 mr-1 text-primary" /> isLoading={bulkMutation.isPending}
Tag >
</Button> <Trash2 className="h-3.5 w-3.5 mr-1" />
Cestino
</Button>
)}
{isMailboxMode && viewMode === 'trash' && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs border-blue-200 hover:bg-blue-100 dark:border-blue-700"
onClick={handleBulkUntrash}
isLoading={bulkMutation.isPending}
>
<RotateCcw className="h-3.5 w-3.5 mr-1" />
Ripristina dal cestino
</Button>
)}
{/* Tag */}
{viewMode !== 'trash' && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs border-blue-200 hover:bg-blue-100 dark:border-blue-700"
onClick={() => setShowTagSelector(true)}
>
<Tag className="h-3.5 w-3.5 mr-1 text-primary" />
Tag
</Button>
)}
</div> </div>
)} )}
@@ -558,17 +659,18 @@ export function InboxPage({ viewMode }: InboxPageProps) {
{debouncedSearch || isReadFilter !== undefined || isStarredFilter !== undefined {debouncedSearch || isReadFilter !== undefined || isStarredFilter !== undefined
? 'Prova a modificare i filtri di ricerca' ? 'Prova a modificare i filtri di ricerca'
: viewMode === 'inbox' : viewMode === 'inbox'
? 'La posta in arrivo è vuota' ? 'La posta in arrivo e vuota'
: viewMode === 'sent' : viewMode === 'sent'
? 'Nessun messaggio inviato' ? 'Nessun messaggio inviato'
: viewMode === 'starred' : viewMode === 'starred'
? 'Nessun messaggio nei preferiti' ? 'Nessun messaggio nei preferiti'
: 'Nessun messaggio archiviato'} : viewMode === 'archived'
? 'Nessun messaggio archiviato'
: 'Il cestino e vuoto'}
</p> </p>
</div> </div>
) : ( ) : (
<div className="divide-y"> <div className="divide-y">
{/* Riga "seleziona tutto" */}
{messages.length > 0 && ( {messages.length > 0 && (
<div className="flex items-center gap-3 px-6 py-2 bg-muted/20 border-b"> <div className="flex items-center gap-3 px-6 py-2 bg-muted/20 border-b">
<button <button
@@ -594,6 +696,7 @@ export function InboxPage({ viewMode }: InboxPageProps) {
key={message.id} key={message.id}
message={message} message={message}
viewMode={viewMode} viewMode={viewMode}
isMailboxMode={isMailboxMode}
isSelected={selectedIds.has(message.id)} isSelected={selectedIds.has(message.id)}
onSelect={(e) => handleToggleSelect(message.id, e)} onSelect={(e) => handleToggleSelect(message.id, e)}
onClick={() => handleMessageClick(message)} onClick={() => handleMessageClick(message)}
@@ -605,6 +708,14 @@ export function InboxPage({ viewMode }: InboxPageProps) {
e.stopPropagation() e.stopPropagation()
archiveMutation.mutate({ id: message.id, archived: !message.is_archived }) archiveMutation.mutate({ id: message.id, archived: !message.is_archived })
}} }}
onMarkUnread={(e) => {
e.stopPropagation()
markUnreadMutation.mutate(message.id)
}}
onToggleTrash={(e) => {
e.stopPropagation()
trashMutation.mutate({ id: message.id, trashed: !message.is_trashed })
}}
mailboxName={ mailboxName={
!mailboxId !mailboxId
? mailboxesData?.items.find((m) => m.id === message.mailbox_id)?.email_address ? mailboxesData?.items.find((m) => m.id === message.mailbox_id)?.email_address
@@ -663,23 +774,28 @@ export function InboxPage({ viewMode }: InboxPageProps) {
interface MessageRowProps { interface MessageRowProps {
message: MessageResponse message: MessageResponse
viewMode: InboxViewMode viewMode: InboxViewMode
isMailboxMode: boolean
isSelected: boolean isSelected: boolean
onSelect: (e: React.MouseEvent) => void onSelect: (e: React.MouseEvent) => void
onClick: () => void onClick: () => void
onToggleStar: (e: React.MouseEvent) => void onToggleStar: (e: React.MouseEvent) => void
onToggleArchive: (e: React.MouseEvent) => void onToggleArchive: (e: React.MouseEvent) => void
/** Presente solo nella vista globale mostra la casella di appartenenza */ onMarkUnread: (e: React.MouseEvent) => void
onToggleTrash: (e: React.MouseEvent) => void
mailboxName?: string mailboxName?: string
} }
function MessageRow({ function MessageRow({
message, message,
viewMode, viewMode,
isMailboxMode,
isSelected, isSelected,
onSelect, onSelect,
onClick, onClick,
onToggleStar, onToggleStar,
onToggleArchive, onToggleArchive,
onMarkUnread,
onToggleTrash,
mailboxName, mailboxName,
}: MessageRowProps) { }: MessageRowProps) {
const [hovered, setHovered] = useState(false) const [hovered, setHovered] = useState(false)
@@ -770,7 +886,6 @@ function MessageRow({
</p> </p>
</div> </div>
{/* Tag badges */}
{message.labels && message.labels.length > 0 && ( {message.labels && message.labels.length > 0 && (
<div className="mt-1" onClick={(e) => e.stopPropagation()}> <div className="mt-1" onClick={(e) => e.stopPropagation()}>
<TagBadgeList labels={message.labels} maxVisible={4} size="sm" /> <TagBadgeList labels={message.labels} maxVisible={4} size="sm" />
@@ -784,9 +899,9 @@ function MessageRow({
)} )}
</div> </div>
{/* ── Azioni rapide (visibili su hover) + indicatori fissi ── */} {/* ── Azioni rapide (visibili su hover) ── */}
<div className="flex items-center gap-1 flex-shrink-0"> <div className="flex items-center gap-1 flex-shrink-0">
{/* Pulsante stella (rapido, su hover o se stellata) */} {/* Stella */}
<button <button
onClick={onToggleStar} onClick={onToggleStar}
title={message.is_starred ? 'Rimuovi dai preferiti' : 'Aggiungi ai preferiti'} title={message.is_starred ? 'Rimuovi dai preferiti' : 'Aggiungi ai preferiti'}
@@ -807,21 +922,55 @@ function MessageRow({
/> />
</button> </button>
{/* Pulsante archivia/ripristina (rapido, su hover) */} {/* Segna come da leggere (solo in modalita' casella, solo messaggi gia' letti) */}
<button {isMailboxMode && message.is_read && viewMode !== 'trash' && (
onClick={onToggleArchive} <button
title={viewMode === 'archived' ? 'Ripristina dalla posta' : 'Archivia'} onClick={onMarkUnread}
className={cn( title="Segna come da leggere"
'p-1 rounded hover:bg-muted transition-all', className={cn(
hovered ? 'opacity-100' : 'opacity-0 pointer-events-none', 'p-1 rounded hover:bg-muted transition-all',
)} hovered ? 'opacity-100' : 'opacity-0 pointer-events-none',
> )}
{viewMode === 'archived' ? ( >
<ArchiveX className="h-4 w-4 text-muted-foreground" /> <MailX className="h-4 w-4 text-muted-foreground" />
) : ( </button>
<Archive className="h-4 w-4 text-muted-foreground" /> )}
)}
</button> {/* Archivia/Ripristina (non nel cestino) */}
{viewMode !== 'trash' && (
<button
onClick={onToggleArchive}
title={viewMode === 'archived' ? 'Ripristina dalla posta' : 'Archivia'}
className={cn(
'p-1 rounded hover:bg-muted transition-all',
hovered ? 'opacity-100' : 'opacity-0 pointer-events-none',
)}
>
{viewMode === 'archived' ? (
<ArchiveX className="h-4 w-4 text-muted-foreground" />
) : (
<Archive className="h-4 w-4 text-muted-foreground" />
)}
</button>
)}
{/* Cestino / Ripristina dal cestino (solo in modalita' casella) */}
{isMailboxMode && (
<button
onClick={onToggleTrash}
title={viewMode === 'trash' ? 'Ripristina dal cestino' : 'Sposta nel cestino'}
className={cn(
'p-1 rounded hover:bg-muted transition-all',
hovered ? 'opacity-100' : 'opacity-0 pointer-events-none',
)}
>
{viewMode === 'trash' ? (
<RotateCcw className="h-4 w-4 text-muted-foreground" />
) : (
<Trash2 className="h-4 w-4 text-muted-foreground" />
)}
</button>
)}
{/* Indicatore allegati */} {/* Indicatore allegati */}
{message.has_attachments && ( {message.has_attachments && (
@@ -12,6 +12,9 @@ import {
Mail, Mail,
Send, Send,
Tag, Tag,
Trash2,
RotateCcw,
MailX,
} from 'lucide-react' } from 'lucide-react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
@@ -29,7 +32,6 @@ export function MessageDetailPage() {
const navigate = useNavigate() const navigate = useNavigate()
const queryClient = useQueryClient() const queryClient = useQueryClient()
// Dialog tag
const [showTagSelector, setShowTagSelector] = useState(false) const [showTagSelector, setShowTagSelector] = useState(false)
// Carica messaggio // Carica messaggio
@@ -62,7 +64,6 @@ export function MessageDetailPage() {
mutationFn: (starred: boolean) => messagesApi.toggleStar(id!, starred), mutationFn: (starred: boolean) => messagesApi.toggleStar(id!, starred),
onSuccess: (updated) => { onSuccess: (updated) => {
queryClient.setQueryData(['message', id], updated) queryClient.setQueryData(['message', id], updated)
// Invalida le query messaggi per aggiornare le viste Preferiti
queryClient.invalidateQueries({ queryKey: ['messages'] }) queryClient.invalidateQueries({ queryKey: ['messages'] })
toast.success(updated.is_starred ? 'Aggiunto ai preferiti' : 'Rimosso dai preferiti') toast.success(updated.is_starred ? 'Aggiunto ai preferiti' : 'Rimosso dai preferiti')
}, },
@@ -80,15 +81,6 @@ export function MessageDetailPage() {
onError: (error) => toast.error(getErrorMessage(error)), 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 // Ripristina dall'archivio
const unarchiveMutation = useMutation({ const unarchiveMutation = useMutation({
mutationFn: () => messagesApi.unarchive(id!), mutationFn: () => messagesApi.unarchive(id!),
@@ -100,17 +92,59 @@ export function MessageDetailPage() {
onError: (error) => toast.error(getErrorMessage(error)), 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 // Imposta tag del messaggio
const setLabelsMutation = useMutation({ const setLabelsMutation = useMutation({
mutationFn: (labelIds: string[]) => mutationFn: (labelIds: string[]) =>
labelsApi.setMessageLabels(id!, { label_ids: labelIds }), labelsApi.setMessageLabels(id!, { label_ids: labelIds }),
onSuccess: (updatedLabels) => { onSuccess: (updatedLabels) => {
// Aggiorna la cache del messaggio con i nuovi label
queryClient.setQueryData(['message', id], (old: typeof message) => { queryClient.setQueryData(['message', id], (old: typeof message) => {
if (!old) return old if (!old) return old
return { ...old, labels: updatedLabels } return { ...old, labels: updatedLabels }
}) })
// Invalida la lista messaggi per aggiornare i badge nella inbox
queryClient.invalidateQueries({ queryKey: ['messages'] }) queryClient.invalidateQueries({ queryKey: ['messages'] })
setShowTagSelector(false) setShowTagSelector(false)
toast.success('Tag aggiornati') toast.success('Tag aggiornati')
@@ -118,7 +152,7 @@ export function MessageDetailPage() {
onError: (error) => toast.error(getErrorMessage(error)), onError: (error) => toast.error(getErrorMessage(error)),
}) })
// Rimuove singolo tag (click su × nel badge) // Rimuove singolo tag
const removeLabelMutation = useMutation({ const removeLabelMutation = useMutation({
mutationFn: (labelId: string) => mutationFn: (labelId: string) =>
labelsApi.removeMessageLabels(id!, { label_ids: [labelId] }), labelsApi.removeMessageLabels(id!, { label_ids: [labelId] }),
@@ -197,8 +231,21 @@ export function MessageDetailPage() {
/> />
</Button> </Button>
{/* Archivia (se non ancora archiviato) */} {/* Segna come da leggere (solo se gia' letto) */}
{!message.is_archived && ( {message.is_read && !message.is_trashed && (
<Button
variant="ghost"
size="icon"
onClick={() => markUnreadMutation.mutate()}
title="Segna come da leggere"
isLoading={markUnreadMutation.isPending}
>
<MailX className="h-5 w-5 text-muted-foreground" />
</Button>
)}
{/* Archivia (se non ancora archiviato e non nel cestino) */}
{!message.is_archived && !message.is_trashed && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -211,7 +258,7 @@ export function MessageDetailPage() {
)} )}
{/* Ripristina dall'archivio (se archiviato) */} {/* Ripristina dall'archivio (se archiviato) */}
{message.is_archived && ( {message.is_archived && !message.is_trashed && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -224,8 +271,35 @@ export function MessageDetailPage() {
</Button> </Button>
)} )}
{/* Rispondi (solo per messaggi inbound PEC certificata) */} {/* Sposta nel cestino (se non gia' nel cestino) */}
{message.direction === 'inbound' && message.pec_type === 'posta_certificata' && ( {!message.is_trashed && (
<Button
variant="ghost"
size="icon"
onClick={() => trashMutation.mutate()}
title="Sposta nel cestino"
isLoading={trashMutation.isPending}
>
<Trash2 className="h-5 w-5 text-muted-foreground" />
</Button>
)}
{/* Ripristina dal cestino (se nel cestino) */}
{message.is_trashed && (
<Button
variant="outline"
size="sm"
onClick={() => untrashMutation.mutate()}
title="Ripristina dal cestino"
isLoading={untrashMutation.isPending}
>
<RotateCcw className="h-4 w-4 mr-1" />
Ripristina
</Button>
)}
{/* Rispondi (solo per messaggi inbound PEC certificata, non nel cestino) */}
{message.direction === 'inbound' && message.pec_type === 'posta_certificata' && !message.is_trashed && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -242,8 +316,28 @@ export function MessageDetailPage() {
</div> </div>
</div> </div>
{/* Banner "Nel Cestino" */}
{message.is_trashed && (
<div className="bg-red-50 border-b border-red-200 px-6 py-2.5 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-red-700">
<Trash2 className="h-4 w-4" />
<span>Questo messaggio si trova nel cestino.</span>
</div>
<Button
variant="outline"
size="sm"
className="h-7 text-xs border-red-300 hover:bg-red-100 text-red-700"
onClick={() => untrashMutation.mutate()}
isLoading={untrashMutation.isPending}
>
<RotateCcw className="h-3.5 w-3.5 mr-1" />
Ripristina
</Button>
</div>
)}
{/* Banner "Archiviato" */} {/* Banner "Archiviato" */}
{message.is_archived && ( {message.is_archived && !message.is_trashed && (
<div className="bg-amber-50 border-b border-amber-200 px-6 py-2.5 flex items-center justify-between"> <div className="bg-amber-50 border-b border-amber-200 px-6 py-2.5 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-amber-700"> <div className="flex items-center gap-2 text-sm text-amber-700">
<Archive className="h-4 w-4" /> <Archive className="h-4 w-4" />
@@ -421,7 +515,7 @@ export function MessageDetailPage() {
<div className="flex items-center gap-2 text-sm text-blue-700"> <div className="flex items-center gap-2 text-sm text-blue-700">
<Mail className="h-4 w-4" /> <Mail className="h-4 w-4" />
<span> <span>
Questo è un messaggio automatico di tipo{' '} Questo e un messaggio automatico di tipo{' '}
<strong> <strong>
<PecTypeBadge type={message.pec_type} /> <PecTypeBadge type={message.pec_type} />
</strong> </strong>
+2
View File
@@ -260,6 +260,8 @@ export interface MessageResponse {
is_starred: boolean is_starred: boolean
is_archived: boolean is_archived: boolean
archived_at: string | null archived_at: string | null
is_trashed: boolean
trashed_at: string | null
raw_eml_path: string | null raw_eml_path: string | null
created_at: string created_at: string
updated_at: string updated_at: string