Files
PecHub/backend/app/api/v1/messages.py
T
2026-03-27 16:54:49 +01:00

712 lines
26 KiB
Python

"""
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,
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
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Query, status
from fastapi.responses import StreamingResponse
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.services.search_service import SearchService
from app.config import get_settings
from app.core.exceptions import ForbiddenError, NotFoundError
from app.database import get_db
from app.dependencies import CurrentUser, DB
from app.models.label import Label
from app.models.message import Attachment, Message
from app.schemas.message import (
AttachmentResponse,
MessageBulkUpdateRequest,
MessageBulkUpdateResponse,
MessageListResponse,
MessageResponse,
MessageUpdateRequest,
)
router = APIRouter(prefix="/messages", tags=["Messages"])
settings = get_settings()
# ─── Helpers ──────────────────────────────────────────────────────────────────
def _apply_vbox_rule(q, field: str, operator: str, value: str):
"""
Applica una singola regola di Virtual Box alla query SQLAlchemy.
field : subject | from_address | to_address | imap_folder
operator : contains | equals | starts_with | ends_with | regex
"""
if field == "subject":
col = Message.subject
elif field == "from_address":
col = Message.from_address
elif field == "to_address":
arr_text = func.array_to_string(Message.to_addresses, ",")
if operator == "contains":
return q.where(arr_text.ilike(f"%{value}%"))
elif operator == "equals":
return q.where(arr_text.ilike(value))
elif operator == "starts_with":
return q.where(arr_text.ilike(f"{value}%"))
elif operator == "ends_with":
return q.where(arr_text.ilike(f"%{value}"))
elif operator == "regex":
return q.where(arr_text.op("~*")(value))
return q
elif field == "imap_folder":
col = Message.imap_folder
else:
return q
if operator == "contains":
return q.where(col.ilike(f"%{value}%"))
elif operator == "equals":
return q.where(func.lower(col) == value.lower())
elif operator == "starts_with":
return q.where(col.ilike(f"{value}%"))
elif operator == "ends_with":
return q.where(col.ilike(f"%{value}"))
elif operator == "regex":
return q.where(col.op("~*")(value))
return q
async def _get_visible_mailbox_ids(
user, db: AsyncSession
) -> Optional[list[uuid.UUID]]:
"""
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).
"""
if user.is_supervisor_or_admin:
return None
from app.services.permission_service import PermissionService
perm_svc = PermissionService(db)
return await perm_svc.get_visible_mailboxes(user)
async def _resolve_message(
message_id: uuid.UUID,
current_user,
db: AsyncSession,
) -> Message:
"""Carica il messaggio e verifica i permessi di accesso."""
result = await db.execute(
select(Message)
.where(
Message.id == message_id,
Message.tenant_id == current_user.tenant_id,
)
.options(selectinload(Message.labels))
)
message = result.scalar_one_or_none()
if not message:
raise NotFoundError(f"Messaggio {message_id} non trovato")
if not current_user.is_admin:
from app.services.permission_service import PermissionService
perm_svc = PermissionService(db)
has_direct_access = await perm_svc.check_can_read(current_user, message.mailbox_id)
if not has_direct_access:
from app.models.virtual_box import (
VirtualBox,
VirtualBoxAssignment,
virtual_box_mailboxes,
)
vbox_result = await db.execute(
select(VirtualBox.id)
.join(
VirtualBoxAssignment,
VirtualBox.id == VirtualBoxAssignment.virtual_box_id,
)
.join(
virtual_box_mailboxes,
VirtualBox.id == virtual_box_mailboxes.c.virtual_box_id,
)
.where(
VirtualBoxAssignment.user_id == current_user.id,
virtual_box_mailboxes.c.mailbox_id == message.mailbox_id,
VirtualBox.tenant_id == current_user.tenant_id,
VirtualBox.is_active == True,
)
.limit(1)
)
has_vbox_access = vbox_result.scalar_one_or_none() is not None
if not has_vbox_access:
raise ForbiddenError("Accesso al messaggio non autorizzato")
return message
# ─── Endpoints ───────────────────────────────────────────────────────────────
@router.get("", response_model=MessageListResponse)
async def list_messages(
current_user: CurrentUser,
db: DB,
# Filtri
vbox_id: Optional[uuid.UUID] = Query(None, description="Filtra per Virtual Box assegnata"),
mailbox_id: Optional[uuid.UUID] = Query(None),
direction: Optional[str] = Query(None, pattern="^(inbound|outbound)$"),
state: Optional[str] = Query(None),
is_read: Optional[bool] = Query(None),
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),
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)"),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
) -> MessageListResponse:
"""
Elenca i messaggi PEC con filtri opzionali.
- `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.
"""
visible_mailbox_ids = await _get_visible_mailbox_ids(current_user, db)
# ── Filtro Virtual Box ────────────────────────────────────────────────────
vbox_rules: list = []
if vbox_id is not None:
from app.models.virtual_box import VirtualBox, VirtualBoxAssignment
vbox_result = await db.execute(
select(VirtualBox)
.where(
VirtualBox.id == vbox_id,
VirtualBox.tenant_id == current_user.tenant_id,
VirtualBox.is_active == True,
)
.options(
selectinload(VirtualBox.rules),
selectinload(VirtualBox.mailboxes),
)
)
vbox = vbox_result.scalar_one_or_none()
if not vbox:
raise NotFoundError("Virtual Box")
if not current_user.is_admin:
assign_result = await db.execute(
select(VirtualBoxAssignment).where(
VirtualBoxAssignment.virtual_box_id == vbox_id,
VirtualBoxAssignment.user_id == current_user.id,
)
)
if not assign_result.scalar_one_or_none():
raise ForbiddenError("Virtual Box non accessibile")
if vbox.mailboxes:
visible_mailbox_ids = [m.id for m in vbox.mailboxes]
vbox_rules = vbox.rules or []
# Query base
q = select(Message).where(
Message.tenant_id == current_user.tenant_id,
Message.parent_message_id.is_(None),
)
if visible_mailbox_ids is not None:
if not visible_mailbox_ids:
return MessageListResponse(items=[], total=0, page=page, page_size=page_size)
q = q.where(Message.mailbox_id.in_(visible_mailbox_ids))
if mailbox_id is not None:
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)
if direction is not None:
q = q.where(Message.direction == direction)
if state is not None:
q = q.where(Message.state == state)
if pec_type is not None:
q = q.where(Message.pec_type == pec_type)
if is_read is not None:
q = q.where(Message.is_read == is_read)
if is_starred is not None:
q = q.where(Message.is_starred == is_starred)
if is_archived is not None:
q = q.where(Message.is_archived == is_archived)
if is_trashed is not None:
q = q.where(Message.is_trashed == is_trashed)
# ── 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
tsquery = func.websearch_to_tsquery("italian", search)
term_like = f"%{search}%"
q = q.where(
or_(
Message.search_vector.op("@@")(tsquery),
Message.search_vector.is_(None) & or_(
Message.subject.ilike(term_like),
Message.from_address.ilike(term_like),
Message.body_text.ilike(term_like),
),
)
)
# ── Filtri data ───────────────────────────────────────────────────────────
if date_from:
q = q.where(or_(Message.received_at >= date_from, Message.sent_at >= date_from))
if date_to:
q = q.where(or_(Message.received_at <= date_to, Message.sent_at <= date_to))
for rule in vbox_rules:
q = _apply_vbox_rule(q, rule.field, rule.operator, rule.value)
count_q = select(func.count()).select_from(q.subquery())
total = (await db.execute(count_q)).scalar_one()
if search:
from sqlalchemy import case as sa_case
tsquery_ord = func.websearch_to_tsquery("italian", search)
rank_expr = sa_case(
(Message.search_vector.isnot(None), func.ts_rank(Message.search_vector, tsquery_ord)),
else_=0.0,
)
order_clauses = [rank_expr.desc(), Message.received_at.desc().nullslast(), Message.created_at.desc()]
else:
order_clauses = [Message.received_at.desc().nullslast(), Message.created_at.desc()]
q = (
q.options(selectinload(Message.labels))
.order_by(*order_clauses)
.offset((page - 1) * page_size)
.limit(page_size)
)
result = await db.execute(q)
items = list(result.scalars().all())
return MessageListResponse(
items=[MessageResponse.model_validate(m) for m in items],
total=total,
page=page,
page_size=page_size,
)
@router.patch("/bulk", response_model=MessageBulkUpdateResponse)
async def bulk_update_messages(
data: MessageBulkUpdateRequest,
current_user: CurrentUser,
db: DB,
) -> MessageBulkUpdateResponse:
"""
Aggiorna in blocco i flag operativi di piu messaggi.
Per is_pending_conservation=True o is_conserved=True richiede can_conserve.
"""
if not data.ids:
return MessageBulkUpdateResponse(updated=0, items=[])
result = await db.execute(
select(Message).where(
Message.id.in_(data.ids),
Message.tenant_id == current_user.tenant_id,
)
)
messages = list(result.scalars().all())
# Filtra per permessi di lettura
if not current_user.is_admin:
from app.services.permission_service import PermissionService
perm_svc = PermissionService(db)
visible = await perm_svc.get_visible_mailboxes(current_user)
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:
message.is_read = data.is_read
if data.is_starred is not None:
message.is_starred = data.is_starred
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 = 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 = 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()
if messages:
updated_ids = [m.id for m in messages]
refreshed_result = await db.execute(
select(Message)
.where(Message.id.in_(updated_ids))
.options(selectinload(Message.labels))
)
messages = list(refreshed_result.scalars().all())
return MessageBulkUpdateResponse(
updated=len(messages),
items=[MessageResponse.model_validate(m) for m in messages],
)
@router.get("/{message_id}", response_model=MessageResponse)
async def get_message(
message_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> MessageResponse:
"""Carica un messaggio per ID."""
message = await _resolve_message(message_id, current_user, db)
return MessageResponse.model_validate(message)
@router.patch("/{message_id}", response_model=MessageResponse)
async def update_message(
message_id: uuid.UUID,
data: MessageUpdateRequest,
current_user: CurrentUser,
db: DB,
) -> MessageResponse:
"""
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:
message.is_starred = data.is_starred
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 = 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 = 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()
refreshed = await db.execute(
select(Message)
.where(Message.id == message_id)
.options(selectinload(Message.labels))
)
message = refreshed.scalar_one()
return MessageResponse.model_validate(message)
@router.get("/{message_id}/attachments", response_model=list[AttachmentResponse])
async def list_attachments(
message_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> list[AttachmentResponse]:
"""Elenca gli allegati di un messaggio."""
message = await _resolve_message(message_id, current_user, db)
result = await db.execute(
select(Attachment)
.where(Attachment.message_id == message.id)
.order_by(Attachment.created_at)
)
attachments = list(result.scalars().all())
return [AttachmentResponse.model_validate(a) for a in attachments]
@router.get("/{message_id}/attachments/{attachment_id}/download")
async def download_attachment(
message_id: uuid.UUID,
attachment_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> StreamingResponse:
"""Scarica un allegato direttamente da MinIO."""
await _resolve_message(message_id, current_user, db)
result = await db.execute(
select(Attachment).where(
Attachment.id == attachment_id,
Attachment.message_id == message_id,
)
)
attachment = result.scalar_one_or_none()
if not attachment:
raise NotFoundError(f"Allegato {attachment_id} non trovato")
try:
from miniopy_async import Minio
client = Minio(
endpoint=settings.minio_endpoint,
access_key=settings.minio_access_key,
secret_key=settings.minio_secret_key,
secure=settings.minio_use_ssl,
)
storage_path = attachment.storage_path
response = await client.get_object(settings.minio_bucket, storage_path)
content_type = attachment.content_type or "application/octet-stream"
filename = attachment.filename.replace('"', "'")
async def _stream():
async for chunk in response.content.iter_chunked(65536):
yield chunk
response.close()
return StreamingResponse(
_stream(),
media_type=content_type,
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Length": str(attachment.size_bytes or ""),
},
)
except Exception as e:
from app.core.logging import get_logger
logger = get_logger(__name__)
logger.error(f"Errore download allegato {attachment_id}: {e}")
raise NotFoundError("File non disponibile al momento")
@router.get("/{message_id}/download-package")
async def download_package(
message_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> StreamingResponse:
"""Scarica un archivio ZIP con tutti i file originali della PEC."""
import io
import zipfile as _zipfile
from miniopy_async import Minio
message = await _resolve_message(message_id, current_user, db)
client = Minio(
endpoint=settings.minio_endpoint,
access_key=settings.minio_access_key,
secret_key=settings.minio_secret_key,
secure=settings.minio_use_ssl,
)
buf = io.BytesIO()
async def _read_minio(path: str) -> bytes:
try:
resp = await client.get_object(settings.minio_bucket, path)
data = await resp.content.read()
resp.close()
return data
except Exception:
return b""
with _zipfile.ZipFile(buf, mode="w", compression=_zipfile.ZIP_DEFLATED) as zf:
att_result = await db.execute(
select(Attachment)
.where(Attachment.message_id == message.id)
.order_by(Attachment.created_at)
)
main_attachments = list(att_result.scalars().all())
for att in main_attachments:
data = await _read_minio(att.storage_path)
if data:
zf.writestr(att.filename, data)
if message.raw_eml_path:
data = await _read_minio(message.raw_eml_path)
if data:
eml_name = message.raw_eml_path.rsplit("/", 1)[-1]
if not eml_name.endswith(".eml"):
eml_name = "messaggio_originale.eml"
existing = {info.filename for info in zf.infolist()}
if eml_name not in existing:
zf.writestr(eml_name, data)
if message.direction == "outbound":
receipts_result = await db.execute(
select(Message)
.where(Message.parent_message_id == message.id)
.order_by(Message.received_at.asc().nullslast(), Message.created_at.asc())
)
receipts = list(receipts_result.scalars().all())
for receipt in receipts:
pec_type = receipt.pec_type or "ricevuta"
folder = f"ricevute/{pec_type}"
r_att_result = await db.execute(
select(Attachment)
.where(Attachment.message_id == receipt.id)
.order_by(Attachment.created_at)
)
r_attachments = list(r_att_result.scalars().all())
for att in r_attachments:
data = await _read_minio(att.storage_path)
if data:
zip_path = f"{folder}/{att.filename}"
existing = {info.filename for info in zf.infolist()}
final_path = zip_path
counter = 1
while final_path in existing:
name, _, ext = att.filename.rpartition(".")
final_path = f"{folder}/{name}_{counter}.{ext}" if ext else f"{folder}/{att.filename}_{counter}"
counter += 1
zf.writestr(final_path, data)
if receipt.raw_eml_path:
data = await _read_minio(receipt.raw_eml_path)
if data:
eml_name = receipt.raw_eml_path.rsplit("/", 1)[-1]
if not eml_name.endswith(".eml"):
eml_name = f"{pec_type}.eml"
zip_path = f"{folder}/{eml_name}"
existing = {info.filename for info in zf.infolist()}
if zip_path not in existing:
zf.writestr(zip_path, data)
buf.seek(0)
zip_bytes = buf.getvalue()
safe_subject = (message.subject or "pec").replace("/", "_").replace("\\", "_")[:50]
zip_filename = f"pec_{safe_subject}.zip"
return StreamingResponse(
iter([zip_bytes]),
media_type="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{zip_filename}"',
"Content-Length": str(len(zip_bytes)),
},
)
@router.get("/{message_id}/receipts", response_model=list[MessageResponse])
async def list_receipts(
message_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> list[MessageResponse]:
"""Elenca le ricevute associate a un messaggio outbound."""
await _resolve_message(message_id, current_user, db)
result = await db.execute(
select(Message)
.where(Message.parent_message_id == message_id)
.options(selectinload(Message.labels))
.order_by(Message.received_at.asc().nullslast(), Message.created_at.asc())
)
receipts = list(result.scalars().all())
return [MessageResponse.model_validate(r) for r in receipts]