Files
soft_usite/pkg/services/umind_oauth_microsoft.go
Lizandro GuarnizoandClaude Sonnet 5 5ba41786d6 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>
2026-08-12 10:25:13 -05:00

110 lines
3.5 KiB
Go

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
}