Spaces:
Running
Running
| """ | |
| 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 <email>' 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 <email>').""" | |
| 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 <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() | |
| 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() | |
| 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 βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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]}" | |
| ) | |