Files
PecHub/backend/app/api/v1/messages.py
T
2026-03-18 21:22:12 +01:00

327 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)
- 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.
"""
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 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.message import Attachment, Message
from app.schemas.message import (
AttachmentResponse,
MessageListResponse,
MessageResponse,
MessageUpdateRequest,
)
router = APIRouter(prefix="/messages", tags=["Messages"])
settings = get_settings()
# ─── Helpers ──────────────────────────────────────────────────────────────────
async def _get_visible_mailbox_ids(
user, db: AsyncSession
) -> Optional[list[uuid.UUID]]:
"""
Per utenti non-admin restituisce la lista di mailbox_id accessibili.
Restituisce None se l'utente è admin (accesso illimitato al tenant).
"""
if user.is_admin:
return None # nessun filtro per admin
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,
)
)
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)
if not await perm_svc.check_can_read(current_user, message.mailbox_id):
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
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),
search: Optional[str] = Query(None, max_length=200),
pec_type: Optional[str] = Query(None),
# Paginazione
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.
- `search` cerca su subject, from_address, to_addresses.
"""
# Determinare le caselle visibili
visible_mailbox_ids = await _get_visible_mailbox_ids(current_user, db)
# Query base
q = select(Message).where(
Message.tenant_id == current_user.tenant_id,
Message.parent_message_id.is_(None), # escludi ricevute (messaggi figlio)
)
# Filtro caselle visibili per non-admin
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)
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 search:
term = f"%{search}%"
q = q.where(
or_(
Message.subject.ilike(term),
Message.from_address.ilike(term),
Message.body_text.ilike(term),
)
)
# Conteggio totale
count_q = select(func.count()).select_from(q.subquery())
total = (await db.execute(count_q)).scalar_one()
# Ordinamento e paginazione
q = (
q.order_by(
Message.received_at.desc().nullslast(),
Message.created_at.desc(),
)
.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.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:
is_read, is_starred, is_archived.
"""
message = await _resolve_message(message_id, current_user, db)
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 = datetime.now(timezone.utc)
elif not data.is_archived:
message.archived_at = None
await db.commit()
await db.refresh(message)
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.
Il file viene streamato al client con i header Content-Disposition corretti.
"""
# Verifica accesso al messaggio
await _resolve_message(message_id, current_user, db)
# Carica allegato
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")
# Stream da MinIO
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 è del tipo "tenant_id/attachments/filename"
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}/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
(messaggi con parent_message_id = message_id).
"""
# Verifica accesso al messaggio padre
await _resolve_message(message_id, current_user, db)
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(result.scalars().all())
return [MessageResponse.model_validate(r) for r in receipts]