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]}"
        )