""" 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_...) RESEND_FROM_EMAIL — indirizzo mittente default (es. noreply@tuodominio.com) RESEND_ALLOWED_DOMAINS — domini FROM consentiti, separati da virgola (SEC2-9) Default from: noreply@resend.dev (sandbox 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 Sicurezza (SEC2-9): - Dominio mittente (from_email) validato contro allowlist RESEND_ALLOWED_DOMAINS o dominio di RESEND_FROM_EMAIL. Impedisce open relay su domini arbitrari. - Indirizzi to/cc/bcc validati con regex prima della chiamata Resend. 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, re, logging, httpx from fastapi import APIRouter, Depends, Request from pydantic import BaseModel, field_validator from .auth_guard import require_role, AuthRole from typing import Optional, List router = APIRouter(prefix="/api/email", tags=["email"]) _logger = logging.getLogger("email_api") # ── Validazione indirizzi e domini (SEC2-9) ─────────────────────────────────── # Regex RFC 5321 semplificata: accetta "nome@dominio.tld" _EMAIL_RE = re.compile(r'^[^@\s<>,;]+@([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)+)$') def _extract_email(addr: str) -> str: """Estrae l'indirizzo grezzo da 'Nome ' o 'email'.""" addr = addr.strip() m = re.search(r'<([^>]+)>', addr) return m.group(1).strip() if m else addr def _valid_email(addr: str) -> bool: """True se addr è un indirizzo email valido (ammette anche 'Nome ').""" return bool(_EMAIL_RE.match(_extract_email(addr))) def _from_domain_allowed(email: str) -> bool: """Controlla che il dominio di email sia nell'allowlist FROM. Allowlist (in ordine di priorità): 1. RESEND_ALLOWED_DOMAINS — lista separata da virgola 2. Dominio estratto da RESEND_FROM_EMAIL 3. Fallback: resend.dev (sandbox Resend, valido in test) SEC2-9: impedisce di usare /api/email/send come open relay su domini arbitrari non verificati in Resend. """ explicit = os.getenv('RESEND_ALLOWED_DOMAINS', '') if explicit: allowed = {d.strip().lower() for d in explicit.split(',') if d.strip()} else: default_from = os.getenv('RESEND_FROM_EMAIL', '') m = _EMAIL_RE.match(default_from) allowed = {m.group(1).lower()} if m else {'resend.dev'} raw = _extract_email(email) m2 = _EMAIL_RE.match(raw) if not m2: return False return m2.group(1).lower() in allowed # ── 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() @field_validator("to", "reply_to") @classmethod def validate_single_addr(cls, v: str) -> str: # noqa: N805 """SEC2-9: valida formato indirizzo singolo.""" if v and not _valid_email(v): raise ValueError(f"Indirizzo email non valido: {v!r}") return v.strip() @field_validator("cc", "bcc") @classmethod def validate_list_addrs(cls, v: list) -> list: # noqa: N805 """SEC2-9: valida formato di ogni indirizzo in cc/bcc.""" for addr in v: if not _valid_email(addr): raise ValueError(f"Indirizzo email non valido: {addr!r}") return v 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, request: Request, role: AuthRole = Depends(require_role(AuthRole.MACHINE)), # P19-SEC2-1: era fail-open ) -> 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'" ), ) # Costruisci e valida dominio mittente (SEC2-9) from_email = req.from_email or os.getenv("RESEND_FROM_EMAIL", "noreply@resend.dev") if not _from_domain_allowed(from_email): _allowed_hint = os.getenv("RESEND_ALLOWED_DOMAINS") or os.getenv("RESEND_FROM_EMAIL", "resend.dev") return SendEmailResponse( ok=False, message=( f"❌ Dominio mittente non consentito: '{_extract_email(from_email)}'. " "Usa un dominio verificato in Resend. " "Per aggiungere domini extra: imposta RESEND_ALLOWED_DOMAINS=dominio1.com,dominio2.com " f"nei Secrets HF Space. Domini correnti consentiti: {_allowed_hint!r}." ), ) 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]}" )