diff --git a/KnowledgeBaseCline.md b/KnowledgeBaseCline.md index 56b3ae5..864d6bc 100644 --- a/KnowledgeBaseCline.md +++ b/KnowledgeBaseCline.md @@ -47,4 +47,11 @@ SSL: Sì Se devi, effettua i test di invio solo al destinatario matteo1801@spidmail.it -Tutto il frontend deve essere in italiano \ No newline at end of file +Tutto il frontend deve essere in italiano + +Credenziali admin +Ruolo Email Password +Super Admin superadmin@pecflow.it SuperAdmin@PecFlow2026! +Admin (tenant demo) admin@demo.pecflow.it Demo@PecFlow2026! +Operator (tenant demo) operator@demo.pecflow.it Oper@PecFlow2026! +Per accedere all'applicazione usa le credenziali Admin del tenant demo. \ No newline at end of file diff --git a/backend/app/api/v1/messages.py b/backend/app/api/v1/messages.py new file mode 100644 index 0000000..90398e1 --- /dev/null +++ b/backend/app/api/v1/messages.py @@ -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] diff --git a/backend/app/main.py b/backend/app/main.py index 3c48cd5..cc60ecf 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/schemas/message.py b/backend/app/schemas/message.py new file mode 100644 index 0000000..f86ea4d --- /dev/null +++ b/backend/app/schemas/message.py @@ -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 diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 37f97ca..42736af 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ server: { host: '0.0.0.0', port: 3000, + allowedHosts: true, proxy: { '/api': { target: 'http://backend:8000',