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>
110 lines
3.5 KiB
Go
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
|
|
}
|