mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
Conservazionee
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user