Files
soft_usite/pkg/services/soporte_imap_service.go
T
Lizandro GuarnizoandClaude Opus 5 d7c266f111 feat(soporte): filtro con IA para los correos entrantes, y que un correo no pueda abrir dos tickets
Filtro (opcional, apagado por defecto): antes de abrir ticket, la IA lee el
correo y decide si es soporte o ruido —newsletters, notificaciones de
plataformas, facturas de proveedores, spam—. Falla abierto: si la IA no está
configurada o se cae, el ticket se abre igual. Ignorar a un cliente es peor que
tener un ticket de más. Hay un campo de contexto del negocio para los casos
raros de cada uno.

Los correos masivos se descartan por sus propios encabezados (List-Unsubscribe,
Precedence, Auto-Submitted) sin gastar una llamada de IA.

Y el motivo por el que el mismo correo se leía y se contestaba de nuevo: la
deduplicación era solo un SELECT previo, que no sirve si el flag de leído no
llegó a guardarse o si dos instancias leen el buzón a la vez. Ahora hay un
índice único parcial sobre message_id: la base rechaza el segundo, y ese rechazo
se trata como "ya estaba", no como error — sin ticket repetido y sin segunda
auto-respuesta.

Si algún correo no se pudo marcar como leído, "Revisar buzón ahora" lo dice en
vez de dejarlo solo en el log: es la señal de que van a volver a leerse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:36:01 -05:00

362 lines
11 KiB
Go

package services
import (
"encoding/base64"
"fmt"
"io"
"log"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/mail"
"strings"
"sync"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// El poller corre por cron; este lock evita que dos corridas se pisen si una
// tarda más que el intervalo (buzón grande, servidor lento).
var imapEnCurso sync.Mutex
// RevisarBuzonSoporte lee los correos sin leer del buzón configurado y los
// convierte en tickets. Cada correo procesado se marca como \Seen, que es lo
// que evita volver a leerlo; la deduplicación por Message-Id es el segundo
// cinturón por si el marcado falla.
func RevisarBuzonSoporte() {
cfg, err := models.GetSoporteWebhookActivo()
if err != nil || cfg == nil || !cfg.ImapActivo || cfg.ImapHost == "" {
return
}
if !imapEnCurso.TryLock() {
log.Printf("[SoporteIMAP] corrida anterior todavía en curso, se salta esta")
return
}
defer imapEnCurso.Unlock()
encontrados, procesados, err := revisarBuzon(cfg)
if err != nil {
log.Printf("[SoporteIMAP] %v", err)
return
}
if encontrados > 0 {
log.Printf("[SoporteIMAP] %d sin leer, %d procesado(s)", encontrados, procesados)
}
}
// RevisarBuzonSoporteConDetalle es la versión que usa el botón "Revisar buzón
// ahora": devuelve los números para poder mostrarlos, en vez de solo loguearlos.
func RevisarBuzonSoporteConDetalle() (encontrados, procesados int, err error) {
cfg, err := models.GetSoporteWebhookActivo()
if err != nil || cfg == nil {
return 0, 0, fmt.Errorf("no hay configuración de soporte guardada")
}
if !cfg.ImapActivo {
return 0, 0, fmt.Errorf("la lectura del buzón está desactivada: tildá \"Leer el buzón cada 2 minutos\" y guardá")
}
if cfg.ImapHost == "" {
return 0, 0, fmt.Errorf("falta el servidor IMAP en la configuración")
}
if !imapEnCurso.TryLock() {
return 0, 0, fmt.Errorf("hay una revisión en curso, probá en unos segundos")
}
defer imapEnCurso.Unlock()
return revisarBuzon(cfg)
}
// ProbarConexionImap valida credenciales sin procesar nada — lo usa el botón
// "Probar" de la vista de configuración.
func ProbarConexionImap(cfg *models.SoporteWebhookConfig) (string, error) {
c, err := conectarImap(cfg)
if err != nil {
return "", err
}
defer c.Close()
carpeta := cfg.ImapCarpeta
if carpeta == "" {
carpeta = "INBOX"
}
datos, err := c.Select(carpeta, &imap.SelectOptions{ReadOnly: true}).Wait()
if err != nil {
return "", fmt.Errorf("no se pudo abrir la carpeta %q: %w", carpeta, err)
}
sinLeer := 0
if r, err := c.UIDSearch(&imap.SearchCriteria{
NotFlag: []imap.Flag{imap.FlagSeen},
}, &imap.SearchOptions{ReturnAll: true}).Wait(); err == nil {
sinLeer = len(r.AllUIDs())
}
_ = c.Logout().Wait()
return fmt.Sprintf("Conexión correcta. %s: %d mensajes, %d sin leer.", carpeta, datos.NumMessages, sinLeer), nil
}
func conectarImap(cfg *models.SoporteWebhookConfig) (*imapclient.Client, error) {
pass, err := DescifrarSecretoUmind(cfg.ImapPasswordEnc)
if err != nil || pass == "" {
return nil, fmt.Errorf("no hay contraseña IMAP guardada")
}
port := cfg.ImapPort
if port == 0 {
port = 993
}
addr := fmt.Sprintf("%s:%d", cfg.ImapHost, port)
var c *imapclient.Client
if strings.EqualFold(cfg.ImapEncryption, "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(cfg.ImapUsername, pass).Wait(); err != nil {
c.Close()
return nil, fmt.Errorf("login IMAP rechazado para %s: %w", cfg.ImapUsername, err)
}
return c, nil
}
func revisarBuzon(cfg *models.SoporteWebhookConfig) (int, int, error) {
c, err := conectarImap(cfg)
if err != nil {
return 0, 0, err
}
defer c.Close()
correos, err := BajarNoLeidos(c, cfg.ImapCarpeta)
if err != nil {
return 0, 0, err
}
if len(correos) == 0 {
return 0, 0, nil
}
fallosAlMarcar := 0
procesados := 0
for _, correo := range correos {
if IngestarCorreoSoporte(cfg, correo.Correo) {
procesados++
}
// Se marca leído aunque se haya ignorado por duplicado: si no, se
// vuelve a bajar en cada corrida para siempre.
if err := MarcarLeido(c, correo.UID); err != nil {
log.Printf("[SoporteIMAP] no se pudo marcar leído el uid=%v: %v", correo.UID, err)
fallosAlMarcar++
}
}
if fallosAlMarcar > 0 {
return len(correos), procesados, fmt.Errorf(
"se procesaron %d correo(s), pero %d no se pudieron marcar como leídos en el servidor: van a volver a leerse",
procesados, fallosAlMarcar)
}
return len(correos), procesados, nil
}
// CorreoConUID es un correo del buzón junto con su UID, que es lo que hace
// falta después para marcarlo como leído.
type CorreoConUID struct {
UID imap.UID
Correo CorreoSoporte
}
// BajarNoLeidos abre la carpeta, busca los mensajes sin leer y los devuelve ya
// parseados. No los marca: eso pasa recién cuando se procesaron.
func BajarNoLeidos(c *imapclient.Client, carpeta string) ([]CorreoConUID, error) {
if carpeta == "" {
carpeta = "INBOX"
}
if _, err := c.Select(carpeta, nil).Wait(); err != nil {
return nil, fmt.Errorf("no se pudo abrir la carpeta %q: %w", carpeta, err)
}
// UIDSearch y no Search: Search devuelve números de secuencia, y de ahí no
// salen UIDs — la búsqueda "encontraba" mensajes y la lista quedaba vacía.
buscados, err := c.UIDSearch(&imap.SearchCriteria{
NotFlag: []imap.Flag{imap.FlagSeen},
}, &imap.SearchOptions{ReturnAll: true}).Wait()
if err != nil {
return nil, fmt.Errorf("búsqueda de no leídos falló: %w", err)
}
uids := buscados.AllUIDs()
if len(uids) == 0 {
return nil, nil
}
// ponytail: tope por corrida para no atragantarse con un buzón que nunca
// se leyó. Los que sobran quedan sin leer y entran en la corrida siguiente.
const maxPorCorrida = 50
if len(uids) > maxPorCorrida {
uids = uids[:maxPorCorrida]
}
// Peek para que el correo quede leído solo si llegamos a procesarlo.
msgs, err := c.Fetch(imap.UIDSetNum(uids...), &imap.FetchOptions{
BodySection: []*imap.FetchItemBodySection{{Peek: true}},
}).Collect()
if err != nil {
return nil, fmt.Errorf("descarga de correos falló: %w", err)
}
var out []CorreoConUID
for _, m := range msgs {
var crudo []byte
for _, b := range m.BodySection {
crudo = b.Bytes
break
}
if len(crudo) == 0 {
continue
}
correo, err := parsearCorreo(crudo)
if err != nil {
log.Printf("[SoporteIMAP] no se pudo leer un correo (uid=%v): %v", m.UID, err)
continue
}
out = append(out, CorreoConUID{UID: m.UID, Correo: correo})
}
return out, nil
}
// MarcarLeido pone el flag \Seen, que es lo que evita releer el mismo correo.
func MarcarLeido(c *imapclient.Client, uid imap.UID) error {
return c.Store(imap.UIDSetNum(uid), &imap.StoreFlags{
Op: imap.StoreFlagsAdd,
Silent: true,
Flags: []imap.Flag{imap.FlagSeen},
}, nil).Close()
}
// parsearCorreo saca remitente, asunto y cuerpo de texto de un mensaje RFC822.
func parsearCorreo(crudo []byte) (CorreoSoporte, error) {
msg, err := mail.ReadMessage(strings.NewReader(string(crudo)))
if err != nil {
return CorreoSoporte{}, err
}
dec := new(mime.WordDecoder)
decodificar := func(s string) string {
if out, err := dec.DecodeHeader(s); err == nil {
return out
}
return s
}
from := decodificar(msg.Header.Get("From"))
nombre := ExtraerNombre(from)
if dir, err := mail.ParseAddress(from); err == nil && dir.Name != "" {
nombre = dir.Name
}
cuerpo, err := cuerpoDeTexto(msg.Header.Get("Content-Type"), msg.Body)
if err != nil {
return CorreoSoporte{}, err
}
return CorreoSoporte{
From: from,
FromName: nombre,
Subject: decodificar(msg.Header.Get("Subject")),
Texto: limpiarCitas(cuerpo),
MessageID: strings.TrimSpace(msg.Header.Get("Message-Id")),
Automatico: esCorreoAutomatico(msg.Header),
}, nil
}
// cuerpoDeTexto devuelve el text/plain del correo; si es multipart baja por las
// partes hasta encontrarlo y cae al HTML solo si no hay texto plano.
func cuerpoDeTexto(contentType string, cuerpo io.Reader) (string, error) {
medio, params, err := mime.ParseMediaType(contentType)
if err != nil {
medio = "text/plain"
params = map[string]string{}
}
if !strings.HasPrefix(medio, "multipart/") {
b, err := io.ReadAll(io.LimitReader(cuerpo, 1<<20))
if err != nil {
return "", err
}
return string(b), nil
}
frontera := params["boundary"]
if frontera == "" {
return "", fmt.Errorf("multipart sin boundary")
}
lector := multipart.NewReader(cuerpo, frontera)
var html string
for {
parte, err := lector.NextPart()
if err == io.EOF {
break
}
if err != nil {
return "", err
}
tipoParte, _, _ := mime.ParseMediaType(parte.Header.Get("Content-Type"))
if strings.HasPrefix(tipoParte, "multipart/") {
anidado, err := cuerpoDeTexto(parte.Header.Get("Content-Type"), parte)
if err == nil && anidado != "" {
return anidado, nil
}
continue
}
if tipoParte != "text/plain" && tipoParte != "text/html" {
continue
}
var lect io.Reader = io.LimitReader(parte, 1<<20)
switch strings.ToLower(parte.Header.Get("Content-Transfer-Encoding")) {
case "quoted-printable":
lect = quotedprintable.NewReader(lect)
case "base64":
lect = base64.NewDecoder(base64.StdEncoding, lect)
}
b, err := io.ReadAll(lect)
if err != nil {
continue
}
if tipoParte == "text/plain" {
return string(b), nil
}
html = string(b)
}
return html, nil
}
// limpiarCitas corta el correo en la primera línea citada o en el separador
// típico de respuesta, para que el ticket no repita todo el hilo anterior.
func limpiarCitas(texto string) string {
lineas := strings.Split(strings.ReplaceAll(texto, "\r\n", "\n"), "\n")
var out []string
for _, l := range lineas {
t := strings.TrimSpace(l)
if strings.HasPrefix(t, ">") ||
strings.HasPrefix(t, "-----Original Message-----") ||
strings.HasPrefix(t, "-----Mensaje original-----") ||
(strings.HasPrefix(t, "El ") && strings.HasSuffix(t, "escribió:")) ||
(strings.HasPrefix(t, "On ") && strings.HasSuffix(t, "wrote:")) {
break
}
out = append(out, l)
}
return strings.TrimSpace(strings.Join(out, "\n"))
}
// esCorreoAutomatico reconoce newsletters y notificaciones de máquina por sus
// encabezados estándar. Es gratis y no se equivoca, así que va antes que
// cualquier modelo.
func esCorreoAutomatico(h mail.Header) bool {
if h.Get("List-Unsubscribe") != "" || h.Get("List-Id") != "" {
return true
}
switch strings.ToLower(strings.TrimSpace(h.Get("Precedence"))) {
case "bulk", "list", "junk":
return true
}
if a := strings.ToLower(strings.TrimSpace(h.Get("Auto-Submitted"))); a != "" && a != "no" {
return true
}
return false
}