@@ -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 allegat i
- 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 messagg i
- 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 (