From 047990811f69fd6dabb386f4667ce67d2be7b034 Mon Sep 17 00:00:00 2001 From: idrainformatica Date: Fri, 27 Mar 2026 16:54:49 +0100 Subject: [PATCH] Conservazionee --- .../alembic/versions/0009_add_conservation.py | 95 ++++++++++ backend/app/api/v1/messages.py | 174 +++++++++--------- backend/app/models/message.py | 6 + backend/app/models/permission.py | 1 + backend/app/schemas/message.py | 8 + backend/app/schemas/permission.py | 4 + backend/app/services/permission_service.py | 25 +++ frontend/src/App.tsx | 24 ++- frontend/src/api/messages.api.ts | 18 ++ frontend/src/components/Layout/Sidebar.tsx | 84 +++++++++ frontend/src/pages/Inbox/InboxPage.tsx | 138 ++++++++++++-- frontend/src/types/api.types.ts | 7 + 12 files changed, 466 insertions(+), 118 deletions(-) create mode 100644 backend/alembic/versions/0009_add_conservation.py diff --git a/backend/alembic/versions/0009_add_conservation.py b/backend/alembic/versions/0009_add_conservation.py new file mode 100644 index 0000000..484b7c7 --- /dev/null +++ b/backend/alembic/versions/0009_add_conservation.py @@ -0,0 +1,95 @@ +"""add conservation fields to messages and mailbox_permissions + +Revision ID: 0009 +Revises: 0008 +Create Date: 2026-03-27 + +Aggiunge: + - messages.is_pending_conservation BOOLEAN NOT NULL DEFAULT FALSE + - messages.pending_conservation_at TIMESTAMP WITH TIME ZONE + - messages.is_conserved BOOLEAN NOT NULL DEFAULT FALSE + - messages.conserved_at TIMESTAMP WITH TIME ZONE + - mailbox_permissions.can_conserve BOOLEAN NOT NULL DEFAULT FALSE +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers +revision = "0009" +down_revision = "0008" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Nuovi campi sulla tabella messages + op.add_column( + "messages", + sa.Column( + "is_pending_conservation", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + ) + op.add_column( + "messages", + sa.Column( + "pending_conservation_at", + sa.DateTime(timezone=True), + nullable=True, + ), + ) + op.add_column( + "messages", + sa.Column( + "is_conserved", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + ) + op.add_column( + "messages", + sa.Column( + "conserved_at", + sa.DateTime(timezone=True), + nullable=True, + ), + ) + + # Indici parziali per query sulle cartelle Conservazione + op.create_index( + "idx_messages_pending_conservation", + "messages", + ["tenant_id"], + postgresql_where=sa.text("is_pending_conservation = true"), + ) + op.create_index( + "idx_messages_conserved", + "messages", + ["tenant_id"], + postgresql_where=sa.text("is_conserved = true"), + ) + + # Permesso can_conserve sulla tabella mailbox_permissions + op.add_column( + "mailbox_permissions", + sa.Column( + "can_conserve", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + ) + + +def downgrade() -> None: + op.drop_column("mailbox_permissions", "can_conserve") + op.drop_index("idx_messages_conserved", table_name="messages") + op.drop_index("idx_messages_pending_conservation", table_name="messages") + op.drop_column("messages", "conserved_at") + op.drop_column("messages", "is_conserved") + op.drop_column("messages", "pending_conservation_at") + op.drop_column("messages", "is_pending_conservation") diff --git a/backend/app/api/v1/messages.py b/backend/app/api/v1/messages.py index 027f9aa..24cfa1f 100644 --- a/backend/app/api/v1/messages.py +++ b/backend/app/api/v1/messages.py @@ -2,18 +2,21 @@ 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, 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) + - 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, is_trashed, + is_pending_conservation, is_conserved) + - 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) Permessi: - Admin: accede a tutti i messaggi del proprio tenant. - Operator/Supervisor/Readonly: solo i messaggi delle caselle su cui hanno almeno il permesso can_read. + - is_pending_conservation / is_conserved: richiedono can_conserve + (implicito per admin, esplicito per supervisor/operator). """ import uuid @@ -61,7 +64,6 @@ 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 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}%")) @@ -77,7 +79,7 @@ def _apply_vbox_rule(q, field: str, operator: str, value: str): elif field == "imap_folder": col = Message.imap_folder else: - return q # campo non supportato – ignorato + return q if operator == "contains": return q.where(col.ilike(f"%{value}%")) @@ -98,12 +100,9 @@ async def _get_visible_mailbox_ids( """ Per utenti non-admin/supervisor restituisce la lista di mailbox_id accessibili. Restituisce None se l'utente e' admin o supervisor (accesso illimitato al tenant). - - Admin e supervisor: None (nessun filtro, query diretta sull'intero tenant). - Operator e readonly: lista esplicita di caselle con can_read=True. """ if user.is_supervisor_or_admin: - return None # nessun filtro per admin e supervisor + return None from app.services.permission_service import PermissionService perm_svc = PermissionService(db) @@ -115,13 +114,7 @@ async def _resolve_message( current_user, db: AsyncSession, ) -> Message: - """Carica il messaggio e verifica i permessi di accesso. - - 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 e assegnato a una Virtual Box attiva che include la casella. - """ + """Carica il messaggio e verifica i permessi di accesso.""" result = await db.execute( select(Message) .where( @@ -140,9 +133,6 @@ async def _resolve_message( has_direct_access = await perm_svc.check_can_read(current_user, message.mailbox_id) if not has_direct_access: - # Verifica accesso tramite Virtual Box: - # l'utente deve essere assegnato a una VBox attiva - # che include la casella del messaggio. from app.models.virtual_box import ( VirtualBox, VirtualBoxAssignment, @@ -189,12 +179,12 @@ async def list_messages( is_starred: Optional[bool] = Query(None), is_archived: Optional[bool] = Query(False), is_trashed: Optional[bool] = Query(False), + is_pending_conservation: Optional[bool] = Query(None, description="Filtra per messaggi in attesa di conservazione"), + is_conserved: Optional[bool] = Query(None, description="Filtra per messaggi gia' conservati"), search: Optional[str] = Query(None, max_length=500), pec_type: Optional[str] = Query(None), - # Filtri data (ISO 8601, es. 2026-01-01T00:00:00Z) date_from: Optional[datetime] = Query(None, description="Data minima (received_at o sent_at)"), date_to: Optional[datetime] = Query(None, description="Data massima (received_at o sent_at)"), - # Paginazione page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), ) -> MessageListResponse: @@ -203,11 +193,10 @@ async def list_messages( - `is_archived=False` (default) esclude i messaggi archiviati. - `is_trashed=False` (default) esclude i messaggi nel cestino. + - `is_pending_conservation` filtra messaggi da conservare (cartella Da Conservare). + - `is_conserved` filtra messaggi gia' conservati (cartella Storico). - `search` usa ricerca full-text (tsvector) con fallback ILIKE. - - `date_from` / `date_to` filtrano per data ricezione o invio. - - `vbox_id` filtra per Virtual Box assegnata all'utente corrente. """ - # Determinare le caselle visibili (normale check permessi) visible_mailbox_ids = await _get_visible_mailbox_ids(current_user, db) # ── Filtro Virtual Box ──────────────────────────────────────────────────── @@ -231,7 +220,6 @@ async def list_messages( if not vbox: raise NotFoundError("Virtual Box") - # Non-admin: verifica che l'utente sia assegnato alla VBox if not current_user.is_admin: assign_result = await db.execute( select(VirtualBoxAssignment).where( @@ -242,32 +230,23 @@ async def list_messages( if not assign_result.scalar_one_or_none(): raise ForbiddenError("Virtual Box non accessibile") - # L'assegnazione alla VBox garantisce accesso alle sue caselle: - # sovrascrive il filtro permessi normali per questa query. if vbox.mailboxes: visible_mailbox_ids = [m.id for m in vbox.mailboxes] - # Se la VBox non ha caselle esplicitamente associate, - # si mantiene il filtro permessi normale (visible_mailbox_ids invariato). vbox_rules = vbox.rules or [] - # ───────────────────────────────────────────────────────────────────────── # Query base q = select(Message).where( Message.tenant_id == current_user.tenant_id, - Message.parent_message_id.is_(None), # escludi ricevute (messaggi figlio) + Message.parent_message_id.is_(None), ) - # Filtro caselle visibili per non-admin (o dopo override VBox) if visible_mailbox_ids is not None: if not visible_mailbox_ids: - # Nessuna casella accessibile → lista vuota return MessageListResponse(items=[], total=0, page=page, page_size=page_size) q = q.where(Message.mailbox_id.in_(visible_mailbox_ids)) - # Filtri opzionali if mailbox_id is not None: - # Verifica che l'utente abbia accesso a questa casella specifica if visible_mailbox_ids is not None and mailbox_id not in visible_mailbox_ids: raise ForbiddenError("Accesso alla casella non autorizzato") q = q.where(Message.mailbox_id == mailbox_id) @@ -293,7 +272,14 @@ async def list_messages( if is_trashed is not None: q = q.where(Message.is_trashed == is_trashed) - # ── Full-text search (FTS con fallback ILIKE per messaggi non indicizzati) ─── + # ── Filtri Conservazione ────────────────────────────────────────────────── + if is_pending_conservation is not None: + q = q.where(Message.is_pending_conservation == is_pending_conservation) + + if is_conserved is not None: + q = q.where(Message.is_conserved == is_conserved) + + # ── Full-text search ────────────────────────────────────────────────────── if search: from sqlalchemy import case as sa_case @@ -302,7 +288,6 @@ async def list_messages( q = q.where( or_( Message.search_vector.op("@@")(tsquery), - # Fallback per messaggi non ancora indicizzati dal worker Message.search_vector.is_(None) & or_( Message.subject.ilike(term_like), Message.from_address.ilike(term_like), @@ -317,15 +302,12 @@ async def list_messages( if date_to: q = q.where(or_(Message.received_at <= date_to, Message.sent_at <= date_to)) - # Applica le regole della Virtual Box (AND tra le regole) for rule in vbox_rules: q = _apply_vbox_rule(q, rule.field, rule.operator, rule.value) - # Conteggio totale count_q = select(func.count()).select_from(q.subquery()) total = (await db.execute(count_q)).scalar_one() - # Ordinamento: se c'e' una ricerca, ordina per rilevanza FTS, poi data if search: from sqlalchemy import case as sa_case @@ -338,7 +320,6 @@ async def list_messages( else: order_clauses = [Message.received_at.desc().nullslast(), Message.created_at.desc()] - # Paginazione q = ( q.options(selectinload(Message.labels)) .order_by(*order_clauses) @@ -364,15 +345,13 @@ async def bulk_update_messages( db: DB, ) -> MessageBulkUpdateResponse: """ - Aggiorna in blocco i flag operativi (is_starred, is_archived, is_trashed) di piu messaggi. + Aggiorna in blocco i flag operativi di piu messaggi. - Restituisce il numero di messaggi aggiornati e la lista aggiornata. - I messaggi non trovati o non accessibili vengono silenziosamente ignorati. + Per is_pending_conservation=True o is_conserved=True richiede can_conserve. """ if not data.ids: return MessageBulkUpdateResponse(updated=0, items=[]) - # Carica tutti i messaggi del tenant result = await db.execute( select(Message).where( Message.id.in_(data.ids), @@ -381,7 +360,7 @@ async def bulk_update_messages( ) messages = list(result.scalars().all()) - # Filtra per permessi se non admin + # Filtra per permessi di lettura if not current_user.is_admin: from app.services.permission_service import PermissionService perm_svc = PermissionService(db) @@ -389,6 +368,22 @@ async def bulk_update_messages( visible_set = set(visible) if visible else set() messages = [m for m in messages if m.mailbox_id in visible_set] + # Se si tenta di modificare flag di conservazione, verifica can_conserve + conservation_change = ( + data.is_pending_conservation is not None or data.is_conserved is not None + ) + if conservation_change and messages: + from app.services.permission_service import PermissionService + perm_svc = PermissionService(db) + # Verifica per ogni casella unica coinvolta + mailbox_ids = {m.mailbox_id for m in messages} + for mb_id in mailbox_ids: + can = await perm_svc.check_can_conserve(current_user, mb_id) + if not can: + raise ForbiddenError( + "Permesso 'conservazione' non assegnato per questa casella" + ) + now = datetime.now(timezone.utc) for message in messages: if data.is_read is not None: @@ -407,10 +402,21 @@ async def bulk_update_messages( message.trashed_at = now elif not data.is_trashed: message.trashed_at = None + if data.is_pending_conservation is not None: + message.is_pending_conservation = data.is_pending_conservation + if data.is_pending_conservation and not message.pending_conservation_at: + message.pending_conservation_at = now + elif not data.is_pending_conservation: + message.pending_conservation_at = None + if data.is_conserved is not None: + message.is_conserved = data.is_conserved + if data.is_conserved and not message.conserved_at: + message.conserved_at = now + elif not data.is_conserved: + message.conserved_at = None await db.commit() - # Ricarica i messaggi aggiornati con selectinload per evitare MissingGreenlet sui labels if messages: updated_ids = [m.id for m in messages] refreshed_result = await db.execute( @@ -445,11 +451,20 @@ async def update_message( db: DB, ) -> MessageResponse: """ - Aggiorna i flag operativi di un messaggio: - is_read, is_starred, is_archived, is_trashed. + Aggiorna i flag operativi di un messaggio. + + Per is_pending_conservation=True o is_conserved=True richiede can_conserve. """ message = await _resolve_message(message_id, current_user, db) + # Verifica permesso conservazione se necessario + if data.is_pending_conservation is not None or data.is_conserved is not None: + from app.services.permission_service import PermissionService + perm_svc = PermissionService(db) + await perm_svc.require_can_conserve(current_user, message.mailbox_id) + + now = datetime.now(timezone.utc) + if data.is_read is not None: message.is_read = data.is_read if data.is_starred is not None: @@ -457,18 +472,29 @@ async def update_message( if data.is_archived is not None: message.is_archived = data.is_archived if data.is_archived and not message.archived_at: - message.archived_at = datetime.now(timezone.utc) + 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 = datetime.now(timezone.utc) + message.trashed_at = now elif not data.is_trashed: message.trashed_at = None + if data.is_pending_conservation is not None: + message.is_pending_conservation = data.is_pending_conservation + if data.is_pending_conservation and not message.pending_conservation_at: + message.pending_conservation_at = now + elif not data.is_pending_conservation: + message.pending_conservation_at = None + if data.is_conserved is not None: + message.is_conserved = data.is_conserved + if data.is_conserved and not message.conserved_at: + message.conserved_at = now + elif not data.is_conserved: + message.conserved_at = None await db.commit() - # Re-query con selectinload per evitare MissingGreenlet sui labels refreshed = await db.execute( select(Message) .where(Message.id == message_id) @@ -503,14 +529,9 @@ async def download_attachment( current_user: CurrentUser, db: DB, ) -> StreamingResponse: - """ - Scarica un allegato direttamente da MinIO. - Il file viene streamato al client con i header Content-Disposition corretti. - """ - # Verifica accesso al messaggio + """Scarica un allegato direttamente da MinIO.""" await _resolve_message(message_id, current_user, db) - # Carica allegato result = await db.execute( select(Attachment).where( Attachment.id == attachment_id, @@ -521,7 +542,6 @@ async def download_attachment( if not attachment: raise NotFoundError(f"Allegato {attachment_id} non trovato") - # Stream da MinIO try: from miniopy_async import Minio @@ -532,7 +552,6 @@ async def download_attachment( secure=settings.minio_use_ssl, ) - # storage_path e del tipo "tenant_id/attachments/filename" storage_path = attachment.storage_path response = await client.get_object(settings.minio_bucket, storage_path) @@ -565,21 +584,12 @@ async def download_package( current_user: CurrentUser, db: DB, ) -> StreamingResponse: - """ - Scarica un archivio ZIP con tutti i file originali della PEC. - - Per messaggi inbound: allegati del messaggio (postacert.eml, daticert.xml, ecc.) - e il raw EML originale. - - Per messaggi outbound: allegati del messaggio + raw EML di ogni ricevuta collegata - (accettazione, consegna, ecc.). - """ + """Scarica un archivio ZIP con tutti i file originali della PEC.""" import io import zipfile as _zipfile from miniopy_async import Minio - # Verifica accesso message = await _resolve_message(message_id, current_user, db) client = Minio( @@ -601,7 +611,6 @@ async def download_package( return b"" with _zipfile.ZipFile(buf, mode="w", compression=_zipfile.ZIP_DEFLATED) as zf: - # ── Allegati del messaggio principale ────────────────────────────── att_result = await db.execute( select(Attachment) .where(Attachment.message_id == message.id) @@ -614,20 +623,16 @@ async def download_package( if data: zf.writestr(att.filename, data) - # ── Raw EML del messaggio principale ────────────────────────────── if message.raw_eml_path: data = await _read_minio(message.raw_eml_path) if data: - # Nome file: messaggio_originale.eml oppure il basename del path eml_name = message.raw_eml_path.rsplit("/", 1)[-1] if not eml_name.endswith(".eml"): eml_name = "messaggio_originale.eml" - # Evita duplicati con gli allegati gia' inseriti existing = {info.filename for info in zf.infolist()} if eml_name not in existing: zf.writestr(eml_name, data) - # ── Ricevute (solo per outbound) ─────────────────────────────────── if message.direction == "outbound": receipts_result = await db.execute( select(Message) @@ -637,11 +642,9 @@ async def download_package( receipts = list(receipts_result.scalars().all()) for receipt in receipts: - # Tipo ricevuta come prefisso cartella pec_type = receipt.pec_type or "ricevuta" folder = f"ricevute/{pec_type}" - # Allegati della ricevuta r_att_result = await db.execute( select(Attachment) .where(Attachment.message_id == receipt.id) @@ -653,7 +656,6 @@ async def download_package( data = await _read_minio(att.storage_path) if data: zip_path = f"{folder}/{att.filename}" - # Gestisce duplicati aggiungendo un contatore existing = {info.filename for info in zf.infolist()} final_path = zip_path counter = 1 @@ -663,7 +665,6 @@ async def download_package( counter += 1 zf.writestr(final_path, data) - # Raw EML della ricevuta if receipt.raw_eml_path: data = await _read_minio(receipt.raw_eml_path) if data: @@ -678,7 +679,6 @@ async def download_package( buf.seek(0) zip_bytes = buf.getvalue() - # Nome del file ZIP basato sull'oggetto della mail safe_subject = (message.subject or "pec").replace("/", "_").replace("\\", "_")[:50] zip_filename = f"pec_{safe_subject}.zip" @@ -698,11 +698,7 @@ async def list_receipts( current_user: CurrentUser, db: DB, ) -> list[MessageResponse]: - """ - Elenca le ricevute associate a un messaggio outbound - (messaggi con parent_message_id = message_id). - """ - # Verifica accesso al messaggio padre + """Elenca le ricevute associate a un messaggio outbound.""" await _resolve_message(message_id, current_user, db) result = await db.execute( diff --git a/backend/app/models/message.py b/backend/app/models/message.py index 63506d0..b7b756e 100644 --- a/backend/app/models/message.py +++ b/backend/app/models/message.py @@ -95,6 +95,12 @@ class Message(Base): is_trashed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) trashed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + # Conservazione + is_pending_conservation: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + pending_conservation_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + is_conserved: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + conserved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + raw_eml_path: Mapped[str | None] = mapped_column(Text, nullable=True) # Full-text search vector (aggiornato da trigger DB + worker per allegati) diff --git a/backend/app/models/permission.py b/backend/app/models/permission.py index 237dac9..ce0cf3c 100644 --- a/backend/app/models/permission.py +++ b/backend/app/models/permission.py @@ -41,6 +41,7 @@ class MailboxPermission(Base): can_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) can_send: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) can_manage: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + can_conserve: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) granted_by: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id"), nullable=True diff --git a/backend/app/schemas/message.py b/backend/app/schemas/message.py index 5c4c799..f3b074a 100644 --- a/backend/app/schemas/message.py +++ b/backend/app/schemas/message.py @@ -51,6 +51,10 @@ class MessageResponse(BaseModel): archived_at: Optional[datetime] = None is_trashed: bool = False trashed_at: Optional[datetime] = None + is_pending_conservation: bool = False + pending_conservation_at: Optional[datetime] = None + is_conserved: bool = False + conserved_at: Optional[datetime] = None raw_eml_path: Optional[str] = None created_at: datetime updated_at: datetime @@ -87,6 +91,8 @@ class MessageUpdateRequest(BaseModel): is_starred: Optional[bool] = None is_archived: Optional[bool] = None is_trashed: Optional[bool] = None + is_pending_conservation: Optional[bool] = None + is_conserved: Optional[bool] = None class MessageBulkUpdateRequest(BaseModel): @@ -95,6 +101,8 @@ class MessageBulkUpdateRequest(BaseModel): is_starred: Optional[bool] = None is_archived: Optional[bool] = None is_trashed: Optional[bool] = None + is_pending_conservation: Optional[bool] = None + is_conserved: Optional[bool] = None class MessageBulkUpdateResponse(BaseModel): diff --git a/backend/app/schemas/permission.py b/backend/app/schemas/permission.py index c09cbfc..8f7b532 100644 --- a/backend/app/schemas/permission.py +++ b/backend/app/schemas/permission.py @@ -12,6 +12,7 @@ class PermissionGrantRequest(BaseModel): can_read: bool = True can_send: bool = False can_manage: bool = False + can_conserve: bool = False class PermissionResponse(BaseModel): @@ -22,6 +23,7 @@ class PermissionResponse(BaseModel): can_read: bool can_send: bool can_manage: bool + can_conserve: bool granted_by: uuid.UUID | None granted_at: datetime @@ -36,6 +38,7 @@ class UserMailboxPermissionResponse(BaseModel): can_read: bool can_send: bool can_manage: bool + can_conserve: bool class MailboxUserPermissionResponse(BaseModel): @@ -47,4 +50,5 @@ class MailboxUserPermissionResponse(BaseModel): can_read: bool can_send: bool can_manage: bool + can_conserve: bool granted_at: datetime diff --git a/backend/app/services/permission_service.py b/backend/app/services/permission_service.py index 697f69b..c458eb1 100644 --- a/backend/app/services/permission_service.py +++ b/backend/app/services/permission_service.py @@ -100,6 +100,22 @@ class PermissionService: perm = await self._get_permission(user.id, mailbox_id) return perm is not None and perm.can_manage + async def check_can_conserve( + self, user: User, mailbox_id: uuid.UUID + ) -> bool: + """Verifica se l'utente puo' spostare messaggi nella cartella Conservazione. + + Admin/super_admin: accesso implicito sempre. + Supervisor: richiede permesso esplicito can_conserve=True. + Operator/readonly: non autorizzati (richiedono permesso esplicito). + """ + if user.role in ("super_admin", "admin"): + return await self._mailbox_belongs_to_tenant(mailbox_id, user.tenant_id) + + # Supervisor, operator e readonly richiedono record esplicito + perm = await self._get_permission(user.id, mailbox_id) + return perm is not None and perm.can_conserve + async def require_can_read(self, user: User, mailbox_id: uuid.UUID) -> None: """Solleva 403 se l'utente non può leggere.""" if not await self.check_can_read(user, mailbox_id): @@ -109,6 +125,11 @@ class PermissionService: if not await self.check_can_send(user, mailbox_id): raise PermissionDeniedError("casella (invio)") + async def require_can_conserve(self, user: User, mailbox_id: uuid.UUID) -> None: + """Solleva 403 se l'utente non puo' spostare messaggi in Conservazione.""" + if not await self.check_can_conserve(user, mailbox_id): + raise PermissionDeniedError("casella (conservazione)") + # ─── CRUD permessi ──────────────────────────────────────────────────────── async def grant_permission( @@ -145,6 +166,7 @@ class PermissionService: existing.can_read = data.can_read existing.can_send = data.can_send existing.can_manage = data.can_manage + existing.can_conserve = data.can_conserve existing.granted_by = granted_by.id return existing @@ -155,6 +177,7 @@ class PermissionService: can_read=data.can_read, can_send=data.can_send, can_manage=data.can_manage, + can_conserve=data.can_conserve, granted_by=granted_by.id, ) self.db.add(perm) @@ -201,6 +224,7 @@ class PermissionService: "can_read": perm.can_read, "can_send": perm.can_send, "can_manage": perm.can_manage, + "can_conserve": perm.can_conserve, "granted_at": perm.granted_at, } for perm, user in rows @@ -228,6 +252,7 @@ class PermissionService: "can_read": perm.can_read, "can_send": perm.can_send, "can_manage": perm.can_manage, + "can_conserve": perm.can_conserve, } for perm, mailbox in rows ] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b6d67d0..4a1ab50 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -47,18 +47,22 @@ export default function App() { } /> {/* Vista globale: tutte le caselle insieme */} - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> {/* 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 b7d08be..282d9d6 100644 --- a/frontend/src/api/messages.api.ts +++ b/frontend/src/api/messages.api.ts @@ -18,6 +18,10 @@ export interface MessageFilters { is_starred?: boolean is_archived?: boolean is_trashed?: boolean + /** Filtra per messaggi in attesa di conservazione (cartella Da Conservare) */ + is_pending_conservation?: boolean + /** Filtra per messaggi gia' conservati (cartella Storico) */ + is_conserved?: boolean search?: string /** Data minima nel formato ISO 8601 (es. "2026-01-01T00:00:00Z") */ date_from?: string @@ -31,6 +35,8 @@ export interface MessageBulkUpdatePayload { is_starred?: boolean is_archived?: boolean is_trashed?: boolean + is_pending_conservation?: boolean + is_conserved?: boolean } export interface MessageBulkUpdateResponse { @@ -78,6 +84,18 @@ export const messagesApi = { .patch(`/messages/${id}`, { is_trashed: false }) .then((r) => r.data), + /** Sposta un messaggio nella cartella Da Conservare */ + conserve: (id: string) => + apiClient + .patch(`/messages/${id}`, { is_pending_conservation: true }) + .then((r) => r.data), + + /** Rimuove un messaggio dalla cartella Da Conservare */ + unconserve: (id: string) => + apiClient + .patch(`/messages/${id}`, { is_pending_conservation: 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 diff --git a/frontend/src/components/Layout/Sidebar.tsx b/frontend/src/components/Layout/Sidebar.tsx index 0ce4e2a..6040abf 100644 --- a/frontend/src/components/Layout/Sidebar.tsx +++ b/frontend/src/components/Layout/Sidebar.tsx @@ -53,6 +53,7 @@ import { Search, BarChart2, ClipboardList, + ShieldCheck, } from 'lucide-react' import { cn } from '@/lib/utils' import { useAuth } from '@/hooks/useAuth' @@ -297,6 +298,53 @@ export function Sidebar() { {!collapsed && Cestino} + + {/* ── Sezione Conservazione (admin e supervisor) ── */} + {(isAdmin || isSupervisor) && ( + <> + {!collapsed && ( +
+

+ Conservazione +

+
+ )} + + cn( + 'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors', + isActive + ? 'bg-teal-700 text-white' + : 'text-teal-300 hover:bg-teal-900/40 hover:text-white', + collapsed && 'justify-center px-2', + ) + } + title={collapsed ? 'Da Conservare' : undefined} + > + + {!collapsed && Da Conservare} + + + cn( + 'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors', + isActive + ? 'bg-teal-700 text-white' + : 'text-teal-300 hover:bg-teal-900/40 hover:text-white', + collapsed && 'justify-center px-2', + ) + } + title={collapsed ? 'Storico Conservazione' : undefined} + > + + {!collapsed && Storico} + + + )} @@ -604,6 +652,8 @@ function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle, unreadCount const displayName = mailbox.display_name || mailbox.email_address const initial = displayName[0]?.toUpperCase() ?? '?' const dotClass = statusDot(mailbox.status) + const { isAdmin, isSupervisor } = useAuth() + const canSeeConservation = isAdmin || isSupervisor /* ── Modalità compressa: solo avatar/iniziale → link diretto all'inbox ── */ if (collapsed) { @@ -759,6 +809,40 @@ function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle, unreadCount Cestino + + {/* Conservazione (solo admin/supervisor) */} + {canSeeConservation && ( + <> + + cn( + 'flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-medium transition-colors', + isActive + ? 'bg-teal-700 text-white' + : 'text-teal-400 hover:bg-teal-900/40 hover:text-white', + ) + } + > + + Da Conservare + + + cn( + 'flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-medium transition-colors', + isActive + ? 'bg-teal-700 text-white' + : 'text-teal-400 hover:bg-teal-900/40 hover:text-white', + ) + } + > + + Storico + + + )} )} diff --git a/frontend/src/pages/Inbox/InboxPage.tsx b/frontend/src/pages/Inbox/InboxPage.tsx index 6eeeda7..0b03a74 100644 --- a/frontend/src/pages/Inbox/InboxPage.tsx +++ b/frontend/src/pages/Inbox/InboxPage.tsx @@ -39,6 +39,8 @@ import { SlidersHorizontal, ChevronDown, ChevronUp, + ShieldCheck, + ShieldX, } from 'lucide-react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import toast from 'react-hot-toast' @@ -47,6 +49,7 @@ import { Input } from '@/components/ui/Input' import { PecStateBadge } from '@/components/PecBadge/PecBadge' import { TagBadgeList } from '@/components/TagManager/TagBadge' import { TagSelector } from '@/components/TagManager/TagSelector' +import { useAuth } from '@/hooks/useAuth' import { messagesApi } from '@/api/messages.api' import { labelsApi } from '@/api/labels.api' import { mailboxesApi } from '@/api/mailboxes.api' @@ -58,7 +61,7 @@ import { getErrorMessage } from '@/api/client' // ─── Props ──────────────────────────────────────────────────────────────────── -export type InboxViewMode = 'inbox' | 'sent' | 'starred' | 'archived' | 'trash' +export type InboxViewMode = 'inbox' | 'sent' | 'starred' | 'archived' | 'trash' | 'conservation_pending' | 'conservation_archived' interface InboxPageProps { viewMode: InboxViewMode @@ -75,6 +78,10 @@ export function InboxPage({ viewMode }: InboxPageProps) { // true = stiamo navigando in una casella reale (non virtual box) const isMailboxMode = !vboxId + // ── Ruolo utente per visibilita' pulsante Conservazione ───────────────────── + const { isAdmin, isSupervisor } = useAuth() + const canConserve = isAdmin || isSupervisor + // ── Stato filtri locale ────────────────────────────────────────────────────── const [searchInput, setSearchInput] = useState('') const [debouncedSearch, setDebouncedSearch] = useState('') @@ -196,6 +203,16 @@ export function InboxPage({ viewMode }: InboxPageProps) { ...advancedBase, is_trashed: true, } + case 'conservation_pending': + return { + ...advancedBase, + is_pending_conservation: true, + } + case 'conservation_archived': + return { + ...advancedBase, + is_conserved: true, + } } })() @@ -310,6 +327,24 @@ export function InboxPage({ viewMode }: InboxPageProps) { onError: (error) => toast.error(getErrorMessage(error)), }) + // ── Conservazione/Rimozione singolo ─────────────────────────────────────────── + const conserveMutation = useMutation({ + mutationFn: ({ id, conserve }: { id: string; conserve: boolean }) => + conserve ? messagesApi.conserve(id) : messagesApi.unconserve(id), + onSuccess: (updatedMsg, { conserve }) => { + 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(conserve ? 'Messaggio inviato in Da Conservare' : 'Messaggio rimosso da Da Conservare') + invalidateMessages() + }, + onError: (error) => toast.error(getErrorMessage(error)), + }) + // ── Azioni bulk ────────────────────────────────────────────────────────────── const bulkMutation = useMutation({ mutationFn: messagesApi.bulkUpdate, @@ -324,6 +359,8 @@ export function InboxPage({ viewMode }: InboxPageProps) { 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'}`) + else if (payload.is_pending_conservation === true) toast.success(`${n} ${n === 1 ? 'messaggio inviato' : 'messaggi inviati'} in Da Conservare`) + else if (payload.is_pending_conservation === false) toast.success(`${n} ${n === 1 ? 'messaggio rimosso' : 'messaggi rimossi'} da Da Conservare`) }, onError: (error) => toast.error(getErrorMessage(error)), }) @@ -367,6 +404,10 @@ export function InboxPage({ viewMode }: InboxPageProps) { bulkMutation.mutate({ ids: Array.from(selectedIds), is_trashed: true }) const handleBulkUntrash = () => bulkMutation.mutate({ ids: Array.from(selectedIds), is_trashed: false }) + const handleBulkConserve = () => + bulkMutation.mutate({ ids: Array.from(selectedIds), is_pending_conservation: true }) + const handleBulkUnconserve = () => + bulkMutation.mutate({ ids: Array.from(selectedIds), is_pending_conservation: false }) // ── Selezione ──────────────────────────────────────────────────────────────── const handleToggleSelect = (id: string, e: React.MouseEvent) => { @@ -409,25 +450,20 @@ export function InboxPage({ viewMode }: InboxPageProps) { // ── Label e icone folder ──────────────────────────────────────────────────── const isInbound = viewMode === 'inbox' const folderLabel = - viewMode === 'inbox' - ? 'Posta in Arrivo' - : viewMode === 'sent' - ? 'Posta Inviata' - : viewMode === 'starred' - ? 'Preferiti' - : viewMode === 'archived' - ? 'Archiviati' - : 'Cestino' + viewMode === 'inbox' ? 'Posta in Arrivo' + : viewMode === 'sent' ? 'Posta Inviata' + : viewMode === 'starred' ? 'Preferiti' + : viewMode === 'archived' ? 'Archiviati' + : viewMode === 'trash' ? 'Cestino' + : viewMode === 'conservation_pending' ? 'Da Conservare' + : 'Storico Conservazione' const FolderIcon = - viewMode === 'inbox' - ? Inbox - : viewMode === 'sent' - ? Send - : viewMode === 'starred' - ? Star - : viewMode === 'archived' - ? Archive - : Trash2 + viewMode === 'inbox' ? Inbox + : viewMode === 'sent' ? Send + : viewMode === 'starred' ? Star + : viewMode === 'archived' ? Archive + : viewMode === 'trash' ? Trash2 + : ShieldCheck const selectedCount = selectedIds.size const allSelected = messages.length > 0 && selectedCount === messages.length @@ -760,6 +796,34 @@ export function InboxPage({ viewMode }: InboxPageProps) { Tag )} + + {/* Invia a Conservazione (solo admin/supervisor, non nelle viste conservazione) */} + {canConserve && viewMode !== 'conservation_pending' && viewMode !== 'conservation_archived' && viewMode !== 'trash' && ( + + )} + + {/* Rimuovi da Da Conservare */} + {canConserve && viewMode === 'conservation_pending' && ( + + )} )} @@ -818,6 +882,7 @@ export function InboxPage({ viewMode }: InboxPageProps) { message={message} viewMode={viewMode} isMailboxMode={isMailboxMode} + canConserve={canConserve} isSelected={selectedIds.has(message.id)} onSelect={(e) => handleToggleSelect(message.id, e)} onClick={() => handleMessageClick(message)} @@ -837,6 +902,10 @@ export function InboxPage({ viewMode }: InboxPageProps) { e.stopPropagation() trashMutation.mutate({ id: message.id, trashed: !message.is_trashed }) }} + onToggleConserve={(e) => { + e.stopPropagation() + conserveMutation.mutate({ id: message.id, conserve: !message.is_pending_conservation }) + }} mailboxName={ !mailboxId ? mailboxesData?.items.find((m) => m.id === message.mailbox_id)?.email_address @@ -897,12 +966,14 @@ interface MessageRowProps { viewMode: InboxViewMode isMailboxMode: boolean isSelected: boolean + canConserve: boolean onSelect: (e: React.MouseEvent) => void onClick: () => void onToggleStar: (e: React.MouseEvent) => void onToggleArchive: (e: React.MouseEvent) => void onMarkUnread: (e: React.MouseEvent) => void onToggleTrash: (e: React.MouseEvent) => void + onToggleConserve: (e: React.MouseEvent) => void mailboxName?: string } @@ -911,12 +982,14 @@ function MessageRow({ viewMode, isMailboxMode, isSelected, + canConserve, onSelect, onClick, onToggleStar, onToggleArchive, onMarkUnread, onToggleTrash, + onToggleConserve, mailboxName, }: MessageRowProps) { const [hovered, setHovered] = useState(false) @@ -1093,6 +1166,33 @@ function MessageRow({ )} + {/* Invia a Conservazione / Rimuovi da Da Conservare */} + {canConserve && viewMode !== 'trash' && viewMode !== 'conservation_archived' && ( + + )} + {/* Indicatore allegati */} {message.has_attachments && ( diff --git a/frontend/src/types/api.types.ts b/frontend/src/types/api.types.ts index 3114757..a06e14a 100644 --- a/frontend/src/types/api.types.ts +++ b/frontend/src/types/api.types.ts @@ -262,6 +262,10 @@ export interface MessageResponse { archived_at: string | null is_trashed: boolean trashed_at: string | null + is_pending_conservation: boolean + pending_conservation_at: string | null + is_conserved: boolean + conserved_at: string | null raw_eml_path: string | null created_at: string updated_at: string @@ -342,6 +346,7 @@ export interface PermissionGrantRequest { can_read?: boolean can_send?: boolean can_manage?: boolean + can_conserve?: boolean } export interface MailboxUserPermissionResponse { @@ -352,6 +357,7 @@ export interface MailboxUserPermissionResponse { can_read: boolean can_send: boolean can_manage: boolean + can_conserve: boolean granted_at: string } @@ -362,6 +368,7 @@ export interface UserMailboxPermissionResponse { can_read: boolean can_send: boolean can_manage: boolean + can_conserve: boolean } // ─── Virtual Box ──────────────────────────────────────────────────────────────