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)
+20 -2
View File
@@ -13,10 +13,11 @@ from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from slowapi.util import get_remote_address
from app.api.v1 import auth, permissions, tenants, users
from app.api.v1 import auth, mailboxes, permissions, tenants, users, ws
from app.config import get_settings
from app.core.logging import get_logger, setup_logging
from app.database import engine
from app.websocket.manager import redis_subscriber_loop
settings = get_settings()
logger = get_logger(__name__)
@@ -25,13 +26,28 @@ logger = get_logger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Gestione ciclo di vita dell'applicazione."""
import asyncio
setup_logging()
logger.info(
"🚀 PecFlow Backend avviato",
extra={"env": settings.app_env, "debug": settings.app_debug},
)
# Avvia il subscriber Redis per il forward degli eventi WebSocket
redis_task = asyncio.create_task(
redis_subscriber_loop(settings.redis_url),
name="redis-ws-subscriber",
)
yield
# Cleanup: chiudi connessioni DB
# Cleanup
redis_task.cancel()
try:
await redis_task
except asyncio.CancelledError:
pass
await engine.dispose()
logger.info("🛑 PecFlow Backend fermato")
@@ -68,6 +84,8 @@ app.include_router(auth.router, prefix=API_PREFIX)
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(ws.router, prefix=API_PREFIX)
# ─── Health check ─────────────────────────────────────────────────────────────
+118
View File
@@ -0,0 +1,118 @@
"""
Schemas Pydantic per le caselle PEC (mailboxes).
Le credenziali vengono accettate in chiaro dalla API e cifrate nel service.
"""
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, EmailStr, Field, field_validator
# ─── Request schemas ──────────────────────────────────────────────────────────
class MailboxCreateRequest(BaseModel):
"""Dati per creare una nuova casella PEC."""
email_address: EmailStr = Field(..., description="Indirizzo email PEC")
display_name: str | None = Field(None, max_length=255, description="Nome visualizzato")
provider: str | None = Field(None, max_length=100, description="Provider PEC (aruba, namirial...)")
# Credenziali IMAP (in chiaro, cifrate prima della persistenza)
imap_host: str = Field(..., min_length=1, max_length=255, description="Host IMAP")
imap_port: int = Field(993, ge=1, le=65535, description="Porta IMAP")
imap_user: str = Field(..., min_length=1, max_length=255, description="Username IMAP")
imap_pass: str = Field(..., min_length=1, description="Password IMAP")
imap_use_ssl: bool = Field(True, description="Usa SSL/TLS per IMAP")
# Credenziali SMTP (in chiaro, cifrate prima della persistenza)
smtp_host: str = Field(..., min_length=1, max_length=255, description="Host SMTP")
smtp_port: int = Field(465, ge=1, le=65535, description="Porta SMTP")
smtp_user: str = Field(..., min_length=1, max_length=255, description="Username SMTP")
smtp_pass: str = Field(..., min_length=1, description="Password SMTP")
smtp_use_tls: bool = Field(True, description="Usa TLS per SMTP")
@field_validator("imap_port", "smtp_port")
@classmethod
def validate_port(cls, v: int) -> int:
if v not in range(1, 65536):
raise ValueError("Porta non valida")
return v
class MailboxUpdateRequest(BaseModel):
"""Aggiornamento parziale di una casella PEC."""
display_name: str | None = Field(None, max_length=255)
provider: str | None = Field(None, max_length=100)
status: Literal["active", "paused"] | None = None
# Aggiornamento credenziali IMAP (opzionale)
imap_host: str | None = Field(None, min_length=1, max_length=255)
imap_port: int | None = Field(None, ge=1, le=65535)
imap_user: str | None = Field(None, min_length=1, max_length=255)
imap_pass: str | None = None
imap_use_ssl: bool | None = None
# Aggiornamento credenziali SMTP (opzionale)
smtp_host: str | None = Field(None, min_length=1, max_length=255)
smtp_port: int | None = Field(None, ge=1, le=65535)
smtp_user: str | None = Field(None, min_length=1, max_length=255)
smtp_pass: str | None = None
smtp_use_tls: bool | None = None
# ─── Response schemas ─────────────────────────────────────────────────────────
class MailboxResponse(BaseModel):
"""Risposta API casella PEC NON include mai le credenziali in chiaro."""
id: uuid.UUID
tenant_id: uuid.UUID
email_address: str
display_name: str | None
provider: str | None
# Info IMAP senza credenziali
imap_host: str
imap_port: int
imap_use_ssl: bool
# Info SMTP senza credenziali
smtp_host: str
smtp_port: int
smtp_use_tls: bool
# Stato sync
status: str
last_sync_at: datetime | None
last_sync_uid: int | None
sync_error_msg: str | None
sync_error_count: int
created_by: uuid.UUID | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class MailboxListResponse(BaseModel):
items: list[MailboxResponse]
total: int
page: int
page_size: int
class ConnectionTestRequest(BaseModel):
"""Test connessione IMAP/SMTP per una casella esistente o per credenziali nuove."""
protocol: Literal["imap", "smtp"] = Field("imap", description="Protocollo da testare")
class ConnectionTestResult(BaseModel):
success: bool
message: str
latency_ms: float | None = None
capabilities: list[str] | None = None # Solo per IMAP
+361
View File
@@ -0,0 +1,361 @@
"""
Servizio caselle PEC CRUD con cifratura AES-256-GCM delle credenziali (ADR-002).
"""
import time
import uuid
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError
from app.core.security import decrypt_credential, encrypt_credential
from app.models.mailbox import Mailbox
from app.models.tenant import Tenant
from app.schemas.mailbox import (
ConnectionTestRequest,
ConnectionTestResult,
MailboxCreateRequest,
MailboxUpdateRequest,
)
class MailboxService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
# ─── CRUD ─────────────────────────────────────────────────────────────────
async def create_mailbox(
self,
tenant_id: uuid.UUID,
data: MailboxCreateRequest,
created_by: uuid.UUID,
) -> Mailbox:
"""Crea una nuova casella PEC cifrando le credenziali."""
# Verifica limite caselle del tenant
tenant = await self.db.get(Tenant, tenant_id)
if not tenant:
raise NotFoundError("tenant")
count_result = await self.db.execute(
select(func.count(Mailbox.id)).where(
Mailbox.tenant_id == tenant_id,
Mailbox.status != "deleted",
)
)
current_count = count_result.scalar_one()
if current_count >= tenant.max_mailboxes:
raise ForbiddenError(
f"Limite caselle raggiunto ({tenant.max_mailboxes}). "
"Aggiorna il piano per aggiungerne altre."
)
# Verifica unicità email nel tenant
existing = await self.db.execute(
select(Mailbox.id).where(
Mailbox.tenant_id == tenant_id,
Mailbox.email_address == str(data.email_address),
Mailbox.status != "deleted",
)
)
if existing.scalar_one_or_none():
raise ConflictError("Casella con questo indirizzo già presente nel tenant")
mailbox = Mailbox(
tenant_id=tenant_id,
email_address=str(data.email_address),
display_name=data.display_name,
provider=data.provider,
# Cifra tutte le credenziali IMAP
imap_host_enc=encrypt_credential(data.imap_host),
imap_port_enc=encrypt_credential(str(data.imap_port)),
imap_user_enc=encrypt_credential(data.imap_user),
imap_pass_enc=encrypt_credential(data.imap_pass),
imap_use_ssl=data.imap_use_ssl,
# Cifra tutte le credenziali SMTP
smtp_host_enc=encrypt_credential(data.smtp_host),
smtp_port_enc=encrypt_credential(str(data.smtp_port)),
smtp_user_enc=encrypt_credential(data.smtp_user),
smtp_pass_enc=encrypt_credential(data.smtp_pass),
smtp_use_tls=data.smtp_use_tls,
created_by=created_by,
status="active",
)
self.db.add(mailbox)
await self.db.flush()
return mailbox
async def list_mailboxes(
self,
tenant_id: uuid.UUID,
page: int = 1,
page_size: int = 50,
include_deleted: bool = False,
) -> tuple[list[Mailbox], int]:
"""Elenca le caselle di un tenant con paginazione."""
base_q = select(Mailbox).where(Mailbox.tenant_id == tenant_id)
if not include_deleted:
base_q = base_q.where(Mailbox.status != "deleted")
# Count totale
count_q = select(func.count()).select_from(base_q.subquery())
total = (await self.db.execute(count_q)).scalar_one()
# Pagina
items_q = (
base_q.order_by(Mailbox.created_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
async def get_mailbox(
self,
mailbox_id: uuid.UUID,
tenant_id: uuid.UUID,
) -> Mailbox:
"""Carica una singola casella verificando l'appartenenza al tenant."""
mailbox = await self.db.get(Mailbox, mailbox_id)
if not mailbox or mailbox.tenant_id != tenant_id or mailbox.status == "deleted":
raise NotFoundError("casella")
return mailbox
async def update_mailbox(
self,
mailbox_id: uuid.UUID,
tenant_id: uuid.UUID,
data: MailboxUpdateRequest,
) -> Mailbox:
"""Aggiornamento parziale di una casella. Ri-cifra le credenziali se modificate."""
mailbox = await self.get_mailbox(mailbox_id, tenant_id)
if data.display_name is not None:
mailbox.display_name = data.display_name
if data.provider is not None:
mailbox.provider = data.provider
if data.status is not None:
mailbox.status = data.status
# IMAP
if data.imap_host is not None:
mailbox.imap_host_enc = encrypt_credential(data.imap_host)
if data.imap_port is not None:
mailbox.imap_port_enc = encrypt_credential(str(data.imap_port))
if data.imap_user is not None:
mailbox.imap_user_enc = encrypt_credential(data.imap_user)
if data.imap_pass is not None:
mailbox.imap_pass_enc = encrypt_credential(data.imap_pass)
if data.imap_use_ssl is not None:
mailbox.imap_use_ssl = data.imap_use_ssl
# SMTP
if data.smtp_host is not None:
mailbox.smtp_host_enc = encrypt_credential(data.smtp_host)
if data.smtp_port is not None:
mailbox.smtp_port_enc = encrypt_credential(str(data.smtp_port))
if data.smtp_user is not None:
mailbox.smtp_user_enc = encrypt_credential(data.smtp_user)
if data.smtp_pass is not None:
mailbox.smtp_pass_enc = encrypt_credential(data.smtp_pass)
if data.smtp_use_tls is not None:
mailbox.smtp_use_tls = data.smtp_use_tls
# Reset error state se il tenant ha aggiornato le credenziali
if any(
v is not None
for v in [data.imap_host, data.imap_pass, data.imap_user, data.imap_port]
):
mailbox.sync_error_count = 0
mailbox.sync_error_msg = None
if mailbox.status == "error":
mailbox.status = "active"
await self.db.flush()
return mailbox
async def delete_mailbox(
self,
mailbox_id: uuid.UUID,
tenant_id: uuid.UUID,
) -> None:
"""Soft-delete: imposta status=deleted."""
mailbox = await self.get_mailbox(mailbox_id, tenant_id)
mailbox.status = "deleted"
await self.db.flush()
# ─── Decrypt helpers (usati internamente e dal worker) ───────────────────
@staticmethod
def decrypt_imap_credentials(mailbox: Mailbox) -> dict:
"""Decifra le credenziali IMAP per uso interno."""
return {
"host": decrypt_credential(mailbox.imap_host_enc),
"port": int(decrypt_credential(mailbox.imap_port_enc)),
"user": decrypt_credential(mailbox.imap_user_enc),
"password": decrypt_credential(mailbox.imap_pass_enc),
"use_ssl": mailbox.imap_use_ssl,
}
@staticmethod
def decrypt_smtp_credentials(mailbox: Mailbox) -> dict:
"""Decifra le credenziali SMTP per uso interno."""
return {
"host": decrypt_credential(mailbox.smtp_host_enc),
"port": int(decrypt_credential(mailbox.smtp_port_enc)),
"user": decrypt_credential(mailbox.smtp_user_enc),
"password": decrypt_credential(mailbox.smtp_pass_enc),
"use_tls": mailbox.smtp_use_tls,
}
# ─── Test connessione ─────────────────────────────────────────────────────
async def test_connection(
self,
mailbox_id: uuid.UUID,
tenant_id: uuid.UUID,
data: ConnectionTestRequest,
) -> ConnectionTestResult:
"""
Testa la connessione IMAP o SMTP della casella.
NON invia messaggi (conforme alle istruzioni: solo test connessione).
"""
mailbox = await self.get_mailbox(mailbox_id, tenant_id)
if data.protocol == "imap":
return await self._test_imap(mailbox)
else:
return await self._test_smtp(mailbox)
async def _test_imap(self, mailbox: Mailbox) -> ConnectionTestResult:
"""Testa connessione IMAP LOGIN + LIST + LOGOUT."""
import asyncio
try:
import aioimaplib
except ImportError:
return ConnectionTestResult(
success=False,
message="aioimaplib non installato nel worker. Eseguire dal worker container.",
)
creds = self.decrypt_imap_credentials(mailbox)
start = time.monotonic()
try:
if creds["use_ssl"]:
client = aioimaplib.IMAP4_SSL(
host=creds["host"], port=creds["port"], timeout=15
)
else:
client = aioimaplib.IMAP4(
host=creds["host"], port=creds["port"], timeout=15
)
await client.wait_hello_from_server()
status, _ = await client.login(creds["user"], creds["password"])
if status != "OK":
await client.logout()
return ConnectionTestResult(
success=False,
message=f"Login fallito: {status}",
latency_ms=round((time.monotonic() - start) * 1000, 1),
)
caps = list(client.protocol.capabilities) if hasattr(client.protocol, "capabilities") else []
await client.logout()
latency = round((time.monotonic() - start) * 1000, 1)
return ConnectionTestResult(
success=True,
message="Connessione IMAP riuscita",
latency_ms=latency,
capabilities=caps,
)
except asyncio.TimeoutError:
return ConnectionTestResult(
success=False,
message="Timeout connessione IMAP (15s)",
latency_ms=round((time.monotonic() - start) * 1000, 1),
)
except Exception as e:
return ConnectionTestResult(
success=False,
message=f"Errore connessione IMAP: {e}",
latency_ms=round((time.monotonic() - start) * 1000, 1),
)
async def _test_smtp(self, mailbox: Mailbox) -> ConnectionTestResult:
"""Testa connessione SMTP solo EHLO/NOOP, nessun invio (ADR-002)."""
try:
import aiosmtplib
except ImportError:
return ConnectionTestResult(
success=False,
message="aiosmtplib non installato. Installare nella fase SMTP.",
)
creds = self.decrypt_smtp_credentials(mailbox)
start = time.monotonic()
try:
smtp = aiosmtplib.SMTP(
hostname=creds["host"],
port=creds["port"],
use_tls=creds["use_tls"],
timeout=15,
)
await smtp.connect()
await smtp.login(creds["user"], creds["password"])
await smtp.quit()
return ConnectionTestResult(
success=True,
message="Connessione SMTP riuscita",
latency_ms=round((time.monotonic() - start) * 1000, 1),
)
except Exception as e:
return ConnectionTestResult(
success=False,
message=f"Errore connessione SMTP: {e}",
latency_ms=round((time.monotonic() - start) * 1000, 1),
)
# ─── Helper per costruire MailboxResponse ─────────────────────────────────
@staticmethod
def to_response_dict(mailbox: Mailbox) -> dict:
"""
Costruisce un dict con i dati decrittati per la risposta API.
Le credenziali (user/pass) non sono incluse.
"""
imap_host = decrypt_credential(mailbox.imap_host_enc)
imap_port = int(decrypt_credential(mailbox.imap_port_enc))
smtp_host = decrypt_credential(mailbox.smtp_host_enc)
smtp_port = int(decrypt_credential(mailbox.smtp_port_enc))
return {
"id": mailbox.id,
"tenant_id": mailbox.tenant_id,
"email_address": mailbox.email_address,
"display_name": mailbox.display_name,
"provider": mailbox.provider,
"imap_host": imap_host,
"imap_port": imap_port,
"imap_use_ssl": mailbox.imap_use_ssl,
"smtp_host": smtp_host,
"smtp_port": smtp_port,
"smtp_use_tls": mailbox.smtp_use_tls,
"status": mailbox.status,
"last_sync_at": mailbox.last_sync_at,
"last_sync_uid": mailbox.last_sync_uid,
"sync_error_msg": mailbox.sync_error_msg,
"sync_error_count": mailbox.sync_error_count,
"created_by": mailbox.created_by,
"created_at": mailbox.created_at,
"updated_at": mailbox.updated_at,
}
+1
View File
@@ -0,0 +1 @@
# WebSocket manager
+144
View File
@@ -0,0 +1,144 @@
"""
WebSocket Connection Manager.
Gestisce le connessioni WebSocket raggruppate per tenant.
Il worker pubblica eventi su Redis (canale ws:tenant:<tenant_id>);
un task asyncio in background ascolta Redis e fa forward ai client WS.
Architettura fan-out:
Worker → Redis PUBLISH ws:tenant:<tid> <payload>
Backend → Redis SUBSCRIBE → forward a tutti i WebSocket del tenant
"""
import asyncio
import json
import uuid
from collections import defaultdict
from fastapi import WebSocket
from app.core.logging import get_logger
logger = get_logger(__name__)
class ConnectionManager:
"""
Gestisce N connessioni WebSocket per M tenant.
Thread-safe per uso in contesto asyncio.
"""
def __init__(self) -> None:
# tenant_id (str) → set of WebSocket
self._connections: dict[str, set[WebSocket]] = defaultdict(set)
# Lock per modifiche al dizionario
self._lock = asyncio.Lock()
async def connect(self, websocket: WebSocket, tenant_id: uuid.UUID) -> None:
"""Registra una nuova connessione WS per il tenant."""
await websocket.accept()
async with self._lock:
self._connections[str(tenant_id)].add(websocket)
logger.info(
"WebSocket connesso",
extra={"tenant_id": str(tenant_id), "total": self._count(tenant_id)},
)
async def disconnect(self, websocket: WebSocket, tenant_id: uuid.UUID) -> None:
"""Rimuove una connessione WS dal tenant."""
async with self._lock:
self._connections[str(tenant_id)].discard(websocket)
if not self._connections[str(tenant_id)]:
del self._connections[str(tenant_id)]
logger.info(
"WebSocket disconnesso",
extra={"tenant_id": str(tenant_id)},
)
async def broadcast_to_tenant(
self, tenant_id: uuid.UUID | str, event: dict
) -> None:
"""
Invia un evento JSON a tutti i client connessi del tenant.
Le connessioni morte vengono rimosse silenziosamente.
"""
tid = str(tenant_id)
async with self._lock:
connections = set(self._connections.get(tid, set()))
if not connections:
return
payload = json.dumps(event, default=str)
dead = set()
for ws in connections:
try:
await ws.send_text(payload)
except Exception:
dead.add(ws)
if dead:
async with self._lock:
self._connections[tid] -= dead
def _count(self, tenant_id: uuid.UUID) -> int:
return len(self._connections.get(str(tenant_id), set()))
@property
def total_connections(self) -> int:
return sum(len(v) for v in self._connections.values())
# Istanza singleton importata da main.py e dal Redis listener
manager = ConnectionManager()
# ─── Redis subscriber (background task) ──────────────────────────────────────
async def redis_subscriber_loop(redis_url: str) -> None:
"""
Task asyncio che si sottoscrive al canale Redis ws:* e
fa forward degli eventi ai client WebSocket del tenant corretto.
Pubblicato dal worker con:
PUBLISH ws:tenant:<tenant_id> <json_payload>
"""
import redis.asyncio as aioredis
logger.info("Redis WS subscriber avviato", extra={"url": redis_url})
while True:
try:
client = aioredis.from_url(redis_url, decode_responses=True)
pubsub = client.pubsub()
await pubsub.psubscribe("ws:tenant:*") # pattern subscription
async for message in pubsub.listen():
if message["type"] not in ("pmessage", "message"):
continue
# Estrae tenant_id dal canale: ws:tenant:<uuid>
channel: str = message.get("channel", "")
if not channel.startswith("ws:tenant:"):
continue
tenant_id_str = channel.removeprefix("ws:tenant:")
try:
tenant_uuid = uuid.UUID(tenant_id_str)
except ValueError:
continue
try:
payload = json.loads(message["data"])
except (json.JSONDecodeError, KeyError):
continue
await manager.broadcast_to_tenant(tenant_uuid, payload)
except asyncio.CancelledError:
logger.info("Redis WS subscriber terminato")
return
except Exception as e:
logger.error(f"Redis WS subscriber errore: {e}. Riconnessione in 5s...")
await asyncio.sleep(5)
+9
View File
@@ -40,6 +40,15 @@ dependencies = [
# Storage MinIO/S3
"miniopy-async>=1.21.0",
# IMAP async (per test connessione nel backend + mailbox service)
"aioimaplib>=2.0.0",
# Redis (async per WebSocket pub/sub)
"redis[asyncio]>=5.0.0",
# WebSocket
"websockets>=12.0",
# Utilities
"python-multipart>=0.0.9", # upload file
"python-dotenv>=1.0.0",
Binary file not shown.