Files
soft_usite/pkg/services/umind_conexion_imap.go
T
Lizandro GuarnizoandClaude Opus 5 eddec45857 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>
2026-08-23 20:08:46 -05:00

163 lines
4.6 KiB
Go

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
}