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