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:
co-authored by
Claude Opus 5
parent
ffa24a068f
commit
eddec45857
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user