mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
Conservazionee
This commit is contained in:
@@ -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")
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
+14
-10
@@ -47,18 +47,22 @@ export default function App() {
|
||||
<Route path="/" element={<Navigate to="/inbox" replace />} />
|
||||
|
||||
{/* Vista globale: tutte le caselle insieme */}
|
||||
<Route path="/inbox" element={<InboxPage viewMode="inbox" />} />
|
||||
<Route path="/sent" element={<InboxPage viewMode="sent" />} />
|
||||
<Route path="/starred" element={<InboxPage viewMode="starred" />} />
|
||||
<Route path="/archived" element={<InboxPage viewMode="archived" />} />
|
||||
<Route path="/trash" element={<InboxPage viewMode="trash" />} />
|
||||
<Route path="/inbox" element={<InboxPage viewMode="inbox" />} />
|
||||
<Route path="/sent" element={<InboxPage viewMode="sent" />} />
|
||||
<Route path="/starred" element={<InboxPage viewMode="starred" />} />
|
||||
<Route path="/archived" element={<InboxPage viewMode="archived" />} />
|
||||
<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 */}
|
||||
<Route path="/mailbox/:mailboxId/inbox" element={<InboxPage viewMode="inbox" />} />
|
||||
<Route path="/mailbox/:mailboxId/sent" element={<InboxPage viewMode="sent" />} />
|
||||
<Route path="/mailbox/:mailboxId/starred" element={<InboxPage viewMode="starred" />} />
|
||||
<Route path="/mailbox/:mailboxId/archived" element={<InboxPage viewMode="archived" />} />
|
||||
<Route path="/mailbox/:mailboxId/trash" element={<InboxPage viewMode="trash" />} />
|
||||
<Route path="/mailbox/:mailboxId/inbox" element={<InboxPage viewMode="inbox" />} />
|
||||
<Route path="/mailbox/:mailboxId/sent" element={<InboxPage viewMode="sent" />} />
|
||||
<Route path="/mailbox/:mailboxId/starred" element={<InboxPage viewMode="starred" />} />
|
||||
<Route path="/mailbox/:mailboxId/archived" element={<InboxPage viewMode="archived" />} />
|
||||
<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 */}
|
||||
<Route path="/virtual-box/:vboxId/inbox" element={<InboxPage viewMode="inbox" />} />
|
||||
|
||||
@@ -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<MessageResponse>(`/messages/${id}`, { is_trashed: false })
|
||||
.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 */
|
||||
bulkUpdate: (payload: MessageBulkUpdatePayload) =>
|
||||
apiClient
|
||||
|
||||
@@ -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() {
|
||||
<Trash2 className="h-4 w-4 flex-shrink-0" />
|
||||
{!collapsed && <span>Cestino</span>}
|
||||
</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>
|
||||
|
||||
@@ -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
|
||||
<Trash2 className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>Cestino</span>
|
||||
</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>
|
||||
|
||||
@@ -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
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -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({
|
||||
</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 */}
|
||||
{message.has_attachments && (
|
||||
<span className="text-xs text-muted-foreground"></span>
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user