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 && (
+
+ )}
+
+ 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 ──────────────────────────────────────────────────────────────