feat: conexiones OAuth (Gmail/Outlook) para el agente + rediseño del orquestador
Backend: - UmindConexion: cuenta de correo conectada por tenant vía OAuth2 (golang.org/x/oauth2, promovida de indirecta a directa), tokens cifrados en reposo con el mismo AES-GCM+APP_KEY que ya usan tools/canales. - Flujo completo: /app/umind/conexiones/conectar redirige a Google/Microsoft, /callback/:proveedor intercambia el code (state autoverificable por HMAC, sin tabla de estados pendientes), refresh on-demand antes de cada uso. - Dos tools nuevas para el agente (enviar_correo/leer_bandeja) que aparecen solo si el tenant tiene una conexión activa, vía Gmail API / Microsoft Graph directo (sin el SDK pesado de Google). - Requiere que el dueño del proyecto cree las apps OAuth en Google Cloud Console / Azure y cargue GOOGLE_OAUTH_CLIENT_ID/SECRET y MS_OAUTH_CLIENT_ID/SECRET — sin eso los botones de conectar fallan con un mensaje claro, no en silencio. Frontend: rediseño del orquestador — layout de sidebar fijo (reemplaza el navbar + lista de página completa), modo oscuro vía prefers-color-scheme, tabs en pill, y la nueva tab "Conexiones". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b2f6b518b6
commit
5ba41786d6
@@ -0,0 +1,109 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// EnviarCorreoMicrosoft manda un correo de texto plano vía Microsoft Graph
|
||||
// (POST /me/sendMail) en nombre de la cuenta conectada.
|
||||
func EnviarCorreoMicrosoft(conexion *models.UmindConexion, destinatario, asunto, cuerpo string) error {
|
||||
accessToken, err := DescifrarSecretoUmind(conexion.AccessTokenEnc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("no se pudo descifrar el access token: %w", err)
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"message": map[string]interface{}{
|
||||
"subject": asunto,
|
||||
"body": map[string]string{"contentType": "Text", "content": cuerpo},
|
||||
"toRecipients": []map[string]interface{}{
|
||||
{"emailAddress": map[string]string{"address": destinatario}},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest(http.MethodPost, "https://graph.microsoft.com/v1.0/me/sendMail", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := umindOAuthHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("no se pudo contactar Microsoft Graph: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
detalle, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return fmt.Errorf("Microsoft Graph respondió %d: %s", resp.StatusCode, string(detalle))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LeerBandejaMicrosoft busca mensajes en la bandeja vía Microsoft Graph
|
||||
// ($search sobre asunto/cuerpo/remitente) y devuelve un resumen liviano.
|
||||
func LeerBandejaMicrosoft(conexion *models.UmindConexion, consulta string, limite int) ([]CorreoResumen, error) {
|
||||
if limite <= 0 || limite > 10 {
|
||||
limite = 10
|
||||
}
|
||||
accessToken, err := DescifrarSecretoUmind(conexion.AccessTokenEnc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no se pudo descifrar el access token: %w", err)
|
||||
}
|
||||
|
||||
q := fmt.Sprintf("https://graph.microsoft.com/v1.0/me/messages?$search=%s&$top=%d&$select=from,subject,receivedDateTime,bodyPreview",
|
||||
url.QueryEscape(`"`+consulta+`"`), limite)
|
||||
req, err := http.NewRequest(http.MethodGet, q, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
// $search requiere este header ("eventual consistency") en Microsoft Graph.
|
||||
req.Header.Set("ConsistencyLevel", "eventual")
|
||||
|
||||
resp, err := umindOAuthHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no se pudo contactar Microsoft Graph: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
detalle, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return nil, fmt.Errorf("Microsoft Graph respondió %d: %s", resp.StatusCode, string(detalle))
|
||||
}
|
||||
|
||||
var out struct {
|
||||
Value []struct {
|
||||
From struct {
|
||||
EmailAddress struct {
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
} `json:"emailAddress"`
|
||||
} `json:"from"`
|
||||
Subject string `json:"subject"`
|
||||
ReceivedDateTime string `json:"receivedDateTime"`
|
||||
BodyPreview string `json:"bodyPreview"`
|
||||
} `json:"value"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resultados := make([]CorreoResumen, 0, len(out.Value))
|
||||
for _, m := range out.Value {
|
||||
resultados = append(resultados, CorreoResumen{
|
||||
De: m.From.EmailAddress.Address,
|
||||
Asunto: m.Subject,
|
||||
Fecha: m.ReceivedDateTime,
|
||||
Extracto: m.BodyPreview,
|
||||
})
|
||||
}
|
||||
return resultados, nil
|
||||
}
|
||||
Reference in New Issue
Block a user