This commit is contained in:
2026-03-18 17:30:13 +01:00
parent 58a233236c
commit d80d912fb3
36 changed files with 3502 additions and 4 deletions
+202
View File
@@ -0,0 +1,202 @@
"""
Router caselle PEC CRUD + test connessione.
Permessi:
- admin: CRUD completo su tutte le caselle del tenant
- altri ruoli: solo lettura (caselle accessibili tramite PermissionService)
"""
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ForbiddenError
from app.database import get_db
from app.dependencies import AdminUser, CurrentUser, DB
from app.schemas.mailbox import (
ConnectionTestRequest,
ConnectionTestResult,
MailboxCreateRequest,
MailboxListResponse,
MailboxResponse,
MailboxUpdateRequest,
)
from app.services.mailbox_service import MailboxService
router = APIRouter(prefix="/mailboxes", tags=["Mailboxes"])
# ─── Helpers ──────────────────────────────────────────────────────────────────
def _svc(db: AsyncSession) -> MailboxService:
return MailboxService(db)
def _build_response(mailbox, svc: MailboxService) -> MailboxResponse:
return MailboxResponse(**MailboxService.to_response_dict(mailbox))
# ─── Endpoints ───────────────────────────────────────────────────────────────
@router.post("", response_model=MailboxResponse, status_code=status.HTTP_201_CREATED)
async def create_mailbox(
data: MailboxCreateRequest,
current_user: AdminUser,
db: DB,
) -> MailboxResponse:
"""
Crea una nuova casella PEC.
Richiede ruolo **admin** o **super_admin**.
Le credenziali vengono cifrate con AES-256-GCM prima della persistenza.
"""
svc = _svc(db)
mailbox = await svc.create_mailbox(
tenant_id=current_user.tenant_id,
data=data,
created_by=current_user.id,
)
await db.commit()
return _build_response(mailbox, svc)
@router.get("", response_model=MailboxListResponse)
async def list_mailboxes(
current_user: CurrentUser,
db: DB,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
) -> MailboxListResponse:
"""
Elenca le caselle PEC.
- Admin vede tutte le caselle del tenant.
- Operatori vedono solo le caselle su cui hanno permesso can_read.
"""
svc = _svc(db)
if current_user.is_admin:
# Admin: tutte le caselle del tenant
items, total = await svc.list_mailboxes(
tenant_id=current_user.tenant_id,
page=page,
page_size=page_size,
)
else:
# Operatori: caselle con permesso
from app.services.permission_service import PermissionService
perm_svc = PermissionService(db)
visible_ids = await perm_svc.get_visible_mailboxes(current_user)
if not visible_ids:
return MailboxListResponse(items=[], total=0, page=page, page_size=page_size)
from sqlalchemy import select
from app.models.mailbox import Mailbox
from sqlalchemy import func
q = (
select(Mailbox)
.where(
Mailbox.id.in_(visible_ids),
Mailbox.status != "deleted",
)
.order_by(Mailbox.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
count_q = select(func.count()).select_from(
select(Mailbox.id).where(
Mailbox.id.in_(visible_ids),
Mailbox.status != "deleted",
).subquery()
)
result = await db.execute(q)
items = list(result.scalars().all())
total = (await db.execute(count_q)).scalar_one()
return MailboxListResponse(
items=[_build_response(m, svc) for m in items],
total=total,
page=page,
page_size=page_size,
)
@router.get("/{mailbox_id}", response_model=MailboxResponse)
async def get_mailbox(
mailbox_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> MailboxResponse:
"""Carica una casella PEC per ID."""
svc = _svc(db)
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, mailbox_id):
raise ForbiddenError("Accesso alla casella non autorizzato")
mailbox = await svc.get_mailbox(mailbox_id, current_user.tenant_id)
return _build_response(mailbox, svc)
@router.put("/{mailbox_id}", response_model=MailboxResponse)
async def update_mailbox(
mailbox_id: uuid.UUID,
data: MailboxUpdateRequest,
current_user: AdminUser,
db: DB,
) -> MailboxResponse:
"""
Aggiorna una casella PEC.
Richiede ruolo **admin** o **super_admin**.
Se vengono fornite nuove credenziali, vengono ri-cifrate.
"""
svc = _svc(db)
mailbox = await svc.update_mailbox(
mailbox_id=mailbox_id,
tenant_id=current_user.tenant_id,
data=data,
)
await db.commit()
return _build_response(mailbox, svc)
@router.delete("/{mailbox_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_mailbox(
mailbox_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> None:
"""
Soft-delete di una casella PEC (status=deleted).
Richiede ruolo **admin**.
"""
svc = _svc(db)
await svc.delete_mailbox(mailbox_id, current_user.tenant_id)
await db.commit()
@router.post(
"/{mailbox_id}/test-connection",
response_model=ConnectionTestResult,
)
async def test_mailbox_connection(
mailbox_id: uuid.UUID,
data: ConnectionTestRequest,
current_user: AdminUser,
db: DB,
) -> ConnectionTestResult:
"""
Testa la connessione IMAP o SMTP della casella.
Non invia messaggi solo verifica la connessione.
Richiede ruolo **admin**.
"""
svc = _svc(db)
return await svc.test_connection(
mailbox_id=mailbox_id,
tenant_id=current_user.tenant_id,
data=data,
)
+114
View File
@@ -0,0 +1,114 @@
"""
WebSocket endpoint connessione real-time per aggiornamenti inbox.
URL: ws://<host>/api/v1/ws?token=<jwt_access_token>
Protocollo messaggi (dal server al client):
{
"type": "mailbox:new_message",
"mailbox_id": "<uuid>",
"message_id": "<uuid>",
"subject": "...",
"from_address": "...",
"received_at": "2026-03-18T14:00:00Z"
}
{
"type": "mailbox:sync_error",
"mailbox_id": "<uuid>",
"error": "...",
"status": "error"
}
{ "type": "ping" } ← heartbeat ogni 30s per mantenere connessione viva
"""
import asyncio
import uuid
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
from jose import JWTError
from app.core.security import decode_token
from app.core.logging import get_logger
from app.websocket.manager import manager
router = APIRouter(tags=["WebSocket"])
logger = get_logger(__name__)
@router.websocket("/ws")
async def websocket_endpoint(
websocket: WebSocket,
token: str = Query(..., description="JWT access token per autenticazione"),
) -> None:
"""
WebSocket endpoint autenticato via JWT query param.
Invia eventi real-time per il tenant dell'utente connesso.
"""
# Autenticazione: valida JWT
try:
payload = decode_token(token)
if payload.get("type") != "access":
await websocket.close(code=4001, reason="Token non valido")
return
tenant_id_str = payload.get("tid")
user_id_str = payload.get("sub")
if not tenant_id_str or not user_id_str:
await websocket.close(code=4001, reason="Token malformato")
return
tenant_id = uuid.UUID(tenant_id_str)
user_id = uuid.UUID(user_id_str)
except (JWTError, ValueError):
await websocket.close(code=4001, reason="Token non valido")
return
# Connessione accettata
await manager.connect(websocket, tenant_id)
logger.info(
"WS autenticato",
extra={"user_id": str(user_id), "tenant_id": str(tenant_id)},
)
# Invia ack di connessione
try:
await websocket.send_json({
"type": "connected",
"tenant_id": str(tenant_id),
"user_id": str(user_id),
})
except Exception:
await manager.disconnect(websocket, tenant_id)
return
# Heartbeat task
async def send_pings() -> None:
while True:
try:
await asyncio.sleep(30)
await websocket.send_json({"type": "ping"})
except Exception:
break
ping_task = asyncio.create_task(send_pings())
try:
# Mantieni la connessione aperta, gestisci messaggi client (pong, ecc.)
while True:
try:
data = await asyncio.wait_for(websocket.receive_text(), timeout=35.0)
# Gestisci pong dal client (opzionale)
if data == "pong":
continue
except asyncio.TimeoutError:
# Nessun messaggio dal client in 35s: connessione morta
break
except WebSocketDisconnect:
break
finally:
ping_task.cancel()
await manager.disconnect(websocket, tenant_id)