Spaces:
Running
Running
File size: 9,491 Bytes
28a08e7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 | """
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]}"
)
|