Terminal / api /email.py
Baida-A
Initial clean deploy (Reverse Proxy removed)
28a08e7
Raw
History Blame
9.49 kB
"""
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 = ""
@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]}"
)