""" SendService – logica di business per l'invio PEC (Fase 4). Responsabilità: 1. Valida permessi (check_can_send) sulla casella selezionata 2. Crea il record Message (direction=outbound, state=queued) 3. Crea il record SendJob (status=pending) 4. Enqueue il job arq 'send_pec' tramite il pool Redis/arq 5. Ritorna SendJobResponse Il worker (arq) gestisce la connessione SMTP, il retry e la scrittura del raw EML su MinIO. """ import asyncio import uuid from datetime import datetime, timezone from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import ForbiddenError, NotFoundError from app.models.mailbox import Mailbox from app.models.message import Message, SendJob from app.models.user import User from app.schemas.send import SendJobResponse, SendPecRequest from app.services.permission_service import PermissionService # ─── Pool arq (singleton lazy) ──────────────────────────────────────────────── # Usato per fare enqueue dei job send_pec senza avviare un worker nel backend. _arq_pool = None _arq_pool_lock = asyncio.Lock() async def _get_arq_pool(): """Restituisce il pool arq condiviso, creandolo se necessario.""" global _arq_pool if _arq_pool is not None: return _arq_pool async with _arq_pool_lock: if _arq_pool is None: from arq import create_pool from arq.connections import RedisSettings from app.config import get_settings settings = get_settings() import urllib.parse parsed = urllib.parse.urlparse(settings.redis_url) rs = RedisSettings( host=parsed.hostname or "localhost", port=parsed.port or 6379, database=int(parsed.path.lstrip("/") or "0"), password=parsed.password or None, ) _arq_pool = await create_pool(rs) return _arq_pool async def close_arq_pool() -> None: """Chiude il pool arq alla shutdown dell'applicazione.""" global _arq_pool if _arq_pool is not None: await _arq_pool.aclose() _arq_pool = None # ─── SendService ────────────────────────────────────────────────────────────── class SendService: def __init__(self, db: AsyncSession) -> None: self.db = db # ── Crea e accoda un invio ──────────────────────────────────────────────── async def create_send_job( self, current_user: User, data: SendPecRequest, ) -> SendJob: """ Crea Message + SendJob e accoda il job di invio. Args: current_user: utente autenticato che richiede l'invio data: dati della PEC da inviare Returns: SendJob appena creato Raises: NotFoundError: casella non trovata o non appartenente al tenant ForbiddenError: utente senza can_send sulla casella """ # ── Verifica casella ────────────────────────────────────────────────── mailbox = await self.db.get(Mailbox, data.mailbox_id) if ( not mailbox or mailbox.tenant_id != current_user.tenant_id or mailbox.status == "deleted" ): raise NotFoundError("casella PEC mittente") if mailbox.status != "active": from app.core.exceptions import ForbiddenError raise ForbiddenError( f"La casella è in stato '{mailbox.status}' e non può inviare" ) # ── Verifica permesso can_send ──────────────────────────────────────── if not current_user.is_admin: perm_svc = PermissionService(self.db) if not await perm_svc.check_can_send(current_user, data.mailbox_id): raise ForbiddenError( "Non hai il permesso di inviare da questa casella" ) # ── Crea il messaggio outbound ──────────────────────────────────────── now = datetime.now(tz=timezone.utc) message = Message( tenant_id=current_user.tenant_id, mailbox_id=data.mailbox_id, direction="outbound", pec_type="posta_certificata", state="queued", subject=data.subject, from_address=mailbox.email_address, to_addresses=[str(a) for a in data.to_addresses], cc_addresses=[str(a) for a in data.cc_addresses] if data.cc_addresses else [], body_text=data.body_text or "", body_html=data.body_html, has_attachments=False, # allegati in Fase 5 sent_at=None, received_at=None, ) # Collegamento a messaggio originale (per risposta/threading) if data.reply_to_message_id: parent = await self.db.get(Message, data.reply_to_message_id) if parent and parent.tenant_id == current_user.tenant_id: message.parent_message_id = data.reply_to_message_id self.db.add(message) await self.db.flush() # ── Crea il SendJob ─────────────────────────────────────────────────── job = SendJob( tenant_id=current_user.tenant_id, mailbox_id=data.mailbox_id, message_id=message.id, status="pending", attempt_count=0, max_attempts=5, created_by=current_user.id, queued_at=now, ) self.db.add(job) await self.db.flush() # ── Enqueue job arq ─────────────────────────────────────────────────── try: arq_pool = await _get_arq_pool() await arq_pool.enqueue_job("send_pec", str(job.id)) except Exception as e: from app.core.logging import get_logger logger = get_logger(__name__) logger.warning( f"[send_service] Impossibile enqueue send_pec job {job.id}: {e}. " "Il job resterà in stato 'pending' per pickup manuale." ) # Non alziamo eccezione: il job è nel DB e verrà processato # dal cron di polling se disponibile return job # ── Lista job di invio ──────────────────────────────────────────────────── async def list_send_jobs( self, tenant_id: uuid.UUID, page: int = 1, page_size: int = 50, mailbox_id: uuid.UUID | None = None, status_filter: str | None = None, ) -> tuple[list[SendJob], int]: """Lista i job di invio del tenant con filtri opzionali.""" base_q = select(SendJob).where(SendJob.tenant_id == tenant_id) if mailbox_id: base_q = base_q.where(SendJob.mailbox_id == mailbox_id) if status_filter: base_q = base_q.where(SendJob.status == status_filter) count_q = select(func.count()).select_from(base_q.subquery()) total = (await self.db.execute(count_q)).scalar_one() items_q = ( base_q.order_by(SendJob.queued_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 # ── Get singolo job ─────────────────────────────────────────────────────── async def get_send_job( self, job_id: uuid.UUID, tenant_id: uuid.UUID, ) -> SendJob: """Carica un singolo SendJob verificando l'appartenenza al tenant.""" job = await self.db.get(SendJob, job_id) if not job or job.tenant_id != tenant_id: raise NotFoundError("job di invio") return job # ── Annulla job (solo se pending) ───────────────────────────────────────── async def cancel_send_job( self, job_id: uuid.UUID, tenant_id: uuid.UUID, ) -> SendJob: """ Annulla un job di invio se è ancora in stato 'pending'. Non cancella il messaggio associato. """ job = await self.get_send_job(job_id, tenant_id) if job.status not in ("pending", "retrying"): raise ForbiddenError( f"Impossibile annullare: il job è in stato '{job.status}'" ) job.status = "failed" job.last_error = "Annullato dall'utente" # Aggiorna anche il messaggio if job.message_id: msg = await self.db.get(Message, job.message_id) if msg and msg.state in ("queued", "draft"): msg.state = "failed" await self.db.flush() return job