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>
163 lines
4.6 KiB
Go
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
|
|
}
|