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
+27
View File
@@ -4,6 +4,7 @@ import (
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
@@ -36,6 +37,32 @@ type UmindCanal struct {
// Documentos adjuntos (PDF, Word, texto): se les extrae el contenido y se
// le pasa al agente como si el cliente lo hubiera escrito.
UsarArchivosDocs bool `json:"usar_archivos_docs" gorm:"column:usar_archivos_docs;default:false"`
// Solo para el canal correo: cada cuántos minutos se revisa la casilla.
// Lo elige el cliente — una inmobiliaria quiere 2, un estudio contable con
// 30 le sobra. Telegram y WhatsApp no lo usan: a ellos les llega webhook.
IntervaloMinutos int `json:"intervalo_minutos" gorm:"column:intervalo_minutos;default:5"`
// UltimaRevision es lo que decide si ya le toca — en la base y no en
// memoria, por la misma razón que el aviso de tope: los despliegues borran
// la RAM varias veces por día.
UltimaRevision *time.Time `json:"ultima_revision" gorm:"column:ultima_revision"`
}
// MarcarRevisionCanal deja constancia de que la casilla se acaba de revisar.
func MarcarRevisionCanal(id uint) {
ahora := time.Now()
app.Http.Database.DB.Model(&UmindCanal{}).Where("id = ?", id).
Update("ultima_revision", &ahora)
}
// GetCanalesCorreoPendientes devuelve los canales de correo activos a los que
// ya les toca revisión según su propio intervalo.
func GetCanalesCorreoPendientes() ([]UmindCanal, error) {
var items []UmindCanal
err := app.Http.Database.DB.
Where("tipo = ? AND activo = ?", "correo", true).
Where("ultima_revision IS NULL OR ultima_revision < NOW() - (intervalo_minutos || ' minutes')::interval").
Find(&items).Error
return items, err
}
func (UmindCanal) TableName() string { return "umind_canales" }
+16 -1
View File
@@ -17,13 +17,17 @@ import (
type UmindConexion struct {
gorm.Model
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
Proveedor string `json:"proveedor" gorm:"column:proveedor;size:20;not null"` // google | microsoft
Proveedor string `json:"proveedor" gorm:"column:proveedor;size:20;not null"` // google | microsoft | imap
Email string `json:"email" gorm:"column:email;size:255"`
AccessTokenEnc string `json:"-" gorm:"column:access_token_enc;type:text"`
RefreshTokenEnc string `json:"-" gorm:"column:refresh_token_enc;type:text"`
ExpiraEn time.Time `json:"expira_en" gorm:"column:expira_en"`
Scopes string `json:"scopes" gorm:"column:scopes;type:text"`
Activo bool `json:"activo" gorm:"column:activo;default:true"`
// CredencialesEnc guarda el blob cifrado de una cuenta IMAP (host, puerto,
// usuario, contraseña, encriptación) — mismo esquema que los canales. Las
// conexiones OAuth no lo usan: sus tokens van en los campos de arriba.
CredencialesEnc string `json:"-" gorm:"column:credenciales_enc;type:text"`
}
func (UmindConexion) TableName() string { return "umind_conexiones" }
@@ -49,6 +53,17 @@ func GetUmindConexionByID(id uint) (*UmindConexion, error) {
// GetUmindConexionActiva retorna la primera conexión activa del agente —
// hoy se soporta una sola cuenta de correo conectada por agente, no una
// bandeja por proveedor a la vez.
// GetUmindConexionesActivas lista todas las cuentas conectadas de un agente.
// Antes se soportaba una sola; ahora un agente puede tener varias casillas y
// el modelo elige por dirección cuando el dueño se lo pide.
func GetUmindConexionesActivas(agenteID uint) ([]UmindConexion, error) {
var items []UmindConexion
err := app.Http.Database.DB.
Where("agente_id = ? AND activo = ?", agenteID, true).
Order("id ASC").Find(&items).Error
return items, err
}
func GetUmindConexionActiva(agenteID uint) (*UmindConexion, error) {
var c UmindConexion
err := app.Http.Database.DB.Where("agente_id = ? AND activo = ?", agenteID, true).First(&c).Error
+7
View File
@@ -85,6 +85,13 @@ func IniciarCron() {
return
}
// Canales de correo de los agentes — corre cada minuto, pero cada casilla
// se revisa según el intervalo que eligió su dueño.
if _, err := cronScheduler.AddFunc("* * * * *", RevisarCanalesCorreo); err != nil {
log.Printf("[CRON] Error registrando tarea canales_correo: %v", err)
return
}
// Buzón de soporte por IMAP — cada 2 minutos. No hace nada si no está
// configurado, así que registrarlo siempre no cuesta.
if _, err := cronScheduler.AddFunc("*/2 * * * *", RevisarBuzonSoporte); err != nil {
+93
View File
@@ -0,0 +1,93 @@
package services
import (
"crypto/tls"
"fmt"
"net/smtp"
"strings"
)
// DatosSMTP es lo mínimo para mandar un correo por el servidor de cualquiera:
// lo usa el buzón de soporte con su configuración y el canal de correo de un
// agente con las credenciales del cliente. Una sola implementación — el bug de
// STARTTLS que abría dos conexiones ya se pagó una vez.
type DatosSMTP struct {
Host string
Puerto int
Encriptado string // ssl | tls | starttls | none
Usuario string
Password string
Desde string
DesdeNombre string
}
// EnviarPorSMTP abre una sola conexión, la asegura según la configuración,
// manda el mensaje y cierra con QUIT.
func EnviarPorSMTP(d DatosSMTP, para, asunto, cuerpoHTML string) error {
if d.Host == "" || d.Desde == "" || para == "" {
return fmt.Errorf("faltan datos para el envío (servidor, remitente o destinatario)")
}
puerto := d.Puerto
if puerto == 0 {
puerto = 587
}
nombre := d.DesdeNombre
if nombre == "" {
nombre = d.Desde
}
addr := fmt.Sprintf("%s:%d", d.Host, puerto)
msg := []byte(fmt.Sprintf(
"From: %s <%s>\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n%s",
nombre, d.Desde, para, asunto, cuerpoHTML))
enc := strings.ToLower(strings.TrimSpace(d.Encriptado))
var cliente *smtp.Client
var err error
if enc == "tls" || enc == "ssl" {
conn, errDial := tls.Dial("tcp", addr, &tls.Config{ServerName: d.Host})
if errDial != nil {
return fmt.Errorf("no se pudo conectar por TLS: %w", errDial)
}
cliente, err = smtp.NewClient(conn, d.Host)
if err != nil {
conn.Close()
return fmt.Errorf("saludo SMTP rechazado: %w", err)
}
} else {
cliente, err = smtp.Dial(addr)
if err != nil {
return fmt.Errorf("no se pudo conectar: %w", err)
}
if enc != "none" {
if err := cliente.StartTLS(&tls.Config{ServerName: d.Host}); err != nil {
cliente.Close()
return fmt.Errorf("STARTTLS rechazado: %w", err)
}
}
}
defer cliente.Close()
if d.Usuario != "" {
if err := cliente.Auth(smtp.PlainAuth("", d.Usuario, d.Password, d.Host)); err != nil {
return fmt.Errorf("autenticación rechazada para %s: %w", d.Usuario, err)
}
}
if err := cliente.Mail(d.Desde); err != nil {
return fmt.Errorf("el servidor rechazó el remitente %s: %w", d.Desde, err)
}
if err := cliente.Rcpt(para); err != nil {
return fmt.Errorf("el servidor rechazó el destinatario %s: %w", para, err)
}
w, err := cliente.Data()
if err != nil {
return err
}
if _, err := w.Write(msg); err != nil {
return err
}
if err := w.Close(); err != nil {
return fmt.Errorf("el servidor rechazó el mensaje: %w", err)
}
return cliente.Quit()
}
+6 -69
View File
@@ -1,10 +1,8 @@
package services
import (
"crypto/tls"
"fmt"
"log"
"net/smtp"
"strings"
"sync"
@@ -41,77 +39,16 @@ func soporteSendMail(to, subject, htmlBody string) error {
if port == 0 {
port = 587
}
addr := fmt.Sprintf("%s:%d", cfg.SmtpHost, port)
msg := []byte(fmt.Sprintf(
"From: %s <%s>\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n%s",
fromName, from, to, subject, htmlBody))
if err := enviarPorSMTP(cfg, addr, from, to, msg); err != nil {
return fmt.Errorf("SMTP de soporte (%s): %w", addr, err)
if err := EnviarPorSMTP(DatosSMTP{
Host: cfg.SmtpHost, Puerto: port, Encriptado: cfg.SmtpEncryption,
Usuario: cfg.SmtpUsername, Password: cfg.SmtpPassword,
Desde: from, DesdeNombre: fromName,
}, to, subject, htmlBody); err != nil {
return fmt.Errorf("SMTP de soporte (%s:%d): %w", cfg.SmtpHost, port, err)
}
return nil
}
// enviarPorSMTP abre una sola conexión, la asegura según la configuración,
// manda el mensaje y cierra con QUIT.
//
// Antes el camino STARTTLS abría una conexión, hacía StartTLS, autenticaba… y
// la descartaba para llamar a smtp.SendMail, que abre otra distinta: la primera
// quedaba colgada y el envío real salía por una conexión que podía no estar
// autenticada igual.
func enviarPorSMTP(cfg *models.SoporteWebhookConfig, addr, from, to string, msg []byte) error {
enc := strings.ToLower(strings.TrimSpace(cfg.SmtpEncryption))
var cliente *smtp.Client
var err error
if enc == "tls" || enc == "ssl" {
conn, errDial := tls.Dial("tcp", addr, &tls.Config{ServerName: cfg.SmtpHost})
if errDial != nil {
return fmt.Errorf("no se pudo conectar por TLS: %w", errDial)
}
cliente, err = smtp.NewClient(conn, cfg.SmtpHost)
if err != nil {
conn.Close()
return fmt.Errorf("saludo SMTP rechazado: %w", err)
}
} else {
cliente, err = smtp.Dial(addr)
if err != nil {
return fmt.Errorf("no se pudo conectar: %w", err)
}
if enc != "none" {
if err := cliente.StartTLS(&tls.Config{ServerName: cfg.SmtpHost}); err != nil {
cliente.Close()
return fmt.Errorf("STARTTLS rechazado: %w", err)
}
}
}
defer cliente.Close()
if cfg.SmtpUsername != "" {
if err := cliente.Auth(smtp.PlainAuth("", cfg.SmtpUsername, cfg.SmtpPassword, cfg.SmtpHost)); err != nil {
return fmt.Errorf("autenticación rechazada para %s: %w", cfg.SmtpUsername, err)
}
}
if err := cliente.Mail(from); err != nil {
return fmt.Errorf("el servidor rechazó el remitente %s: %w", from, err)
}
if err := cliente.Rcpt(to); err != nil {
return fmt.Errorf("el servidor rechazó el destinatario %s: %w", to, err)
}
w, err := cliente.Data()
if err != nil {
return err
}
if _, err := w.Write(msg); err != nil {
return err
}
if err := w.Close(); err != nil {
return fmt.Errorf("el servidor rechazó el mensaje: %w", err)
}
return cliente.Quit()
}
// ProbarEnvioSoporte manda un correo de prueba por el mismo camino que usa el
// acuse automático, y devuelve el error tal cual. Es la única forma de saber
// por qué no llega: el acuse real se manda en segundo plano.
+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:
+151
View File
@@ -0,0 +1,151 @@
package services
import (
"fmt"
"log"
"strings"
"sync"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// El canal correo: el agente atiende una casilla como atiende WhatsApp. La
// diferencia es que acá no hay webhook — se revisa la casilla cada tanto, con
// el intervalo que eligió el cliente en el canal.
// correoEnCurso evita que dos pasadas del cron se pisen si una tarda más que
// el minuto. Por proceso alcanza: el cron corre en uno solo.
var correoEnCurso sync.Mutex
// RevisarCanalesCorreo corre cada minuto y atiende solo los canales a los que
// ya les toca según su propio intervalo. Con un pool acotado: 100 casillas no
// deben salir a la red en el mismo instante.
func RevisarCanalesCorreo() {
if !correoEnCurso.TryLock() {
return
}
defer correoEnCurso.Unlock()
canales, err := models.GetCanalesCorreoPendientes()
if err != nil {
log.Printf("[UMIND_CORREO] No se pudieron listar los canales: %v", err)
return
}
if len(canales) == 0 {
return
}
const trabajadores = 8
cola := make(chan models.UmindCanal)
var wg sync.WaitGroup
for w := 0; w < trabajadores; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for canal := range cola {
atenderCasilla(canal)
}
}()
}
for _, c := range canales {
cola <- c
}
close(cola)
wg.Wait()
}
// atenderCasilla revisa una casilla y responde lo que corresponda. Se marca la
// revisión ANTES de procesar: si la casilla está rota, el error queda en
// Auditoría y no se reintenta hasta el próximo intervalo — martillar cada
// minuto un login que falla es la forma de que el host del cliente nos bloquee.
func atenderCasilla(canal models.UmindCanal) {
models.MarcarRevisionCanal(canal.ID)
agente, err := models.GetUmindAgenteByID(canal.AgenteID)
if err != nil || !agente.Activo {
return
}
creds, err := DescifrarCredencialesCanal(canal.CredencialesEnc)
if err != nil {
registrarErrorCorreo(canal, "credenciales del canal corruptas", err)
return
}
cred := CredencialesIMAP{
Host: creds["imap_host"], Usuario: creds["usuario"],
Password: creds["password"], Encriptado: creds["imap_encriptado"],
}
fmt.Sscanf(creds["imap_puerto"], "%d", &cred.Puerto)
c, err := conectarIMAPGenerico(cred)
if err != nil {
registrarErrorCorreo(canal, "no se pudo entrar a la casilla", err)
return
}
defer c.Close()
correos, err := BajarNoLeidos(c, "INBOX", 24)
if err != nil {
registrarErrorCorreo(canal, "no se pudo leer la casilla", err)
return
}
for _, correo := range correos {
// Las guardas que separan "asistente útil" de "bucle con otro robot":
// nunca contestar correo automático, nunca contestarse a sí mismo.
e := correo.Correo
remitente := strings.ToLower(ExtraerEmail(e.From))
if e.Automatico || remitente == "" || remitente == strings.ToLower(creds["usuario"]) {
_ = MarcarLeido(c, correo.UID)
continue
}
if strings.TrimSpace(e.Texto) == "" {
_ = MarcarLeido(c, correo.UID)
continue
}
// La sesión es el remitente: sus correos siguientes conservan el hilo,
// igual que un chat de WhatsApp conserva el número.
sessionID := "mail:" + remitente
respuesta, err := ProcessWidgetMessage(agente, sessionID, e.Texto)
if err != nil {
registrarErrorCorreo(canal, "el agente no pudo responder un correo", err)
// Sin marcar leído: se reintenta en la próxima revisión.
continue
}
asunto := e.Subject
if asunto != "" && !strings.HasPrefix(strings.ToLower(asunto), "re:") {
asunto = "Re: " + asunto
}
if asunto == "" {
asunto = "Re: tu consulta"
}
cuerpo := strings.ReplaceAll(respuesta, "\n", "<br>")
err = EnviarPorSMTP(DatosSMTP{
Host: creds["smtp_host"], Encriptado: creds["smtp_encriptado"],
Usuario: creds["usuario"], Password: creds["password"],
Desde: creds["usuario"], DesdeNombre: agente.Nombre,
Puerto: puertoDe(creds["smtp_puerto"], 587),
}, ExtraerEmail(e.From), asunto, cuerpo)
if err != nil {
registrarErrorCorreo(canal, "no se pudo enviar la respuesta", err)
continue
}
// Leído recién cuando la respuesta salió: si algo falló en el medio,
// el correo queda sin leer y se reintenta.
_ = MarcarLeido(c, correo.UID)
}
}
func puertoDe(s string, def int) int {
var n int
if _, err := fmt.Sscanf(s, "%d", &n); err != nil || n == 0 {
return def
}
return n
}
func registrarErrorCorreo(canal models.UmindCanal, mensaje string, err error) {
log.Printf("[UMIND_CORREO] canal %d: %s: %v", canal.ID, mensaje, err)
models.RegistrarEventoUmind(canal.AgenteID, "error", "canal_correo", mensaje, err.Error())
}
+78
View File
@@ -0,0 +1,78 @@
package services
import (
"os"
"strings"
"testing"
)
func TestPuertoDe(t *testing.T) {
casos := map[string]int{"993": 993, "587": 587, "": 587, "abc": 587, "0": 587}
for entrada, quiero := range casos {
if got := puertoDe(entrada, 587); got != quiero {
t.Errorf("puertoDe(%q) = %d, quiero %d", entrada, got, quiero)
}
}
}
// Las propiedades que separan "asistente útil" de "incidente". Van sobre el
// código porque son de flujo con IMAP y SMTP reales de por medio — pero si
// alguien las borra, este test es el único que grita.
func TestGuardasDelCanalCorreo(t *testing.T) {
b, err := os.ReadFile("umind_canal_correo_service.go")
if err != nil {
t.Fatal(err)
}
s := string(b)
// La revisión se marca ANTES de procesar: una casilla con la contraseña
// cambiada no puede reintentar login cada minuto — el host del cliente
// termina bloqueando nuestra IP como atacante.
i := strings.Index(s, "func atenderCasilla")
marca := strings.Index(s[i:], "MarcarRevisionCanal")
conexion := strings.Index(s[i:], "conectarIMAPGenerico")
if marca < 0 || conexion < 0 || marca > conexion {
t.Error("la revisión tiene que marcarse antes de conectar: si no, un login que falla se reintenta cada minuto")
}
// Nunca responder correo automático ni responderse a sí mismo: es la
// diferencia entre un asistente y un bucle con otro autoresponder.
if !strings.Contains(s, "e.Automatico") {
t.Error("falta la guarda de correos automáticos")
}
if !strings.Contains(s, `remitente == strings.ToLower(creds["usuario"])`) {
t.Error("falta la guarda de no responderse a sí mismo")
}
// El correo se marca leído recién cuando la respuesta salió: si el envío
// falla, queda sin leer y se reintenta en la próxima revisión.
envio := strings.Index(s, "EnviarPorSMTP")
leido := strings.LastIndex(s, "MarcarLeido")
if envio < 0 || leido < envio {
t.Error("el marcado como leído tiene que ir después del envío de la respuesta")
}
}
// La casilla de una conexión (Lo que sabe) es de SOLO lectura: se consulta a
// pedido y el estado leído/no-leído es del cliente. Si alguna vez esta función
// marca algo, el asistente le está pisando la bandeja personal.
func TestLeerBandejaIMAPNoTocaLaCasilla(t *testing.T) {
b, err := os.ReadFile("umind_conexion_imap.go")
if err != nil {
t.Fatal(err)
}
s := string(b)
i := strings.Index(s, "func LeerBandejaIMAP")
j := strings.Index(s[i:], "\nfunc ")
cuerpo := s[i : i+j]
if strings.Contains(cuerpo, "MarcarLeido") || strings.Contains(cuerpo, "StoreFlagsAdd") {
t.Error("LeerBandejaIMAP no puede marcar correos: la casilla es del cliente")
}
if !strings.Contains(cuerpo, "Peek: true") {
t.Error("el fetch tiene que ir con Peek para no marcar como leído")
}
if !strings.Contains(cuerpo, "ReadOnly: true") {
t.Error("el SELECT de INBOX tiene que ser de solo lectura")
}
}
+162
View File
@@ -0,0 +1,162 @@
package services
import (
"encoding/json"
"fmt"
"strings"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// CredencialesIMAP es el blob que viaja cifrado en UmindConexion.CredencialesEnc.
type CredencialesIMAP struct {
Host string `json:"host"`
Puerto int `json:"puerto"`
Usuario string `json:"usuario"`
Password string `json:"password"`
Encriptado string `json:"encriptado"` // ssl | starttls
}
// CifrarCredencialesIMAP / DescifrarCredencialesIMAP — mismo esquema AES-GCM
// que los canales: un solo blob, nunca campos sueltos en claro.
func CifrarCredencialesIMAP(c CredencialesIMAP) (string, error) {
b, err := json.Marshal(c)
if err != nil {
return "", err
}
return CifrarSecretoUmind(string(b))
}
func DescifrarCredencialesIMAP(enc string) (CredencialesIMAP, error) {
var c CredencialesIMAP
claro, err := DescifrarSecretoUmind(enc)
if err != nil {
return c, err
}
err = json.Unmarshal([]byte(claro), &c)
return c, err
}
func conectarIMAPGenerico(cred CredencialesIMAP) (*imapclient.Client, error) {
puerto := cred.Puerto
if puerto == 0 {
puerto = 993
}
addr := fmt.Sprintf("%s:%d", cred.Host, puerto)
var c *imapclient.Client
var err error
if strings.EqualFold(cred.Encriptado, "starttls") {
c, err = imapclient.DialStartTLS(addr, nil)
} else {
c, err = imapclient.DialTLS(addr, nil)
}
if err != nil {
return nil, fmt.Errorf("no se pudo conectar a %s: %w", addr, err)
}
if err := c.Login(cred.Usuario, cred.Password).Wait(); err != nil {
c.Close()
return nil, fmt.Errorf("login rechazado para %s: %w", cred.Usuario, err)
}
return c, nil
}
// LeerBandejaIMAP trae los últimos correos de la casilla, más recientes
// primero. Es la tool "leer_bandeja" cuando la cuenta es IMAP: el dueño le
// pide al agente "revisame los correos de hoy y resumime" y esto es lo que el
// modelo recibe para resumir.
//
// Con Peek y sin marcar NADA: es la casilla personal del cliente y el estado
// leído/no-leído es suyo — un asistente que le "lee" los correos no puede
// dejárselos como leídos.
func LeerBandejaIMAP(conexion *models.UmindConexion, consulta string, limite int) ([]CorreoResumen, error) {
if limite <= 0 || limite > 10 {
limite = 10
}
cred, err := DescifrarCredencialesIMAP(conexion.CredencialesEnc)
if err != nil {
return nil, fmt.Errorf("credenciales de la cuenta corruptas: %w", err)
}
c, err := conectarIMAPGenerico(cred)
if err != nil {
return nil, err
}
defer c.Close()
buzon, err := c.Select("INBOX", &imap.SelectOptions{ReadOnly: true}).Wait()
if err != nil {
return nil, fmt.Errorf("no se pudo abrir INBOX: %w", err)
}
if buzon.NumMessages == 0 {
return []CorreoResumen{}, nil
}
// Los últimos N por número de secuencia: el orden de llegada. Se traen
// algunos de más cuando hay filtro, porque el filtrado es local.
traer := uint32(limite)
if consulta != "" {
traer = uint32(limite * 4)
}
desde := uint32(1)
if buzon.NumMessages > traer {
desde = buzon.NumMessages - traer + 1
}
var seq imap.SeqSet
seq.AddRange(desde, buzon.NumMessages)
msgs, err := c.Fetch(seq, &imap.FetchOptions{
BodySection: []*imap.FetchItemBodySection{{Peek: true}},
}).Collect()
if err != nil {
return nil, fmt.Errorf("no se pudieron traer los correos: %w", err)
}
filtro := strings.ToLower(strings.TrimSpace(consulta))
var out []CorreoResumen
// Al revés: los más recientes primero.
for i := len(msgs) - 1; i >= 0 && len(out) < limite; i-- {
var crudo []byte
for _, b := range msgs[i].BodySection {
crudo = b.Bytes
break
}
if len(crudo) == 0 {
continue
}
correo, err := parsearCorreo(crudo)
if err != nil {
continue
}
if filtro != "" &&
!strings.Contains(strings.ToLower(correo.Subject), filtro) &&
!strings.Contains(strings.ToLower(correo.From), filtro) &&
!strings.Contains(strings.ToLower(correo.Texto), filtro) {
continue
}
extracto := correo.Texto
if len([]rune(extracto)) > 300 {
extracto = string([]rune(extracto)[:300]) + "…"
}
out = append(out, CorreoResumen{
De: correo.From,
Asunto: correo.Subject,
Extracto: extracto,
})
}
return out, nil
}
// ProbarConexionIMAPGenerica valida credenciales sin tocar nada del buzón.
func ProbarConexionIMAPGenerica(cred CredencialesIMAP) error {
c, err := conectarIMAPGenerico(cred)
if err != nil {
return err
}
defer c.Close()
if _, err := c.Select("INBOX", &imap.SelectOptions{ReadOnly: true}).Wait(); err != nil {
return fmt.Errorf("no se pudo abrir INBOX: %w", err)
}
_ = c.Logout().Wait()
return nil
}