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>
117 lines
3.7 KiB
Go
117 lines
3.7 KiB
Go
package services
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
|
)
|
|
|
|
// EnviarCorreoGoogle manda un correo de texto plano vía Gmail API en nombre
|
|
// de la cuenta conectada. Asume que conexion ya pasó por RefrescarSiVence.
|
|
func EnviarCorreoGoogle(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)
|
|
}
|
|
|
|
mime := fmt.Sprintf("To: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=\"UTF-8\"\r\n\r\n%s",
|
|
destinatario, asunto, cuerpo)
|
|
raw := base64.RawURLEncoding.EncodeToString([]byte(mime))
|
|
|
|
body, _ := json.Marshal(map[string]string{"raw": raw})
|
|
req, err := http.NewRequest(http.MethodPost, "https://gmail.googleapis.com/gmail/v1/users/me/messages/send", 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 Gmail: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
detalle, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
return fmt.Errorf("Gmail respondió %d: %s", resp.StatusCode, string(detalle))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LeerBandejaGoogle busca mensajes en Gmail (sintaxis de búsqueda de Gmail,
|
|
// ej: "from:cliente@ejemplo.com") y devuelve un resumen liviano de cada uno.
|
|
func LeerBandejaGoogle(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)
|
|
}
|
|
|
|
listURL := fmt.Sprintf("https://gmail.googleapis.com/gmail/v1/users/me/messages?q=%s&maxResults=%d",
|
|
url.QueryEscape(consulta), limite)
|
|
var lista struct {
|
|
Messages []struct {
|
|
ID string `json:"id"`
|
|
} `json:"messages"`
|
|
}
|
|
if err := gmailGetJSON(listURL, accessToken, &lista); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resultados := make([]CorreoResumen, 0, len(lista.Messages))
|
|
for _, m := range lista.Messages {
|
|
detalleURL := fmt.Sprintf("https://gmail.googleapis.com/gmail/v1/users/me/messages/%s?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date", m.ID)
|
|
var msg struct {
|
|
Snippet string `json:"snippet"`
|
|
Payload struct {
|
|
Headers []struct {
|
|
Name string `json:"name"`
|
|
Value string `json:"value"`
|
|
} `json:"headers"`
|
|
} `json:"payload"`
|
|
}
|
|
if err := gmailGetJSON(detalleURL, accessToken, &msg); err != nil {
|
|
continue // un mensaje individual que falla no debe tirar abajo toda la búsqueda
|
|
}
|
|
r := CorreoResumen{Extracto: msg.Snippet}
|
|
for _, h := range msg.Payload.Headers {
|
|
switch h.Name {
|
|
case "From":
|
|
r.De = h.Value
|
|
case "Subject":
|
|
r.Asunto = h.Value
|
|
case "Date":
|
|
r.Fecha = h.Value
|
|
}
|
|
}
|
|
resultados = append(resultados, r)
|
|
}
|
|
return resultados, nil
|
|
}
|
|
|
|
func gmailGetJSON(url, accessToken string, out interface{}) error {
|
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
resp, err := umindOAuthHTTPClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("no se pudo contactar Gmail: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
detalle, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
return fmt.Errorf("Gmail respondió %d: %s", resp.StatusCode, string(detalle))
|
|
}
|
|
return json.NewDecoder(resp.Body).Decode(out)
|
|
}
|