This commit is contained in:
2026-03-18 18:16:44 +01:00
parent c89c08c397
commit b3c8b77f12
20 changed files with 1934 additions and 36 deletions
+157
View File
@@ -0,0 +1,157 @@
"""
Router API Invio PEC (Fase 4).
Endpoint:
POST /send invia una nuova PEC (crea Message + SendJob, accoda job)
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 uuid
from typing import Annotated
from fastapi import APIRouter, Query, status
from app.core.exceptions import ForbiddenError
from app.dependencies import AdminUser, CurrentUser, DB
from app.schemas.send import SendJobListResponse, SendJobResponse, SendPecRequest
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)
# ─── Endpoints ────────────────────────────────────────────────────────────────
@router.post(
"",
response_model=SendJobResponse,
status_code=status.HTTP_201_CREATED,
summary="Invia una PEC",
description=(
"Crea un messaggio PEC in uscita e accoda il job di invio SMTP. "
"Il job viene eseguito in background con retry automatico. "
"Richiede permesso **can_send** sulla casella (gli admin possono inviare da qualsiasi casella del tenant)."
),
)
async def create_send_job(
data: SendPecRequest,
current_user: CurrentUser,
db: DB,
) -> SendJobResponse:
svc = _svc(db)
job = await svc.create_send_job(current_user=current_user, data=data)
await db.commit()
# Refresh per ottenere tutti i valori default dal DB
await db.refresh(job)
return _job_response(job)
@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:
"""
Elenca i job di invio del tenant.
Gli admin vedono tutti i job; gli operatori vedono solo i job
delle caselle su cui hanno permesso can_read.
"""
svc = _svc(db)
# Filtro opzionale per casella: verifica accesso se non admin
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,
)
@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:
"""Recupera lo stato di un singolo job di invio."""
svc = _svc(db)
job = await svc.get_send_job(job_id, current_user.tenant_id)
# Verifica accesso alla casella se non admin
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)
@router.delete(
"/jobs/{job_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Annulla job di invio",
)
async def cancel_send_job(
job_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> None:
"""
Annulla un job di invio se è ancora in stato **pending** o **retrying**.
Non è possibile annullare un invio già partito (stato sending) o
completato (sent).
"""
svc = _svc(db)
# Verifica che l'utente possa agire su questo job
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 db.commit()
+20 -9
View File
@@ -21,6 +21,23 @@ security = HTTPBearer()
# ─── Database con RLS ─────────────────────────────────────────────────────────
async def _set_rls_tenant_id(db: AsyncSession, tenant_id: uuid.UUID) -> None:
"""
Imposta la variabile di sessione PostgreSQL per RLS.
È un no-op su SQLite (test environment) poiché SQLite non supporta
il comando SET LOCAL né il concetto di Row Level Security.
"""
try:
await db.execute(
text(f"SET LOCAL app.current_tenant_id = '{tenant_id!s}'")
)
except Exception:
# SQLite (usato nei test di integrazione) non supporta SET LOCAL.
# In produzione (PostgreSQL) questo comando funziona sempre.
pass
async def get_db_with_rls(
tenant_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
@@ -29,10 +46,7 @@ async def get_db_with_rls(
Imposta la variabile di sessione PostgreSQL per RLS.
Da usare dopo aver estratto il tenant_id dall'utente autenticato.
"""
await db.execute(
text("SET LOCAL app.current_tenant_id = :tenant_id"),
{"tenant_id": str(tenant_id)},
)
await _set_rls_tenant_id(db, tenant_id)
return db
@@ -68,11 +82,8 @@ async def get_current_user(
except ValueError:
raise TokenInvalidError()
# Imposta RLS per questo tenant
# SET LOCAL non supporta parametri $1, usiamo text() con valore inline
await db.execute(
text(f"SET LOCAL app.current_tenant_id = '{tenant_id!s}'")
)
# Imposta RLS per questo tenant (no-op su SQLite/test)
await _set_rls_tenant_id(db, tenant_id)
# Carica utente
result = await db.execute(
+5 -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, tenants, users, ws
from app.api.v1 import auth, mailboxes, 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
@@ -48,6 +48,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
await redis_task
except asyncio.CancelledError:
pass
# Chiudi pool arq se aperto
from app.services.send_service import close_arq_pool
await close_arq_pool()
await engine.dispose()
logger.info("🛑 PecFlow Backend fermato")
@@ -85,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(send.router, prefix=API_PREFIX)
app.include_router(ws.router, prefix=API_PREFIX)
+81
View File
@@ -0,0 +1,81 @@
"""
Schemi Pydantic per l'invio PEC (Fase 4).
"""
import uuid
from datetime import datetime
from pydantic import BaseModel, EmailStr, field_validator
class SendPecRequest(BaseModel):
"""
Richiesta di invio PEC.
Accettato come JSON body; gli allegati vengono gestiti
in una fase successiva tramite endpoint multipart dedicato.
"""
mailbox_id: uuid.UUID
"""Casella PEC mittente."""
to_addresses: list[EmailStr]
"""Destinatari principali (almeno uno)."""
cc_addresses: list[EmailStr] = []
"""Destinatari in copia (opzionale)."""
subject: str
"""Oggetto del messaggio."""
body_text: str = ""
"""Corpo in testo semplice."""
body_html: str | None = None
"""Corpo HTML (opzionale)."""
reply_to_message_id: uuid.UUID | None = None
"""UUID del messaggio a cui si risponde (per threading, opzionale)."""
@field_validator("to_addresses")
@classmethod
def at_least_one_recipient(cls, v: list[EmailStr]) -> list[EmailStr]:
if not v:
raise ValueError("Almeno un destinatario è obbligatorio")
return v
@field_validator("subject")
@classmethod
def subject_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("Il campo Oggetto non può essere vuoto")
return v.strip()
class SendJobResponse(BaseModel):
"""Stato di un job di invio PEC."""
model_config = {"from_attributes": True}
id: uuid.UUID
tenant_id: uuid.UUID
mailbox_id: uuid.UUID
message_id: uuid.UUID | None = None
status: str
"""Stato: pending | sending | sent | failed | retrying"""
attempt_count: int
max_attempts: int
next_retry_at: datetime | None = None
last_error: str | None = None
queued_at: datetime
sent_at: datetime | None = None
created_by: uuid.UUID | None = None
class SendJobListResponse(BaseModel):
"""Lista paginata di job di invio."""
items: list[SendJobResponse]
total: int
page: int
page_size: int
+250
View File
@@ -0,0 +1,250 @@
"""
SendService logica di business per l'invio PEC (Fase 4).
Responsabilità:
1. Valida permessi (check_can_send) sulla casella selezionata
2. Crea il record Message (direction=outbound, state=queued)
3. Crea il record SendJob (status=pending)
4. Enqueue il job arq 'send_pec' tramite il pool Redis/arq
5. Ritorna SendJobResponse
Il worker (arq) gestisce la connessione SMTP, il retry e la scrittura
del raw EML su MinIO.
"""
import asyncio
import uuid
from datetime import datetime, timezone
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ForbiddenError, NotFoundError
from app.models.mailbox import Mailbox
from app.models.message import Message, SendJob
from app.models.user import User
from app.schemas.send import SendJobResponse, SendPecRequest
from app.services.permission_service import PermissionService
# ─── Pool arq (singleton lazy) ────────────────────────────────────────────────
# Usato per fare enqueue dei job send_pec senza avviare un worker nel backend.
_arq_pool = None
_arq_pool_lock = asyncio.Lock()
async def _get_arq_pool():
"""Restituisce il pool arq condiviso, creandolo se necessario."""
global _arq_pool
if _arq_pool is not None:
return _arq_pool
async with _arq_pool_lock:
if _arq_pool is None:
from arq import create_pool
from arq.connections import RedisSettings
from app.config import get_settings
settings = get_settings()
import urllib.parse
parsed = urllib.parse.urlparse(settings.redis_url)
rs = RedisSettings(
host=parsed.hostname or "localhost",
port=parsed.port or 6379,
database=int(parsed.path.lstrip("/") or "0"),
password=parsed.password or None,
)
_arq_pool = await create_pool(rs)
return _arq_pool
async def close_arq_pool() -> None:
"""Chiude il pool arq alla shutdown dell'applicazione."""
global _arq_pool
if _arq_pool is not None:
await _arq_pool.aclose()
_arq_pool = None
# ─── SendService ──────────────────────────────────────────────────────────────
class SendService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
# ── Crea e accoda un invio ────────────────────────────────────────────────
async def create_send_job(
self,
current_user: User,
data: SendPecRequest,
) -> SendJob:
"""
Crea Message + SendJob e accoda il job di invio.
Args:
current_user: utente autenticato che richiede l'invio
data: dati della PEC da inviare
Returns:
SendJob appena creato
Raises:
NotFoundError: casella non trovata o non appartenente al tenant
ForbiddenError: utente senza can_send sulla casella
"""
# ── Verifica casella ──────────────────────────────────────────────────
mailbox = await self.db.get(Mailbox, data.mailbox_id)
if (
not mailbox
or mailbox.tenant_id != current_user.tenant_id
or mailbox.status == "deleted"
):
raise NotFoundError("casella PEC mittente")
if mailbox.status != "active":
from app.core.exceptions import ForbiddenError
raise ForbiddenError(
f"La casella è in stato '{mailbox.status}' e non può inviare"
)
# ── Verifica permesso can_send ────────────────────────────────────────
if not current_user.is_admin:
perm_svc = PermissionService(self.db)
if not await perm_svc.check_can_send(current_user, data.mailbox_id):
raise ForbiddenError(
"Non hai il permesso di inviare da questa casella"
)
# ── Crea il messaggio outbound ────────────────────────────────────────
now = datetime.now(tz=timezone.utc)
message = Message(
tenant_id=current_user.tenant_id,
mailbox_id=data.mailbox_id,
direction="outbound",
pec_type="posta_certificata",
state="queued",
subject=data.subject,
from_address=mailbox.email_address,
to_addresses=[str(a) for a in data.to_addresses],
cc_addresses=[str(a) for a in data.cc_addresses] if data.cc_addresses else [],
body_text=data.body_text or "",
body_html=data.body_html,
has_attachments=False, # allegati in Fase 5
sent_at=None,
received_at=None,
)
# Collegamento a messaggio originale (per risposta/threading)
if data.reply_to_message_id:
parent = await self.db.get(Message, data.reply_to_message_id)
if parent and parent.tenant_id == current_user.tenant_id:
message.parent_message_id = data.reply_to_message_id
self.db.add(message)
await self.db.flush()
# ── Crea il SendJob ───────────────────────────────────────────────────
job = SendJob(
tenant_id=current_user.tenant_id,
mailbox_id=data.mailbox_id,
message_id=message.id,
status="pending",
attempt_count=0,
max_attempts=5,
created_by=current_user.id,
queued_at=now,
)
self.db.add(job)
await self.db.flush()
# ── Enqueue job arq ───────────────────────────────────────────────────
try:
arq_pool = await _get_arq_pool()
await arq_pool.enqueue_job("send_pec", str(job.id))
except Exception as e:
from app.core.logging import get_logger
logger = get_logger(__name__)
logger.warning(
f"[send_service] Impossibile enqueue send_pec job {job.id}: {e}. "
"Il job resterà in stato 'pending' per pickup manuale."
)
# Non alziamo eccezione: il job è nel DB e verrà processato
# dal cron di polling se disponibile
return job
# ── Lista job di invio ────────────────────────────────────────────────────
async def list_send_jobs(
self,
tenant_id: uuid.UUID,
page: int = 1,
page_size: int = 50,
mailbox_id: uuid.UUID | None = None,
status_filter: str | None = None,
) -> tuple[list[SendJob], int]:
"""Lista i job di invio del tenant con filtri opzionali."""
base_q = select(SendJob).where(SendJob.tenant_id == tenant_id)
if mailbox_id:
base_q = base_q.where(SendJob.mailbox_id == mailbox_id)
if status_filter:
base_q = base_q.where(SendJob.status == status_filter)
count_q = select(func.count()).select_from(base_q.subquery())
total = (await self.db.execute(count_q)).scalar_one()
items_q = (
base_q.order_by(SendJob.queued_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
result = await self.db.execute(items_q)
items = list(result.scalars().all())
return items, total
# ── Get singolo job ───────────────────────────────────────────────────────
async def get_send_job(
self,
job_id: uuid.UUID,
tenant_id: uuid.UUID,
) -> SendJob:
"""Carica un singolo SendJob verificando l'appartenenza al tenant."""
job = await self.db.get(SendJob, job_id)
if not job or job.tenant_id != tenant_id:
raise NotFoundError("job di invio")
return job
# ── Annulla job (solo se pending) ─────────────────────────────────────────
async def cancel_send_job(
self,
job_id: uuid.UUID,
tenant_id: uuid.UUID,
) -> SendJob:
"""
Annulla un job di invio se è ancora in stato 'pending'.
Non cancella il messaggio associato.
"""
job = await self.get_send_job(job_id, tenant_id)
if job.status not in ("pending", "retrying"):
raise ForbiddenError(
f"Impossibile annullare: il job è in stato '{job.status}'"
)
job.status = "failed"
job.last_error = "Annullato dall'utente"
# Aggiorna anche il messaggio
if job.message_id:
msg = await self.db.get(Message, job.message_id)
if msg and msg.state in ("queued", "draft"):
msg.state = "failed"
await self.db.flush()
return job