Spaces:
Configuration error
Configuration error
| """ | |
| 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@<dominio configurato in Resend> | |
| 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 <email>") | |
| 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 = "" | |
| 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 ───────────────────────────────────────────────────────────────── | |
| 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]}" | |
| ) | |