""" email.py — Invio email via Resend API (https://resend.com). Endpoint: POST /api/email/send — Invia email transazionale/markdown Configurazione: RESEND_API_KEY env var — richiesto (chiave Resend, formato re_...) Default from: noreply@ Comportamento: - body può essere testo plain o HTML - Se html=true, body viene inviato come HTML (altrimenti text/plain) - Supporta cc, bcc, reply_to, allegati (future extension) - Rate limit Resend free tier: 100 email/day, 1 email/sec Error handling: - Resend 422: validazione campi (to/from non validi) - Resend 429: rate limit - Mancanza RESEND_API_KEY → istruzione chiara per configurazione """ from __future__ import annotations import os, logging, httpx from fastapi import APIRouter, Request, Depends, HTTPException from .auth_guard import require_role, AuthRole from pydantic import BaseModel, EmailStr, field_validator from typing import Optional, List router = APIRouter(prefix="/api/email", tags=["email"]) _logger = logging.getLogger("email_api") # ── Modelli ───────────────────────────────────────────────────────────────── class SendEmailRequest(BaseModel): to: str # destinatario (email o "Nome ") subject: str # oggetto body: str # corpo (testo plain o HTML) html: bool = False # True → invia come text/html from_name: str = "Agente AI" from_email: str = "" # auto: noreply@dominio o default Resend cc: List[str] = [] bcc: List[str] = [] reply_to: str = "" @field_validator("subject", "body") @classmethod def not_empty(cls, v: str) -> str: # noqa: N805 if not v.strip(): raise ValueError("Il campo non può essere vuoto") return v.strip() class SendEmailResponse(BaseModel): ok: bool id: Optional[str] = None message: str provider: str = "resend" # ── Endpoint ───────────────────────────────────────────────────────────────── @router.post("/send", response_model=SendEmailResponse) async def send_email( req: SendEmailRequest, role: AuthRole = Depends(require_role(AuthRole.MACHINE)), ) -> SendEmailResponse: """ Invia email via Resend API. Richiede RESEND_API_KEY nell'ambiente del backend HF Space. """ api_key = os.getenv("RESEND_API_KEY", "") if not api_key: _logger.warning("RESEND_API_KEY non configurata") return SendEmailResponse( ok=False, message=( "❌ RESEND_API_KEY non configurata. " "Per abilitare send_email: " "1. Registrati su https://resend.com (gratuito fino a 3.000 email/mese) " "2. Crea un API key in Dashboard → API Keys " "3. Aggiungi RESEND_API_KEY nei Secrets dell'HF Space " "4. (opzionale) Verifica il tuo dominio per il campo 'from'" ), ) # ── GAP-EMAIL-OPENRELAY fix: allowlist domini mittente e validazione ── import re as _re_email _EMAIL_RE = _re_email.compile(r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$') if not _EMAIL_RE.match(req.to): raise HTTPException(400, "Destinatario non valido") _allowed_domains = {os.getenv("RESEND_DOMAIN", "resend.dev"), "agente-ai.pages.dev"} from_email = req.from_email or os.getenv("RESEND_FROM_EMAIL", "noreply@resend.dev") _from_domain = from_email.split("@")[-1] if "@" in from_email else "" if _from_domain not in _allowed_domains and not from_email.endswith(".resend.dev"): # Se il dominio non è in allowlist, forza il mittente di sistema from_email = os.getenv("RESEND_FROM_EMAIL", "noreply@resend.dev") # Costruisci mittente from_field = f"{req.from_name} <{from_email}>" if req.from_name else from_email # Payload Resend payload: dict = { "from": from_field, "to": [req.to] if isinstance(req.to, str) else req.to, "subject": req.subject, } if req.html: payload["html"] = req.body else: payload["text"] = req.body if req.cc: payload["cc"] = req.cc if req.bcc: payload["bcc"] = req.bcc if req.reply_to: payload["reply_to"] = req.reply_to try: async with httpx.AsyncClient(timeout=20.0) as client: resp = await client.post( "https://api.resend.com/emails", json=payload, headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", }, ) if resp.status_code in (200, 201): data = resp.json() email_id = data.get("id", "") _logger.info("Email inviata OK → id=%s to=%s", email_id, req.to) return SendEmailResponse( ok=True, id=email_id, message=f"✅ Email inviata con successo a {req.to} (id: {email_id})", ) # Gestione errori specifici Resend err_body: dict = {} try: err_body = resp.json() except Exception as _exc: _logger.debug("[email] silenced %s", type(_exc).__name__) # noqa: BLE001 err_msg = err_body.get("message") or err_body.get("error") or resp.text[:200] if resp.status_code == 422: return SendEmailResponse( ok=False, message=f"❌ Email non valida: {err_msg}. Verifica indirizzo 'to' e 'from'.", ) if resp.status_code == 429: return SendEmailResponse( ok=False, message="❌ Rate limit Resend raggiunto (100 email/giorno sul piano free). Riprova tra qualche ora.", ) if resp.status_code == 401: return SendEmailResponse( ok=False, message="❌ RESEND_API_KEY non valida. Verifica il valore nei Secrets dell'HF Space.", ) return SendEmailResponse( ok=False, message=f"❌ Resend errore {resp.status_code}: {err_msg}", ) except httpx.TimeoutException: return SendEmailResponse( ok=False, message="❌ Timeout: Resend API non risponde. Riprova." ) except Exception as exc: _logger.error("send_email exception: %s", exc) return SendEmailResponse( ok=False, message=f"❌ Errore invio email: {str(exc)[:200]}" )