fase 5 fix

This commit is contained in:
2026-03-18 21:22:12 +01:00
parent 9fe656b34c
commit 538d6a6bec
5 changed files with 420 additions and 2 deletions
+326
View File
@@ -0,0 +1,326 @@
"""
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]
+2 -1
View File
@@ -13,7 +13,7 @@ from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from slowapi.util import get_remote_address
from app.api.v1 import auth, mailboxes, permissions, send, tenants, users, ws
from app.api.v1 import auth, mailboxes, messages, permissions, send, tenants, users, ws
from app.config import get_settings
from app.core.logging import get_logger, setup_logging
from app.database import engine
@@ -88,6 +88,7 @@ app.include_router(users.router, prefix=API_PREFIX)
app.include_router(tenants.router, prefix=API_PREFIX)
app.include_router(permissions.router, prefix=API_PREFIX)
app.include_router(mailboxes.router, prefix=API_PREFIX)
app.include_router(messages.router, prefix=API_PREFIX)
app.include_router(send.router, prefix=API_PREFIX)
app.include_router(ws.router, prefix=API_PREFIX)
+83
View File
@@ -0,0 +1,83 @@
"""
Schemi Pydantic per Message, Attachment e operazioni correlate.
"""
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, model_validator
class AttachmentResponse(BaseModel):
id: uuid.UUID
message_id: uuid.UUID
filename: str
content_type: Optional[str] = None
size_bytes: Optional[int] = None
storage_path: str
checksum_sha256: Optional[str] = None
created_at: datetime
model_config = {"from_attributes": True}
class MessageResponse(BaseModel):
id: uuid.UUID
tenant_id: uuid.UUID
mailbox_id: uuid.UUID
message_id_header: Optional[str] = None
imap_uid: Optional[int] = None
imap_folder: str
direction: str
pec_type: str
state: str
subject: Optional[str] = None
from_address: Optional[str] = None
to_addresses: list[str] = []
cc_addresses: list[str] = []
sent_at: Optional[datetime] = None
received_at: Optional[datetime] = None
size_bytes: Optional[int] = None
body_text: Optional[str] = None
body_html: Optional[str] = None
has_attachments: bool = False
parent_message_id: Optional[uuid.UUID] = None
is_read: bool = False
is_starred: bool = False
is_archived: bool = False
archived_at: Optional[datetime] = None
raw_eml_path: Optional[str] = None
created_at: datetime
updated_at: datetime
@model_validator(mode="before")
@classmethod
def coerce_arrays(cls, data: object) -> object:
"""Normalizza i campi array a liste vuote se None (da ORM)."""
if hasattr(data, "__dict__"):
# ORM object
for field in ("to_addresses", "cc_addresses"):
val = getattr(data, field, None)
if val is None:
object.__setattr__(data, field, [])
elif isinstance(data, dict):
for field in ("to_addresses", "cc_addresses"):
if data.get(field) is None:
data[field] = []
return data
model_config = {"from_attributes": True}
class MessageListResponse(BaseModel):
items: list[MessageResponse]
total: int
page: int
page_size: int
class MessageUpdateRequest(BaseModel):
is_read: Optional[bool] = None
is_starred: Optional[bool] = None
is_archived: Optional[bool] = None