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