""" Router API – Invio PEC. Endpoint: POST /send – invia una PEC via JSON (body_text, senza allegati) POST /send/multipart – invia una PEC con allegati (multipart/form-data) GET /send/jobs – lista job di invio del tenant (paginata) GET /send/jobs/{id} – dettaglio di un singolo job DELETE /send/jobs/{id} – annulla job se ancora pending/retrying """ import json import uuid from typing import Annotated from fastapi import APIRouter, File, Form, HTTPException, Query, Request, UploadFile, status from app.core.exceptions import ForbiddenError from app.dependencies import CurrentUser, DB from app.schemas.send import SendJobListResponse, SendJobResponse, SendPecRequest from app.services.audit_service import get_real_ip from app.services.permission_service import PermissionService from app.services.send_service import SendService router = APIRouter(prefix="/send", tags=["Invio PEC"]) # ─── Helpers ────────────────────────────────────────────────────────────────── def _svc(db) -> SendService: return SendService(db) def _job_response(job) -> SendJobResponse: return SendJobResponse.model_validate(job) # ─── POST /send (JSON – retrocompatibile) ──────────────────────────────────── @router.post( "", response_model=SendJobResponse, status_code=status.HTTP_201_CREATED, summary="Invia una PEC (JSON, senza allegati)", description=( "Crea un messaggio PEC in uscita e accoda il job di invio SMTP. " "Per inviare allegati utilizzare `POST /send/multipart`." ), ) async def create_send_job( request: Request, data: SendPecRequest, current_user: CurrentUser, db: DB, ) -> SendJobResponse: from app.services.audit_service import log_audit svc = _svc(db) job = await svc.create_send_job(current_user=current_user, data=data) await log_audit( db, "pec.sent", tenant_id=current_user.tenant_id, user_id=current_user.id, resource_type="send_job", resource_id=job.id, ip_address=get_real_ip(request), user_agent=request.headers.get("user-agent"), payload={ "mailbox_id": str(job.mailbox_id), "to_addresses": data.to_addresses, "subject": data.subject, "has_attachments": False, }, ) await db.commit() await db.refresh(job) return _job_response(job) # ─── POST /send/multipart (Form + files) ───────────────────────────────────── @router.post( "/multipart", response_model=SendJobResponse, status_code=status.HTTP_201_CREATED, summary="Invia una PEC con allegati (multipart/form-data)", description=( "Accetta un form multipart con:\n" "- **data**: JSON string con i campi della PEC (stessa struttura di `POST /send`, " " più il campo opzionale `body_html`)\n" "- **attachments**: zero o più file allegati (max 20 MB ciascuno)\n\n" "Carica gli allegati su MinIO e crea i record `Attachment` nel DB prima di accodare il job." ), ) async def create_send_job_multipart( request: Request, current_user: CurrentUser, db: DB, data: str = Form( ..., description="JSON con i dati della PEC (campi SendPecRequest + body_html opzionale)", ), attachments: list[UploadFile] = File( default=[], description="File allegati (0 o più, max 20 MB ciascuno)", ), ) -> SendJobResponse: from app.services.audit_service import log_audit # ── Parse del JSON ──────────────────────────────────────────────────────── try: raw = json.loads(data) pec_data = SendPecRequest.model_validate(raw) # Estrai body_html (non presente nel modello base) body_html: str | None = raw.get("body_html") or None except Exception as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Dati PEC non validi: {exc}", ) # ── Leggi file allegati ─────────────────────────────────────────────────── MAX_FILE_BYTES = 20 * 1024 * 1024 # 20 MB files_data: list[dict] = [] for upload in attachments: content = await upload.read() if not content: continue # Salta file vuoti if len(content) > MAX_FILE_BYTES: raise HTTPException( status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail=f"Il file '{upload.filename}' supera il limite di 20 MB", ) files_data.append( { "filename": upload.filename or "allegato", "content": content, "content_type": upload.content_type or "application/octet-stream", } ) # ── Sovrascrive body_html se fornito ────────────────────────────────────── if body_html: pec_data.body_html = body_html # type: ignore[attr-defined] svc = _svc(db) job = await svc.create_send_job( current_user=current_user, data=pec_data, attachments=files_data if files_data else None, ) await log_audit( db, "pec.sent", tenant_id=current_user.tenant_id, user_id=current_user.id, resource_type="send_job", resource_id=job.id, ip_address=get_real_ip(request), user_agent=request.headers.get("user-agent"), payload={ "mailbox_id": str(job.mailbox_id), "to_addresses": pec_data.to_addresses, "subject": pec_data.subject, "has_attachments": bool(files_data), "attachment_count": len(files_data), }, ) await db.commit() await db.refresh(job) return _job_response(job) # ─── GET /send/jobs ──────────────────────────────────────────────────────────── @router.get( "/jobs", response_model=SendJobListResponse, summary="Lista job di invio", ) async def list_send_jobs( current_user: CurrentUser, db: DB, page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), mailbox_id: uuid.UUID | None = Query(None), status_filter: str | None = Query( None, alias="status", description="Filtra per stato: pending | sending | sent | failed | retrying", ), ) -> SendJobListResponse: svc = _svc(db) if mailbox_id and not current_user.is_admin: perm_svc = PermissionService(db) if not await perm_svc.check_can_read(current_user, mailbox_id): raise ForbiddenError("Accesso alla casella non autorizzato") items, total = await svc.list_send_jobs( tenant_id=current_user.tenant_id, page=page, page_size=page_size, mailbox_id=mailbox_id, status_filter=status_filter, ) return SendJobListResponse( items=[_job_response(j) for j in items], total=total, page=page, page_size=page_size, ) # ─── GET /send/jobs/{id} ─────────────────────────────────────────────────────── @router.get( "/jobs/{job_id}", response_model=SendJobResponse, summary="Dettaglio job di invio", ) async def get_send_job( job_id: uuid.UUID, current_user: CurrentUser, db: DB, ) -> SendJobResponse: svc = _svc(db) job = await svc.get_send_job(job_id, current_user.tenant_id) if not current_user.is_admin: perm_svc = PermissionService(db) if not await perm_svc.check_can_read(current_user, job.mailbox_id): raise ForbiddenError("Accesso non autorizzato") return _job_response(job) # ─── DELETE /send/jobs/{id} ──────────────────────────────────────────────────── @router.delete( "/jobs/{job_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Annulla job di invio", ) async def cancel_send_job( request: Request, job_id: uuid.UUID, current_user: CurrentUser, db: DB, ) -> None: from app.services.audit_service import log_audit svc = _svc(db) job = await svc.get_send_job(job_id, current_user.tenant_id) if not current_user.is_admin: perm_svc = PermissionService(db) if not await perm_svc.check_can_send(current_user, job.mailbox_id): raise ForbiddenError("Autorizzazione insufficiente per annullare questo invio") await svc.cancel_send_job(job_id, current_user.tenant_id) await log_audit( db, "pec.send_cancelled", tenant_id=current_user.tenant_id, user_id=current_user.id, resource_type="send_job", resource_id=job_id, ip_address=get_real_ip(request), user_agent=request.headers.get("user-agent"), payload={ "job_id": str(job_id), "mailbox_id": str(job.mailbox_id), "previous_status": job.status, }, ) await db.commit()