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
+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,
}