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.
|
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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
]
|
]
|
||||||
|
|||||||
+14
-10
@@ -47,18 +47,22 @@ export default function App() {
|
|||||||
<Route path="/" element={<Navigate to="/inbox" replace />} />
|
<Route path="/" element={<Navigate to="/inbox" replace />} />
|
||||||
|
|
||||||
{/* Vista globale: tutte le caselle insieme */}
|
{/* Vista globale: tutte le caselle insieme */}
|
||||||
<Route path="/inbox" element={<InboxPage viewMode="inbox" />} />
|
<Route path="/inbox" element={<InboxPage viewMode="inbox" />} />
|
||||||
<Route path="/sent" element={<InboxPage viewMode="sent" />} />
|
<Route path="/sent" element={<InboxPage viewMode="sent" />} />
|
||||||
<Route path="/starred" element={<InboxPage viewMode="starred" />} />
|
<Route path="/starred" element={<InboxPage viewMode="starred" />} />
|
||||||
<Route path="/archived" element={<InboxPage viewMode="archived" />} />
|
<Route path="/archived" element={<InboxPage viewMode="archived" />} />
|
||||||
<Route path="/trash" element={<InboxPage viewMode="trash" />} />
|
<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" />} />
|
||||||
<Route path="/mailbox/:mailboxId/sent" element={<InboxPage viewMode="sent" />} />
|
<Route path="/mailbox/:mailboxId/sent" element={<InboxPage viewMode="sent" />} />
|
||||||
<Route path="/mailbox/:mailboxId/starred" element={<InboxPage viewMode="starred" />} />
|
<Route path="/mailbox/:mailboxId/starred" element={<InboxPage viewMode="starred" />} />
|
||||||
<Route path="/mailbox/:mailboxId/archived" element={<InboxPage viewMode="archived" />} />
|
<Route path="/mailbox/:mailboxId/archived" element={<InboxPage viewMode="archived" />} />
|
||||||
<Route path="/mailbox/:mailboxId/trash" element={<InboxPage viewMode="trash" />} />
|
<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,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user