""" 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.services.audit_service import log_audit 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() await log_audit( self.db, "mailbox.created", tenant_id=tenant_id, user_id=created_by, resource_type="mailbox", resource_id=mailbox.id, payload={"email_address": mailbox.email_address}, ) 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() await log_audit( self.db, "mailbox.updated", tenant_id=tenant_id, resource_type="mailbox", resource_id=mailbox_id, payload={"mailbox_id": str(mailbox_id)}, ) 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) email = mailbox.email_address mailbox.status = "deleted" await self.db.flush() await log_audit( self.db, "mailbox.deleted", tenant_id=tenant_id, resource_type="mailbox", resource_id=mailbox_id, payload={"email_address": email}, ) # ─── 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, }