""" Job arq: send_pec – invio PEC con retry esponenziale. Flusso completo: 1. API backend crea Message(state=queued) + SendJob(status=pending) 2. Backend enqueue send_pec via arq 3. send_pec legge il job dal DB, tenta l'invio SMTP 4. Successo → Message(state=sent), SendJob(status=sent), upload EML su MinIO, enqueue watch_receipt con defer 24h 5. Fallimento transitorio → status=retrying, re-enqueue con backoff 6. Fallimento definitivo (max_attempts raggiunto) → status=failed, Message(state=failed), evento WS Retry backoff (max 5 tentativi totali): Tentativo 1 fallisce → attendi 1 min → tentativo 2 Tentativo 2 fallisce → attendi 5 min → tentativo 3 Tentativo 3 fallisce → attendi 15 min → tentativo 4 Tentativo 4 fallisce → attendi 1 ora → tentativo 5 Tentativo 5 fallisce → FAILED (no retry) """ import io import json import logging import uuid as uuid_module from datetime import datetime, timedelta, timezone from typing import Any from sqlalchemy import select from app.database import AsyncSessionLocal from app.models import Mailbox, Message, SendJob from app.smtp.sender import SmtpSender from app.storage.minio_client import upload_outbound_eml logger = logging.getLogger(__name__) # ─── Configurazione retry ───────────────────────────────────────────────────── MAX_ATTEMPTS = 5 # Delay in secondi dopo il fallimento del tentativo N (0-based) RETRY_DELAYS = [ 60, # dopo tentativo 1 fallisce → 1 min 300, # dopo tentativo 2 fallisce → 5 min 900, # dopo tentativo 3 fallisce → 15 min 3600, # dopo tentativo 4 fallisce → 1 ora # tentativo 5 = ultimo → nessun retry ] # ─── Job principale ─────────────────────────────────────────────────────────── async def send_pec(ctx: dict[str, Any], send_job_id: str) -> dict: """ Job arq: invia una PEC. Args: ctx: contesto arq (contiene redis client arq per re-enqueue) send_job_id: UUID del SendJob da processare Returns: dict con esito: status, message_id, attempt """ redis_client = ctx.get("redis") async with AsyncSessionLocal() as db: # ── Carica dati ────────────────────────────────────────────────────── job = await db.get(SendJob, uuid_module.UUID(send_job_id)) if not job: logger.error(f"[send_pec] SendJob {send_job_id} non trovato") return {"status": "error", "message": "SendJob non trovato"} if job.status in ("sent", "failed"): logger.warning( f"[send_pec] SendJob {send_job_id} già in stato {job.status!r}, skip" ) return {"status": "skipped", "current_status": job.status} msg = await db.get(Message, job.message_id) if not msg: logger.error(f"[send_pec] Messaggio {job.message_id} non trovato") job.status = "failed" job.last_error = "Messaggio associato non trovato" await db.commit() return {"status": "error", "message": "Messaggio non trovato"} mailbox = await db.get(Mailbox, job.mailbox_id) if not mailbox: logger.error(f"[send_pec] Casella {job.mailbox_id} non trovata") job.status = "failed" job.last_error = "Casella mittente non trovata" msg.state = "failed" await db.commit() return {"status": "error", "message": "Casella non trovata"} # ── Aggiorna contatori ──────────────────────────────────────────────── job.status = "sending" job.attempt_count += 1 current_attempt = job.attempt_count await db.flush() logger.info( f"[send_pec] Tentativo {current_attempt}/{MAX_ATTEMPTS} " f"per job {send_job_id} → {msg.to_addresses}" ) # ── Tenta invio SMTP ────────────────────────────────────────────────── try: sender = SmtpSender(mailbox) message_id_header, raw_eml = await sender.send( to_addresses=list(msg.to_addresses or []), cc_addresses=list(msg.cc_addresses or []), subject=msg.subject or "", body_text=msg.body_text or "", body_html=msg.body_html, attachments=None, # allegati in fase successiva (Fase 5) ) # ── Successo: aggiorna DB ───────────────────────────────────────── now = datetime.now(tz=timezone.utc) msg.message_id_header = message_id_header msg.state = "sent" msg.sent_at = now job.status = "sent" job.sent_at = now job.message_id = msg.id # Upload raw EML su MinIO try: eml_path = await upload_outbound_eml( tenant_id=str(msg.tenant_id), mailbox_id=str(msg.mailbox_id), message_id=str(msg.id), eml_bytes=raw_eml, ) msg.raw_eml_path = eml_path logger.debug(f"[send_pec] EML salvato: {eml_path}") except Exception as minio_err: logger.warning(f"[send_pec] Upload MinIO fallito (non critico): {minio_err}") await db.commit() # ── Pubblica evento WS ──────────────────────────────────────────── if redis_client: await _publish_ws_event(redis_client, msg.tenant_id, { "type": "message:sent", "message_id": str(msg.id), "mailbox_id": str(msg.mailbox_id), "subject": msg.subject, "message_id_header": message_id_header, }) # ── Enqueue watch_receipt dopo 24h ──────────────────────────────── try: await redis_client.enqueue_job( "watch_receipt", str(msg.id), _defer_by=timedelta(hours=24), ) logger.info( f"[send_pec] watch_receipt schedulato per {msg.id} " f"tra 24h" ) except Exception as e: logger.warning(f"[send_pec] Errore enqueue watch_receipt: {e}") logger.info( f"[send_pec] ✅ PEC inviata: job={send_job_id} " f"message_id_header={message_id_header}" ) return { "status": "sent", "send_job_id": send_job_id, "message_id": str(msg.id), "message_id_header": message_id_header, "attempt": current_attempt, } except Exception as smtp_error: error_msg = str(smtp_error) logger.warning( f"[send_pec] Tentativo {current_attempt} fallito: {error_msg}" ) # ── Gestione retry / failure ────────────────────────────────────── if current_attempt >= MAX_ATTEMPTS: # Esauriti tutti i tentativi → FAILED job.status = "failed" job.last_error = error_msg msg.state = "failed" await db.commit() # Pubblica evento WS: invio fallito if redis_client: await _publish_ws_event(redis_client, msg.tenant_id, { "type": "message:send_failed", "message_id": str(msg.id), "mailbox_id": str(msg.mailbox_id), "subject": msg.subject, "error": error_msg, "attempts": current_attempt, }) logger.error( f"[send_pec] ❌ Invio FALLITO definitivamente: " f"job={send_job_id}, errore: {error_msg}" ) return { "status": "failed", "send_job_id": send_job_id, "error": error_msg, "attempts": current_attempt, } else: # Retry con backoff delay_seconds = RETRY_DELAYS[current_attempt - 1] next_retry = datetime.now(tz=timezone.utc) + timedelta(seconds=delay_seconds) job.status = "retrying" job.last_error = error_msg job.next_retry_at = next_retry msg.state = "queued" # torna in coda await db.commit() # Re-enqueue con defer try: await redis_client.enqueue_job( "send_pec", send_job_id, _defer_by=timedelta(seconds=delay_seconds), ) logger.info( f"[send_pec] Retry {current_attempt} schedulato " f"in {delay_seconds}s per job {send_job_id}" ) except Exception as enqueue_err: logger.error( f"[send_pec] Impossibile re-enqueue job {send_job_id}: " f"{enqueue_err}" ) return { "status": "retrying", "send_job_id": send_job_id, "attempt": current_attempt, "next_retry_at": next_retry.isoformat(), "delay_seconds": delay_seconds, "error": error_msg, } # ─── Helpers ────────────────────────────────────────────────────────────────── async def _publish_ws_event( redis_client: Any, tenant_id: uuid_module.UUID, event: dict, ) -> None: """Pubblica un evento WebSocket per il tenant tramite Redis pub/sub.""" try: channel = f"ws:tenant:{tenant_id}" await redis_client.publish(channel, json.dumps(event, default=str)) except Exception as e: logger.warning(f"[send_pec] Errore pubblicazione WS: {e}")