mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
Fase 2
This commit is contained in:
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# WebSocket manager
|
||||
@@ -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)
|
||||
@@ -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.
Reference in New Issue
Block a user