Conservazionee

This commit is contained in:
2026-03-27 16:54:49 +01:00
parent e390d344ff
commit 047990811f
12 changed files with 466 additions and 118 deletions
@@ -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")
+85 -89
View File
@@ -2,18 +2,21 @@
Router messaggi PEC. 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, is_trashed) - PATCH /messages/{id} - aggiorna flags (is_read, is_starred, is_archived, is_trashed,
- PATCH /messages/bulk aggiorna in blocco piu messaggi is_pending_conservation, is_conserved)
- GET /messages/{id}/attachments lista allegati - PATCH /messages/bulk - aggiorna in blocco piu messaggi
- GET /messages/{id}/attachments/{att_id}/download scarica allegato da MinIO - GET /messages/{id}/attachments - lista allegati
- GET /messages/{id}/receipts ricevute (messaggi figlio) - GET /messages/{id}/attachments/{att_id}/download - scarica allegato da MinIO
- GET /messages/{id}/receipts - ricevute (messaggi figlio)
Permessi: Permessi:
- Admin: accede a tutti i messaggi del proprio tenant. - Admin: accede a tutti i messaggi del proprio tenant.
- Operator/Supervisor/Readonly: solo i messaggi delle caselle su cui - Operator/Supervisor/Readonly: solo i messaggi delle caselle su cui
hanno almeno il permesso can_read. hanno almeno il permesso can_read.
- is_pending_conservation / is_conserved: richiedono can_conserve
(implicito per admin, esplicito per supervisor/operator).
""" """
import uuid import uuid
@@ -61,7 +64,6 @@ 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 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}%"))
@@ -77,7 +79,7 @@ def _apply_vbox_rule(q, field: str, operator: str, value: str):
elif field == "imap_folder": elif field == "imap_folder":
col = Message.imap_folder col = Message.imap_folder
else: else:
return q # campo non supportato ignorato return q
if operator == "contains": if operator == "contains":
return q.where(col.ilike(f"%{value}%")) 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. 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). 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: if user.is_supervisor_or_admin:
return None # nessun filtro per admin e supervisor return None
from app.services.permission_service import PermissionService from app.services.permission_service import PermissionService
perm_svc = PermissionService(db) perm_svc = PermissionService(db)
@@ -115,13 +114,7 @@ async def _resolve_message(
current_user, current_user,
db: AsyncSession, db: AsyncSession,
) -> Message: ) -> Message:
"""Carica il messaggio e verifica i permessi di accesso. """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.
"""
result = await db.execute( result = await db.execute(
select(Message) select(Message)
.where( .where(
@@ -140,9 +133,6 @@ async def _resolve_message(
has_direct_access = await perm_svc.check_can_read(current_user, message.mailbox_id) has_direct_access = await perm_svc.check_can_read(current_user, message.mailbox_id)
if not has_direct_access: 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 ( from app.models.virtual_box import (
VirtualBox, VirtualBox,
VirtualBoxAssignment, VirtualBoxAssignment,
@@ -189,12 +179,12 @@ async def list_messages(
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), 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), search: Optional[str] = Query(None, max_length=500),
pec_type: Optional[str] = Query(None), 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_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)"), date_to: Optional[datetime] = Query(None, description="Data massima (received_at o sent_at)"),
# Paginazione
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200), page_size: int = Query(50, ge=1, le=200),
) -> MessageListResponse: ) -> MessageListResponse:
@@ -203,11 +193,10 @@ async def list_messages(
- `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. - `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. - `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) visible_mailbox_ids = await _get_visible_mailbox_ids(current_user, db)
# ── Filtro Virtual Box ──────────────────────────────────────────────────── # ── Filtro Virtual Box ────────────────────────────────────────────────────
@@ -231,7 +220,6 @@ async def list_messages(
if not vbox: if not vbox:
raise NotFoundError("Virtual Box") raise NotFoundError("Virtual Box")
# Non-admin: verifica che l'utente sia assegnato alla VBox
if not current_user.is_admin: if not current_user.is_admin:
assign_result = await db.execute( assign_result = await db.execute(
select(VirtualBoxAssignment).where( select(VirtualBoxAssignment).where(
@@ -242,32 +230,23 @@ async def list_messages(
if not assign_result.scalar_one_or_none(): if not assign_result.scalar_one_or_none():
raise ForbiddenError("Virtual Box non accessibile") 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: if vbox.mailboxes:
visible_mailbox_ids = [m.id for m in 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 [] vbox_rules = vbox.rules or []
# ─────────────────────────────────────────────────────────────────────────
# Query base # Query base
q = select(Message).where( q = select(Message).where(
Message.tenant_id == current_user.tenant_id, 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 visible_mailbox_ids is not None:
if not visible_mailbox_ids: if not visible_mailbox_ids:
# Nessuna casella accessibile → lista vuota
return MessageListResponse(items=[], total=0, page=page, page_size=page_size) return MessageListResponse(items=[], total=0, page=page, page_size=page_size)
q = q.where(Message.mailbox_id.in_(visible_mailbox_ids)) q = q.where(Message.mailbox_id.in_(visible_mailbox_ids))
# Filtri opzionali
if mailbox_id is not None: 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: if visible_mailbox_ids is not None and mailbox_id not in visible_mailbox_ids:
raise ForbiddenError("Accesso alla casella non autorizzato") raise ForbiddenError("Accesso alla casella non autorizzato")
q = q.where(Message.mailbox_id == mailbox_id) q = q.where(Message.mailbox_id == mailbox_id)
@@ -293,7 +272,14 @@ async def list_messages(
if is_trashed is not None: if is_trashed is not None:
q = q.where(Message.is_trashed == is_trashed) 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: if search:
from sqlalchemy import case as sa_case from sqlalchemy import case as sa_case
@@ -302,7 +288,6 @@ async def list_messages(
q = q.where( q = q.where(
or_( or_(
Message.search_vector.op("@@")(tsquery), Message.search_vector.op("@@")(tsquery),
# Fallback per messaggi non ancora indicizzati dal worker
Message.search_vector.is_(None) & or_( Message.search_vector.is_(None) & or_(
Message.subject.ilike(term_like), Message.subject.ilike(term_like),
Message.from_address.ilike(term_like), Message.from_address.ilike(term_like),
@@ -317,15 +302,12 @@ async def list_messages(
if date_to: if date_to:
q = q.where(or_(Message.received_at <= date_to, Message.sent_at <= 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: for rule in vbox_rules:
q = _apply_vbox_rule(q, rule.field, rule.operator, rule.value) q = _apply_vbox_rule(q, rule.field, rule.operator, rule.value)
# Conteggio totale
count_q = select(func.count()).select_from(q.subquery()) count_q = select(func.count()).select_from(q.subquery())
total = (await db.execute(count_q)).scalar_one() total = (await db.execute(count_q)).scalar_one()
# Ordinamento: se c'e' una ricerca, ordina per rilevanza FTS, poi data
if search: if search:
from sqlalchemy import case as sa_case from sqlalchemy import case as sa_case
@@ -338,7 +320,6 @@ async def list_messages(
else: else:
order_clauses = [Message.received_at.desc().nullslast(), Message.created_at.desc()] order_clauses = [Message.received_at.desc().nullslast(), Message.created_at.desc()]
# Paginazione
q = ( q = (
q.options(selectinload(Message.labels)) q.options(selectinload(Message.labels))
.order_by(*order_clauses) .order_by(*order_clauses)
@@ -364,15 +345,13 @@ async def bulk_update_messages(
db: DB, db: DB,
) -> MessageBulkUpdateResponse: ) -> 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. Per is_pending_conservation=True o is_conserved=True richiede can_conserve.
I messaggi non trovati o non accessibili vengono silenziosamente ignorati.
""" """
if not data.ids: if not data.ids:
return MessageBulkUpdateResponse(updated=0, items=[]) return MessageBulkUpdateResponse(updated=0, items=[])
# Carica tutti i messaggi del tenant
result = await db.execute( result = await db.execute(
select(Message).where( select(Message).where(
Message.id.in_(data.ids), Message.id.in_(data.ids),
@@ -381,7 +360,7 @@ async def bulk_update_messages(
) )
messages = list(result.scalars().all()) messages = list(result.scalars().all())
# Filtra per permessi se non admin # Filtra per permessi di lettura
if not current_user.is_admin: if not current_user.is_admin:
from app.services.permission_service import PermissionService from app.services.permission_service import PermissionService
perm_svc = PermissionService(db) perm_svc = PermissionService(db)
@@ -389,6 +368,22 @@ async def bulk_update_messages(
visible_set = set(visible) if visible else set() visible_set = set(visible) if visible else set()
messages = [m for m in messages if m.mailbox_id in visible_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) now = datetime.now(timezone.utc)
for message in messages: for message in messages:
if data.is_read is not None: if data.is_read is not None:
@@ -407,10 +402,21 @@ async def bulk_update_messages(
message.trashed_at = now message.trashed_at = now
elif not data.is_trashed: elif not data.is_trashed:
message.trashed_at = None 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() await db.commit()
# Ricarica i messaggi aggiornati con selectinload per evitare MissingGreenlet sui labels
if messages: if messages:
updated_ids = [m.id for m in messages] updated_ids = [m.id for m in messages]
refreshed_result = await db.execute( refreshed_result = await db.execute(
@@ -445,11 +451,20 @@ async def update_message(
db: DB, db: DB,
) -> MessageResponse: ) -> MessageResponse:
""" """
Aggiorna i flag operativi di un messaggio: Aggiorna i flag operativi di un messaggio.
is_read, is_starred, is_archived, is_trashed.
Per is_pending_conservation=True o is_conserved=True richiede can_conserve.
""" """
message = await _resolve_message(message_id, current_user, db) 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: if data.is_read is not None:
message.is_read = data.is_read message.is_read = data.is_read
if data.is_starred is not None: if data.is_starred is not None:
@@ -457,18 +472,29 @@ async def update_message(
if data.is_archived is not None: if data.is_archived is not None:
message.is_archived = data.is_archived message.is_archived = data.is_archived
if data.is_archived and not message.archived_at: 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: elif not data.is_archived:
message.archived_at = None message.archived_at = None
if data.is_trashed is not None: if data.is_trashed is not None:
message.is_trashed = data.is_trashed message.is_trashed = data.is_trashed
if data.is_trashed and not message.trashed_at: 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: elif not data.is_trashed:
message.trashed_at = None 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() await db.commit()
# Re-query con selectinload per evitare MissingGreenlet sui labels
refreshed = await db.execute( refreshed = await db.execute(
select(Message) select(Message)
.where(Message.id == message_id) .where(Message.id == message_id)
@@ -503,14 +529,9 @@ async def download_attachment(
current_user: CurrentUser, current_user: CurrentUser,
db: DB, db: DB,
) -> StreamingResponse: ) -> StreamingResponse:
""" """Scarica un allegato direttamente da MinIO."""
Scarica un allegato direttamente da MinIO.
Il file viene streamato al client con i header Content-Disposition corretti.
"""
# Verifica accesso al messaggio
await _resolve_message(message_id, current_user, db) await _resolve_message(message_id, current_user, db)
# Carica allegato
result = await db.execute( result = await db.execute(
select(Attachment).where( select(Attachment).where(
Attachment.id == attachment_id, Attachment.id == attachment_id,
@@ -521,7 +542,6 @@ async def download_attachment(
if not attachment: if not attachment:
raise NotFoundError(f"Allegato {attachment_id} non trovato") raise NotFoundError(f"Allegato {attachment_id} non trovato")
# Stream da MinIO
try: try:
from miniopy_async import Minio from miniopy_async import Minio
@@ -532,7 +552,6 @@ async def download_attachment(
secure=settings.minio_use_ssl, secure=settings.minio_use_ssl,
) )
# 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)
@@ -565,21 +584,12 @@ async def download_package(
current_user: CurrentUser, current_user: CurrentUser,
db: DB, db: DB,
) -> StreamingResponse: ) -> StreamingResponse:
""" """Scarica un archivio ZIP con tutti i file originali della PEC."""
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.).
"""
import io import io
import zipfile as _zipfile import zipfile as _zipfile
from miniopy_async import Minio from miniopy_async import Minio
# Verifica accesso
message = await _resolve_message(message_id, current_user, db) message = await _resolve_message(message_id, current_user, db)
client = Minio( client = Minio(
@@ -601,7 +611,6 @@ async def download_package(
return b"" return b""
with _zipfile.ZipFile(buf, mode="w", compression=_zipfile.ZIP_DEFLATED) as zf: with _zipfile.ZipFile(buf, mode="w", compression=_zipfile.ZIP_DEFLATED) as zf:
# ── Allegati del messaggio principale ──────────────────────────────
att_result = await db.execute( att_result = await db.execute(
select(Attachment) select(Attachment)
.where(Attachment.message_id == message.id) .where(Attachment.message_id == message.id)
@@ -614,20 +623,16 @@ async def download_package(
if data: if data:
zf.writestr(att.filename, data) zf.writestr(att.filename, data)
# ── Raw EML del messaggio principale ──────────────────────────────
if message.raw_eml_path: if message.raw_eml_path:
data = await _read_minio(message.raw_eml_path) data = await _read_minio(message.raw_eml_path)
if data: if data:
# Nome file: messaggio_originale.eml oppure il basename del path
eml_name = message.raw_eml_path.rsplit("/", 1)[-1] eml_name = message.raw_eml_path.rsplit("/", 1)[-1]
if not eml_name.endswith(".eml"): if not eml_name.endswith(".eml"):
eml_name = "messaggio_originale.eml" eml_name = "messaggio_originale.eml"
# Evita duplicati con gli allegati gia' inseriti
existing = {info.filename for info in zf.infolist()} existing = {info.filename for info in zf.infolist()}
if eml_name not in existing: if eml_name not in existing:
zf.writestr(eml_name, data) zf.writestr(eml_name, data)
# ── Ricevute (solo per outbound) ───────────────────────────────────
if message.direction == "outbound": if message.direction == "outbound":
receipts_result = await db.execute( receipts_result = await db.execute(
select(Message) select(Message)
@@ -637,11 +642,9 @@ async def download_package(
receipts = list(receipts_result.scalars().all()) receipts = list(receipts_result.scalars().all())
for receipt in receipts: for receipt in receipts:
# Tipo ricevuta come prefisso cartella
pec_type = receipt.pec_type or "ricevuta" pec_type = receipt.pec_type or "ricevuta"
folder = f"ricevute/{pec_type}" folder = f"ricevute/{pec_type}"
# Allegati della ricevuta
r_att_result = await db.execute( r_att_result = await db.execute(
select(Attachment) select(Attachment)
.where(Attachment.message_id == receipt.id) .where(Attachment.message_id == receipt.id)
@@ -653,7 +656,6 @@ async def download_package(
data = await _read_minio(att.storage_path) data = await _read_minio(att.storage_path)
if data: if data:
zip_path = f"{folder}/{att.filename}" zip_path = f"{folder}/{att.filename}"
# Gestisce duplicati aggiungendo un contatore
existing = {info.filename for info in zf.infolist()} existing = {info.filename for info in zf.infolist()}
final_path = zip_path final_path = zip_path
counter = 1 counter = 1
@@ -663,7 +665,6 @@ async def download_package(
counter += 1 counter += 1
zf.writestr(final_path, data) zf.writestr(final_path, data)
# Raw EML della ricevuta
if receipt.raw_eml_path: if receipt.raw_eml_path:
data = await _read_minio(receipt.raw_eml_path) data = await _read_minio(receipt.raw_eml_path)
if data: if data:
@@ -678,7 +679,6 @@ async def download_package(
buf.seek(0) buf.seek(0)
zip_bytes = buf.getvalue() zip_bytes = buf.getvalue()
# Nome del file ZIP basato sull'oggetto della mail
safe_subject = (message.subject or "pec").replace("/", "_").replace("\\", "_")[:50] safe_subject = (message.subject or "pec").replace("/", "_").replace("\\", "_")[:50]
zip_filename = f"pec_{safe_subject}.zip" zip_filename = f"pec_{safe_subject}.zip"
@@ -698,11 +698,7 @@ async def list_receipts(
current_user: CurrentUser, current_user: CurrentUser,
db: DB, db: DB,
) -> list[MessageResponse]: ) -> list[MessageResponse]:
""" """Elenca le ricevute associate a un messaggio outbound."""
Elenca le ricevute associate a un messaggio outbound
(messaggi con parent_message_id = message_id).
"""
# Verifica accesso al messaggio padre
await _resolve_message(message_id, current_user, db) await _resolve_message(message_id, current_user, db)
result = await db.execute( result = await db.execute(
+6
View File
@@ -95,6 +95,12 @@ class Message(Base):
is_trashed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_trashed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
trashed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) 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) raw_eml_path: Mapped[str | None] = mapped_column(Text, nullable=True)
# Full-text search vector (aggiornato da trigger DB + worker per allegati) # Full-text search vector (aggiornato da trigger DB + worker per allegati)
+1
View File
@@ -41,6 +41,7 @@ class MailboxPermission(Base):
can_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) can_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
can_send: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) can_send: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
can_manage: 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( granted_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
+8
View File
@@ -51,6 +51,10 @@ class MessageResponse(BaseModel):
archived_at: Optional[datetime] = None archived_at: Optional[datetime] = None
is_trashed: bool = False is_trashed: bool = False
trashed_at: Optional[datetime] = None 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 raw_eml_path: Optional[str] = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -87,6 +91,8 @@ class MessageUpdateRequest(BaseModel):
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 is_trashed: Optional[bool] = None
is_pending_conservation: Optional[bool] = None
is_conserved: Optional[bool] = None
class MessageBulkUpdateRequest(BaseModel): class MessageBulkUpdateRequest(BaseModel):
@@ -95,6 +101,8 @@ class MessageBulkUpdateRequest(BaseModel):
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 is_trashed: Optional[bool] = None
is_pending_conservation: Optional[bool] = None
is_conserved: Optional[bool] = None
class MessageBulkUpdateResponse(BaseModel): class MessageBulkUpdateResponse(BaseModel):
+4
View File
@@ -12,6 +12,7 @@ class PermissionGrantRequest(BaseModel):
can_read: bool = True can_read: bool = True
can_send: bool = False can_send: bool = False
can_manage: bool = False can_manage: bool = False
can_conserve: bool = False
class PermissionResponse(BaseModel): class PermissionResponse(BaseModel):
@@ -22,6 +23,7 @@ class PermissionResponse(BaseModel):
can_read: bool can_read: bool
can_send: bool can_send: bool
can_manage: bool can_manage: bool
can_conserve: bool
granted_by: uuid.UUID | None granted_by: uuid.UUID | None
granted_at: datetime granted_at: datetime
@@ -36,6 +38,7 @@ class UserMailboxPermissionResponse(BaseModel):
can_read: bool can_read: bool
can_send: bool can_send: bool
can_manage: bool can_manage: bool
can_conserve: bool
class MailboxUserPermissionResponse(BaseModel): class MailboxUserPermissionResponse(BaseModel):
@@ -47,4 +50,5 @@ class MailboxUserPermissionResponse(BaseModel):
can_read: bool can_read: bool
can_send: bool can_send: bool
can_manage: bool can_manage: bool
can_conserve: bool
granted_at: datetime granted_at: datetime
@@ -100,6 +100,22 @@ class PermissionService:
perm = await self._get_permission(user.id, mailbox_id) perm = await self._get_permission(user.id, mailbox_id)
return perm is not None and perm.can_manage 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: async def require_can_read(self, user: User, mailbox_id: uuid.UUID) -> None:
"""Solleva 403 se l'utente non può leggere.""" """Solleva 403 se l'utente non può leggere."""
if not await self.check_can_read(user, mailbox_id): 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): if not await self.check_can_send(user, mailbox_id):
raise PermissionDeniedError("casella (invio)") 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 ──────────────────────────────────────────────────────── # ─── CRUD permessi ────────────────────────────────────────────────────────
async def grant_permission( async def grant_permission(
@@ -145,6 +166,7 @@ class PermissionService:
existing.can_read = data.can_read existing.can_read = data.can_read
existing.can_send = data.can_send existing.can_send = data.can_send
existing.can_manage = data.can_manage existing.can_manage = data.can_manage
existing.can_conserve = data.can_conserve
existing.granted_by = granted_by.id existing.granted_by = granted_by.id
return existing return existing
@@ -155,6 +177,7 @@ class PermissionService:
can_read=data.can_read, can_read=data.can_read,
can_send=data.can_send, can_send=data.can_send,
can_manage=data.can_manage, can_manage=data.can_manage,
can_conserve=data.can_conserve,
granted_by=granted_by.id, granted_by=granted_by.id,
) )
self.db.add(perm) self.db.add(perm)
@@ -201,6 +224,7 @@ class PermissionService:
"can_read": perm.can_read, "can_read": perm.can_read,
"can_send": perm.can_send, "can_send": perm.can_send,
"can_manage": perm.can_manage, "can_manage": perm.can_manage,
"can_conserve": perm.can_conserve,
"granted_at": perm.granted_at, "granted_at": perm.granted_at,
} }
for perm, user in rows for perm, user in rows
@@ -228,6 +252,7 @@ class PermissionService:
"can_read": perm.can_read, "can_read": perm.can_read,
"can_send": perm.can_send, "can_send": perm.can_send,
"can_manage": perm.can_manage, "can_manage": perm.can_manage,
"can_conserve": perm.can_conserve,
} }
for perm, mailbox in rows for perm, mailbox in rows
] ]
+4
View File
@@ -52,6 +52,8 @@ export default function App() {
<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" />} /> <Route path="/trash" element={<InboxPage viewMode="trash" />} />
<Route path="/conservation-pending" element={<InboxPage viewMode="conservation_pending" />} />
<Route path="/conservation-archived" element={<InboxPage viewMode="conservation_archived" />} />
{/* 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" />} />
@@ -59,6 +61,8 @@ export default function App() {
<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" />} /> <Route path="/mailbox/:mailboxId/trash" element={<InboxPage viewMode="trash" />} />
<Route path="/mailbox/:mailboxId/conservation-pending" element={<InboxPage viewMode="conservation_pending" />} />
<Route path="/mailbox/:mailboxId/conservation-archived" element={<InboxPage viewMode="conservation_archived" />} />
{/* 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" />} />
+18
View File
@@ -18,6 +18,10 @@ export interface MessageFilters {
is_starred?: boolean is_starred?: boolean
is_archived?: boolean is_archived?: boolean
is_trashed?: 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 search?: string
/** Data minima nel formato ISO 8601 (es. "2026-01-01T00:00:00Z") */ /** Data minima nel formato ISO 8601 (es. "2026-01-01T00:00:00Z") */
date_from?: string date_from?: string
@@ -31,6 +35,8 @@ export interface MessageBulkUpdatePayload {
is_starred?: boolean is_starred?: boolean
is_archived?: boolean is_archived?: boolean
is_trashed?: boolean is_trashed?: boolean
is_pending_conservation?: boolean
is_conserved?: boolean
} }
export interface MessageBulkUpdateResponse { export interface MessageBulkUpdateResponse {
@@ -78,6 +84,18 @@ export const messagesApi = {
.patch<MessageResponse>(`/messages/${id}`, { is_trashed: false }) .patch<MessageResponse>(`/messages/${id}`, { is_trashed: false })
.then((r) => r.data), .then((r) => r.data),
/** Sposta un messaggio nella cartella Da Conservare */
conserve: (id: string) =>
apiClient
.patch<MessageResponse>(`/messages/${id}`, { is_pending_conservation: true })
.then((r) => r.data),
/** Rimuove un messaggio dalla cartella Da Conservare */
unconserve: (id: string) =>
apiClient
.patch<MessageResponse>(`/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 */ /** Aggiorna in blocco is_starred e/o is_archived e/o is_trashed su più messaggi */
bulkUpdate: (payload: MessageBulkUpdatePayload) => bulkUpdate: (payload: MessageBulkUpdatePayload) =>
apiClient apiClient
@@ -53,6 +53,7 @@ import {
Search, Search,
BarChart2, BarChart2,
ClipboardList, ClipboardList,
ShieldCheck,
} 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'
@@ -297,6 +298,53 @@ export function Sidebar() {
<Trash2 className="h-4 w-4 flex-shrink-0" /> <Trash2 className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Cestino</span>} {!collapsed && <span>Cestino</span>}
</NavLink> </NavLink>
{/* ── Sezione Conservazione (admin e supervisor) ── */}
{(isAdmin || isSupervisor) && (
<>
{!collapsed && (
<div className="pt-1 pb-0.5 px-1">
<p className="text-[10px] font-semibold text-teal-500/70 uppercase tracking-wider">
Conservazione
</p>
</div>
)}
<NavLink
to="/conservation-pending"
end
className={({ isActive }) =>
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}
>
<ShieldCheck className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Da Conservare</span>}
</NavLink>
<NavLink
to="/conservation-archived"
end
className={({ isActive }) =>
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}
>
<ShieldCheck className="h-4 w-4 flex-shrink-0 opacity-60" />
{!collapsed && <span>Storico</span>}
</NavLink>
</>
)}
</div> </div>
</div> </div>
@@ -604,6 +652,8 @@ function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle, unreadCount
const displayName = mailbox.display_name || mailbox.email_address const displayName = mailbox.display_name || mailbox.email_address
const initial = displayName[0]?.toUpperCase() ?? '?' const initial = displayName[0]?.toUpperCase() ?? '?'
const dotClass = statusDot(mailbox.status) const dotClass = statusDot(mailbox.status)
const { isAdmin, isSupervisor } = useAuth()
const canSeeConservation = isAdmin || isSupervisor
/* ── Modalità compressa: solo avatar/iniziale → link diretto all'inbox ── */ /* ── Modalità compressa: solo avatar/iniziale → link diretto all'inbox ── */
if (collapsed) { if (collapsed) {
@@ -759,6 +809,40 @@ function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle, unreadCount
<Trash2 className="h-3.5 w-3.5 flex-shrink-0" /> <Trash2 className="h-3.5 w-3.5 flex-shrink-0" />
<span>Cestino</span> <span>Cestino</span>
</NavLink> </NavLink>
{/* Conservazione (solo admin/supervisor) */}
{canSeeConservation && (
<>
<NavLink
to={`/mailbox/${mailbox.id}/conservation-pending`}
className={({ isActive }) =>
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',
)
}
>
<ShieldCheck className="h-3.5 w-3.5 flex-shrink-0" />
<span>Da Conservare</span>
</NavLink>
<NavLink
to={`/mailbox/${mailbox.id}/conservation-archived`}
className={({ isActive }) =>
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',
)
}
>
<ShieldCheck className="h-3.5 w-3.5 flex-shrink-0 opacity-60" />
<span>Storico</span>
</NavLink>
</>
)}
</div> </div>
)} )}
</div> </div>
+119 -19
View File
@@ -39,6 +39,8 @@ import {
SlidersHorizontal, SlidersHorizontal,
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
ShieldCheck,
ShieldX,
} 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'
@@ -47,6 +49,7 @@ import { Input } from '@/components/ui/Input'
import { PecStateBadge } from '@/components/PecBadge/PecBadge' import { PecStateBadge } from '@/components/PecBadge/PecBadge'
import { TagBadgeList } from '@/components/TagManager/TagBadge' import { TagBadgeList } from '@/components/TagManager/TagBadge'
import { TagSelector } from '@/components/TagManager/TagSelector' import { TagSelector } from '@/components/TagManager/TagSelector'
import { useAuth } from '@/hooks/useAuth'
import { messagesApi } from '@/api/messages.api' import { messagesApi } from '@/api/messages.api'
import { labelsApi } from '@/api/labels.api' import { labelsApi } from '@/api/labels.api'
import { mailboxesApi } from '@/api/mailboxes.api' import { mailboxesApi } from '@/api/mailboxes.api'
@@ -58,7 +61,7 @@ import { getErrorMessage } from '@/api/client'
// ─── Props ──────────────────────────────────────────────────────────────────── // ─── Props ────────────────────────────────────────────────────────────────────
export type InboxViewMode = 'inbox' | 'sent' | 'starred' | 'archived' | 'trash' export type InboxViewMode = 'inbox' | 'sent' | 'starred' | 'archived' | 'trash' | 'conservation_pending' | 'conservation_archived'
interface InboxPageProps { interface InboxPageProps {
viewMode: InboxViewMode viewMode: InboxViewMode
@@ -75,6 +78,10 @@ export function InboxPage({ viewMode }: InboxPageProps) {
// true = stiamo navigando in una casella reale (non virtual box) // true = stiamo navigando in una casella reale (non virtual box)
const isMailboxMode = !vboxId const isMailboxMode = !vboxId
// ── Ruolo utente per visibilita' pulsante Conservazione ─────────────────────
const { isAdmin, isSupervisor } = useAuth()
const canConserve = isAdmin || isSupervisor
// ── Stato filtri locale ────────────────────────────────────────────────────── // ── Stato filtri locale ──────────────────────────────────────────────────────
const [searchInput, setSearchInput] = useState('') const [searchInput, setSearchInput] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('') const [debouncedSearch, setDebouncedSearch] = useState('')
@@ -196,6 +203,16 @@ export function InboxPage({ viewMode }: InboxPageProps) {
...advancedBase, ...advancedBase,
is_trashed: true, 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)), 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 ────────────────────────────────────────────────────────────── // ── Azioni bulk ──────────────────────────────────────────────────────────────
const bulkMutation = useMutation({ const bulkMutation = useMutation({
mutationFn: messagesApi.bulkUpdate, 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_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 === 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_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)), onError: (error) => toast.error(getErrorMessage(error)),
}) })
@@ -367,6 +404,10 @@ export function InboxPage({ viewMode }: InboxPageProps) {
bulkMutation.mutate({ ids: Array.from(selectedIds), is_trashed: true }) bulkMutation.mutate({ ids: Array.from(selectedIds), is_trashed: true })
const handleBulkUntrash = () => const handleBulkUntrash = () =>
bulkMutation.mutate({ ids: Array.from(selectedIds), is_trashed: false }) 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 ──────────────────────────────────────────────────────────────── // ── Selezione ────────────────────────────────────────────────────────────────
const handleToggleSelect = (id: string, e: React.MouseEvent) => { const handleToggleSelect = (id: string, e: React.MouseEvent) => {
@@ -409,25 +450,20 @@ export function InboxPage({ viewMode }: InboxPageProps) {
// ── Label e icone folder ──────────────────────────────────────────────────── // ── Label e icone folder ────────────────────────────────────────────────────
const isInbound = viewMode === 'inbox' const isInbound = viewMode === 'inbox'
const folderLabel = const folderLabel =
viewMode === 'inbox' viewMode === 'inbox' ? 'Posta in Arrivo'
? 'Posta in Arrivo' : viewMode === 'sent' ? 'Posta Inviata'
: viewMode === 'sent' : viewMode === 'starred' ? 'Preferiti'
? 'Posta Inviata' : viewMode === 'archived' ? 'Archiviati'
: viewMode === 'starred' : viewMode === 'trash' ? 'Cestino'
? 'Preferiti' : viewMode === 'conservation_pending' ? 'Da Conservare'
: viewMode === 'archived' : 'Storico Conservazione'
? 'Archiviati'
: 'Cestino'
const FolderIcon = const FolderIcon =
viewMode === 'inbox' viewMode === 'inbox' ? Inbox
? Inbox : viewMode === 'sent' ? Send
: viewMode === 'sent' : viewMode === 'starred' ? Star
? Send : viewMode === 'archived' ? Archive
: viewMode === 'starred' : viewMode === 'trash' ? Trash2
? Star : ShieldCheck
: 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
@@ -760,6 +796,34 @@ export function InboxPage({ viewMode }: InboxPageProps) {
Tag Tag
</Button> </Button>
)} )}
{/* Invia a Conservazione (solo admin/supervisor, non nelle viste conservazione) */}
{canConserve && viewMode !== 'conservation_pending' && viewMode !== 'conservation_archived' && viewMode !== 'trash' && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs border-teal-200 hover:bg-teal-50 dark:border-teal-800 text-teal-700"
onClick={handleBulkConserve}
isLoading={bulkMutation.isPending}
>
<ShieldCheck className="h-3.5 w-3.5 mr-1" />
Invia a Conservazione
</Button>
)}
{/* Rimuovi da Da Conservare */}
{canConserve && viewMode === 'conservation_pending' && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs border-orange-200 hover:bg-orange-50 dark:border-orange-800 text-orange-700"
onClick={handleBulkUnconserve}
isLoading={bulkMutation.isPending}
>
<ShieldX className="h-3.5 w-3.5 mr-1" />
Rimuovi da Da Conservare
</Button>
)}
</div> </div>
)} )}
@@ -818,6 +882,7 @@ export function InboxPage({ viewMode }: InboxPageProps) {
message={message} message={message}
viewMode={viewMode} viewMode={viewMode}
isMailboxMode={isMailboxMode} isMailboxMode={isMailboxMode}
canConserve={canConserve}
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)}
@@ -837,6 +902,10 @@ export function InboxPage({ viewMode }: InboxPageProps) {
e.stopPropagation() e.stopPropagation()
trashMutation.mutate({ id: message.id, trashed: !message.is_trashed }) trashMutation.mutate({ id: message.id, trashed: !message.is_trashed })
}} }}
onToggleConserve={(e) => {
e.stopPropagation()
conserveMutation.mutate({ id: message.id, conserve: !message.is_pending_conservation })
}}
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
@@ -897,12 +966,14 @@ interface MessageRowProps {
viewMode: InboxViewMode viewMode: InboxViewMode
isMailboxMode: boolean isMailboxMode: boolean
isSelected: boolean isSelected: boolean
canConserve: 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
onMarkUnread: (e: React.MouseEvent) => void onMarkUnread: (e: React.MouseEvent) => void
onToggleTrash: (e: React.MouseEvent) => void onToggleTrash: (e: React.MouseEvent) => void
onToggleConserve: (e: React.MouseEvent) => void
mailboxName?: string mailboxName?: string
} }
@@ -911,12 +982,14 @@ function MessageRow({
viewMode, viewMode,
isMailboxMode, isMailboxMode,
isSelected, isSelected,
canConserve,
onSelect, onSelect,
onClick, onClick,
onToggleStar, onToggleStar,
onToggleArchive, onToggleArchive,
onMarkUnread, onMarkUnread,
onToggleTrash, onToggleTrash,
onToggleConserve,
mailboxName, mailboxName,
}: MessageRowProps) { }: MessageRowProps) {
const [hovered, setHovered] = useState(false) const [hovered, setHovered] = useState(false)
@@ -1093,6 +1166,33 @@ function MessageRow({
</button> </button>
)} )}
{/* Invia a Conservazione / Rimuovi da Da Conservare */}
{canConserve && viewMode !== 'trash' && viewMode !== 'conservation_archived' && (
<button
onClick={onToggleConserve}
title={viewMode === 'conservation_pending' ? 'Rimuovi da Da Conservare' : 'Invia a Conservazione'}
className={cn(
'p-1 rounded hover:bg-muted transition-all',
message.is_pending_conservation
? 'opacity-100'
: hovered
? 'opacity-100'
: 'opacity-0 pointer-events-none',
)}
>
{viewMode === 'conservation_pending' ? (
<ShieldX className="h-4 w-4 text-orange-500" />
) : (
<ShieldCheck
className={cn(
'h-4 w-4',
message.is_pending_conservation ? 'text-teal-600' : 'text-muted-foreground',
)}
/>
)}
</button>
)}
{/* Indicatore allegati */} {/* Indicatore allegati */}
{message.has_attachments && ( {message.has_attachments && (
<span className="text-xs text-muted-foreground"></span> <span className="text-xs text-muted-foreground"></span>
+7
View File
@@ -262,6 +262,10 @@ export interface MessageResponse {
archived_at: string | null archived_at: string | null
is_trashed: boolean is_trashed: boolean
trashed_at: string | null 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 raw_eml_path: string | null
created_at: string created_at: string
updated_at: string updated_at: string
@@ -342,6 +346,7 @@ export interface PermissionGrantRequest {
can_read?: boolean can_read?: boolean
can_send?: boolean can_send?: boolean
can_manage?: boolean can_manage?: boolean
can_conserve?: boolean
} }
export interface MailboxUserPermissionResponse { export interface MailboxUserPermissionResponse {
@@ -352,6 +357,7 @@ export interface MailboxUserPermissionResponse {
can_read: boolean can_read: boolean
can_send: boolean can_send: boolean
can_manage: boolean can_manage: boolean
can_conserve: boolean
granted_at: string granted_at: string
} }
@@ -362,6 +368,7 @@ export interface UserMailboxPermissionResponse {
can_read: boolean can_read: boolean
can_send: boolean can_send: boolean
can_manage: boolean can_manage: boolean
can_conserve: boolean
} }
// ─── Virtual Box ────────────────────────────────────────────────────────────── // ─── Virtual Box ──────────────────────────────────────────────────────────────