feat(umind): el correo entra al agente por dos puertas distintas — y son distintas a propósito

En "Dónde atiende", el canal correo: el agente lee una casilla y responde solo
los correos que llegan, con su base de conocimiento, igual que atiende WhatsApp.
Como el correo no tiene webhook, se revisa por intervalo — y el intervalo lo
elige el cliente por canal (2 a 60 minutos): una inmobiliaria quiere 2, a un
estudio contable con 30 le sobra. El cron corre cada minuto pero cada casilla
se revisa solo cuando le toca, con un pool de 8 para que 100 casillas no salgan
a la red en el mismo instante.

Las guardas que separan "asistente" de "incidente", cada una con su test:
nunca responde correo automático ni se responde a sí mismo (el bucle con otro
autoresponder); la revisión se marca ANTES de conectar, así una contraseña
cambiada no martilla el login cada minuto hasta que el host del cliente nos
bloquea; y el correo se marca leído recién cuando la respuesta salió — si el
envío falla, queda sin leer y se reintenta.

En "Lo que sabe", las cuentas de correo: casillas que el agente consulta a
pedido — "revisame los correos de hoy y haceme un resumen" — sin nada de fondo.
Solo lectura en serio: Peek, INBOX en read-only, y un test que falla si alguien
le agrega un marcado. Ahora se pueden conectar varias por agente; con más de
una, el modelo pregunta cuál en vez de adivinar — resumirle a alguien la
casilla que no pidió no es un error menor. El alta pide correo y contraseña:
el host se deduce (mail.<dominio>) y el campo técnico aparece recién si eso
falla. Se prueba la conexión antes de guardar, con la persona mirando.

Enviar por una cuenta conectada está bloqueado a propósito: para responder
correos está el canal, con sus guardas. Una tool de envío sin límites es una
máquina de spam con el dominio del cliente.

La navegación acompaña: "Lo que sabe" agrupa Información, Cuentas de correo y
Herramientas — tres formas de saber, no tres pantallas sueltas — y Avanzado
queda solo con Problemas.

El SMTP se unificó en una sola implementación que comparten soporte y el canal:
el bug de STARTTLS que abría dos conexiones ya se pagó una vez.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-23 20:08:46 -05:00
co-authored by Claude Opus 5
parent ffa24a068f
commit eddec45857
21 changed files with 987 additions and 143 deletions
+59 -12
View File
@@ -119,13 +119,14 @@ func umindEmailTools() []agentTool {
Type: "function",
Function: agentToolFunc{
Name: "leer_bandeja",
Description: "Busca correos recibidos en la bandeja conectada del negocio (ej. revisar si llegó un comprobante o la respuesta de un cliente).",
Description: "Busca o resume correos recibidos en las casillas conectadas del negocio (ej. revisar si llegó un comprobante, resumir los correos de hoy).",
Parameters: agentToolParam{
Type: "object",
Properties: map[string]agentToolParam{
"consulta": {Type: "string", Description: "Qué buscar: remitente, palabras clave del asunto o del cuerpo"},
"consulta": {Type: "string", Description: "Qué buscar: remitente o palabras clave. Vacío = los correos más recientes"},
"cuenta": {Type: "string", Description: "Dirección de la casilla a revisar, si hay más de una conectada"},
},
Required: []string{"consulta"},
Required: []string{},
},
},
},
@@ -188,18 +189,61 @@ func executeUmindTool(agenteID uint, name string, args map[string]interface{}) s
// Microsoft Graph según el proveedor de la conexión activa del agente,
// refrescando el token primero si hace falta.
func executeUmindEmailTool(agenteID uint, name string, args map[string]interface{}) string {
conexion, err := models.GetUmindConexionActiva(agenteID)
if err != nil {
conexiones, err := models.GetUmindConexionesActivas(agenteID)
if err != nil || len(conexiones) == 0 {
return `{"error": "no hay ninguna cuenta de correo conectada"}`
}
if err := RefrescarSiVence(conexion); err != nil {
log.Printf("[UMIND] Error refrescando token OAuth (conexión %d): %v", conexion.ID, err)
models.RegistrarEventoUmind(agenteID, "error", "email", "Error refrescando el token de la cuenta de correo conectada", err.Error())
return `{"error": "no se pudo usar la cuenta de correo conectada, intenta más tarde"}`
// Con varias casillas, el modelo elige por dirección; con una sola, esa.
// Si hay varias y no dijo cuál, se le listan para que pregunte o elija —
// adivinar la casilla equivocada sería resumirle a alguien el correo que
// no pidió.
cuenta, _ := args["cuenta"].(string)
cuenta = strings.ToLower(strings.TrimSpace(cuenta))
var conexion *models.UmindConexion
if len(conexiones) == 1 {
conexion = &conexiones[0]
} else if cuenta != "" {
for i := range conexiones {
if strings.Contains(strings.ToLower(conexiones[i].Email), cuenta) {
conexion = &conexiones[i]
break
}
}
if conexion == nil {
return fmt.Sprintf(`{"error": "no hay ninguna cuenta que coincida con %q"}`, cuenta)
}
} else {
emails := make([]string, len(conexiones))
for i, cx := range conexiones {
emails[i] = cx.Email
}
b, _ := json.Marshal(map[string]interface{}{
"error": "hay varias cuentas conectadas: indicá cuál en el parámetro cuenta",
"cuentas": emails,
})
return string(b)
}
esIMAP := conexion.Proveedor == "imap"
// El refresco de token es cosa de OAuth; una cuenta IMAP no vence.
if !esIMAP {
if err := RefrescarSiVence(conexion); err != nil {
log.Printf("[UMIND] Error refrescando token OAuth (conexión %d): %v", conexion.ID, err)
models.RegistrarEventoUmind(agenteID, "error", "email", "Error refrescando el token de la cuenta de correo conectada", err.Error())
return `{"error": "no se pudo usar la cuenta de correo conectada, intenta más tarde"}`
}
}
switch name {
case "enviar_correo":
// Las cuentas IMAP son de solo lectura a propósito: se conectan en "Lo
// que sabe" para consultar. Para que el agente RESPONDA correos está el
// canal de correo en "Dónde atiende", con sus guardas propias.
if esIMAP {
return `{"error": "esta cuenta es de solo lectura; para enviar correos configurá el canal de correo en Dónde atiende"}`
}
destinatario, _ := args["destinatario"].(string)
asunto, _ := args["asunto"].(string)
cuerpo, _ := args["cuerpo"].(string)
@@ -223,9 +267,12 @@ func executeUmindEmailTool(agenteID uint, name string, args map[string]interface
consulta, _ := args["consulta"].(string)
var resultados []CorreoResumen
var lecErr error
if conexion.Proveedor == UmindOAuthGoogle {
switch {
case esIMAP:
resultados, lecErr = LeerBandejaIMAP(conexion, consulta, 8)
case conexion.Proveedor == UmindOAuthGoogle:
resultados, lecErr = LeerBandejaGoogle(conexion, consulta, 5)
} else {
default:
resultados, lecErr = LeerBandejaMicrosoft(conexion, consulta, 5)
}
if lecErr != nil {
@@ -233,7 +280,7 @@ func executeUmindEmailTool(agenteID uint, name string, args map[string]interface
models.RegistrarEventoUmind(agenteID, "error", "email", "Error leyendo la bandeja de correo", lecErr.Error())
return `{"error": "no se pudo leer la bandeja"}`
}
b, _ := json.Marshal(map[string]interface{}{"resultados": resultados})
b, _ := json.Marshal(map[string]interface{}{"cuenta": conexion.Email, "resultados": resultados})
return string(b)
default: