fix(soporte): el filtro se estaba comiendo correos nuevos, y nadie se enteraba

Una respuesta a un hilo existente saltea el clasificador; un correo nuevo no.
Por eso el sistema parecía funcionar solo con las respuestas: los correos
nuevos que el modelo consideraba "no soporte" desaparecían sin ticket, sin
acuse al cliente y sin más rastro que un renglón de log que nadie mira.

Dos cambios:

- El filtro solo opina sobre desconocidos. Si el remitente es un cliente o un
  usuario del portal, siempre se abre el ticket. Para eso la resolución del
  remitente pasa a correr antes del filtro. A un cliente registrado no se le
  descarta el correo por lo que diga un modelo.

- Lo descartado se ve. "Revisar buzón ahora" informa cuántos dejó afuera el
  filtro, con asunto, remitente y motivo. Guardado en memoria: es diagnóstico
  de hace un rato, no algo que valga una tabla.

Además, si la respuesta automática está apagada, el log lo dice al crear el
ticket — era la otra explicación posible de "no me contestó" y no se distinguía.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-17 19:56:32 -05:00
co-authored by Claude Opus 5
parent 1b599606f7
commit 4674413257
4 changed files with 78 additions and 25 deletions
+32 -1
View File
@@ -139,11 +139,15 @@ func revisarBuzon(cfg *models.SoporteWebhookConfig) (int, int, error) {
return 0, 0, nil
}
fallosAlMarcar := 0
var descartes []string
procesados := 0
for _, correo := range correos {
if IngestarCorreoSoporte(cfg, correo.Correo) {
ok, descarte := IngestarCorreoSoporteConDetalle(cfg, correo.Correo)
if ok {
procesados++
} else if descarte != "" {
descartes = append(descartes, descarte)
}
// Se marca leído aunque se haya ignorado por duplicado: si no, se
// vuelve a bajar en cada corrida para siempre.
@@ -152,6 +156,7 @@ func revisarBuzon(cfg *models.SoporteWebhookConfig) (int, int, error) {
fallosAlMarcar++
}
}
ultimosDescartes.guardar(descartes)
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",
@@ -160,6 +165,32 @@ func revisarBuzon(cfg *models.SoporteWebhookConfig) (int, int, error) {
return len(correos), procesados, nil
}
// ultimosDescartes guarda lo que el filtro dejó afuera en la última corrida,
// para poder mostrarlo en la pantalla de configuración. En memoria a propósito:
// es información de diagnóstico de hace un rato, no algo que valga una tabla.
var ultimosDescartes = &descartesRecientes{}
type descartesRecientes struct {
mu sync.Mutex
items []string
}
func (d *descartesRecientes) guardar(items []string) {
d.mu.Lock()
defer d.mu.Unlock()
if len(items) > 0 {
d.items = items
}
}
// UltimosCorreosDescartados devuelve los descartes de la última corrida que
// tuvo alguno.
func UltimosCorreosDescartados() []string {
ultimosDescartes.mu.Lock()
defer ultimosDescartes.mu.Unlock()
return append([]string(nil), ultimosDescartes.items...)
}
// criterioNoLeidos arma la búsqueda: no leídos y, si hay ventana configurada,
// recibidos dentro de ella.
//
+32 -18
View File
@@ -2,6 +2,7 @@ package services
import (
"errors"
"fmt"
"log"
"regexp"
"strconv"
@@ -31,9 +32,17 @@ var ticketRefRe = regexp.MustCompile(`(?i)\[Ticket #(\d+)\]`)
// ticket existente. Devuelve true si se procesó algo (false = ignorado por
// duplicado o por venir vacío).
func IngestarCorreoSoporte(cfg *models.SoporteWebhookConfig, e CorreoSoporte) bool {
ok, _ := IngestarCorreoSoporteConDetalle(cfg, e)
return ok
}
// IngestarCorreoSoporteConDetalle es la misma ingesta, pero además dice por qué
// se descartó un correo. Un descarte que solo va al log es invisible: si el
// filtro se come el correo de un cliente, nadie se entera.
func IngestarCorreoSoporteConDetalle(cfg *models.SoporteWebhookConfig, e CorreoSoporte) (procesado bool, descarte string) {
if strings.TrimSpace(e.From) == "" {
log.Printf("[Soporte] Correo sin remitente, ignorado (asunto=%q)", e.Subject)
return false
return false, ""
}
// Un correo sin asunto es raro pero existe, y perderlo en silencio es peor
// que abrir un ticket con un título feo.
@@ -45,7 +54,7 @@ func IngestarCorreoSoporte(cfg *models.SoporteWebhookConfig, e CorreoSoporte) bo
// puede releer un correo si falló el marcado como leído.
if models.EmailMessageIDYaProcesado(e.MessageID) {
log.Printf("[Soporte] Correo duplicado ignorado (message_id=%s)", e.MessageID)
return false
return false, ""
}
fromEmail := ExtraerEmail(e.From)
@@ -76,17 +85,17 @@ func IngestarCorreoSoporte(cfg *models.SoporteWebhookConfig, e CorreoSoporte) bo
if err := models.CreateTicketMensaje(msg); err != nil {
if esCorreoDuplicado(err) {
log.Printf("[Soporte] El correo %s ya estaba en el ticket #%d", e.MessageID, hilo.ID)
return false
return false, ""
}
log.Printf("[Soporte] Error agregando mensaje al ticket #%d: %v", hilo.ID, err)
return false
return false, ""
}
if hilo.Estado == "resuelto" || hilo.Estado == "cerrado" {
_ = models.UpdateTicketEstado(hilo.ID, "abierto")
}
log.Printf("[Soporte] Respuesta agregada al ticket #%d (%s)", hilo.ID, fromEmail)
notificarTicketDeCorreo(hilo, contenido, true)
return true
return true, ""
}
ticket := &models.ProyectoTicket{
@@ -99,23 +108,26 @@ func IngestarCorreoSoporte(cfg *models.SoporteWebhookConfig, e CorreoSoporte) bo
MessageID: e.MessageID,
}
// Filtro y triage con IA: solo para tickets nuevos. Una respuesta a un hilo
// ya existente es soporte por definición, no hace falta preguntárselo a nadie.
if cfg != nil && cfg.ClasificarConIA {
// De quién es: sin esto un ticket de correo es un texto suelto que no se
// puede cruzar con nada. Si no matchea nadie, queda como contacto externo.
// Va antes del filtro a propósito: saber quién escribe cambia si se filtra.
resolverRemitente(ticket, fromEmail)
// Filtro y triage con IA: solo para tickets nuevos y solo para desconocidos.
// Una respuesta a un hilo es soporte por definición, y a un cliente
// registrado no se le descarta el correo por lo que opine un modelo.
conocido := ticket.ClienteID != nil || ticket.PortalUserID != nil
if cfg != nil && cfg.ClasificarConIA && !conocido {
cl := ClasificarCorreoSoporte(e, cfg.ContextoNegocio)
if !cl.EsSoporte {
log.Printf("[Soporte] Descartado por el filtro (%s): %q de %s %s",
cl.Categoria, e.Subject, fromEmail, cl.Motivo)
return false
motivo := fmt.Sprintf("%q de %s — %s (%s)", e.Subject, fromEmail, cl.Motivo, cl.Categoria)
log.Printf("[Soporte] Descartado por el filtro: %s", motivo)
return false, motivo
}
ticket.Categoria = cl.Categoria
ticket.Prioridad = cl.Prioridad
}
// De quién es: sin esto un ticket de correo es un texto suelto que no se
// puede cruzar con nada. Si no matchea nadie, queda como contacto externo.
resolverRemitente(ticket, fromEmail)
if cfg != nil && cfg.AsignarA != nil {
ticket.AsignadoA = cfg.AsignarA
}
@@ -124,18 +136,20 @@ func IngestarCorreoSoporte(cfg *models.SoporteWebhookConfig, e CorreoSoporte) bo
// correo entrando dos veces. Si salta, no es un error: es que ya estaba.
if esCorreoDuplicado(err) {
log.Printf("[Soporte] Correo duplicado rechazado por la base (message_id=%s)", e.MessageID)
return false
return false, ""
}
log.Printf("[Soporte] Error creando ticket: %v", err)
return false
return false, ""
}
log.Printf("[Soporte] Ticket #%d creado desde email (%s): %s", ticket.ID, fromEmail, e.Subject)
notificarTicketDeCorreo(ticket, contenido, false)
if cfg != nil && cfg.ResponderAuto {
SendSoporteAutoRespuesta(ticket)
} else {
log.Printf("[Soporte] Ticket #%d sin acuse al cliente: la respuesta automática está desactivada", ticket.ID)
}
return true
return true, ""
}
// buscarTicketDeHilo intenta encontrar el ticket al que pertenece una respuesta:
+6 -4
View File
@@ -205,10 +205,12 @@
</div>
<p class="text-xs text-slate-400 mb-3">
Antes de abrir un ticket, la IA lee el correo y decide si es una solicitud de soporte o ruido
(newsletters, notificaciones automáticas, facturas de proveedores, spam). Lo que descarta no abre
ticket, pero el correo sigue en el buzón y queda registrado en el log.
Ante la duda abre el ticket: es peor ignorar a un cliente que tener uno de más.
Si la IA no responde, el ticket se abre igual.
(newsletters, notificaciones automáticas, facturas de proveedores, spam).
<strong>Solo se filtra a los desconocidos</strong>: si el remitente es un cliente o un usuario del
portal, siempre abre ticket. Ante la duda también abre: es peor ignorar a alguien que tener un
ticket de más, y si la IA no responde el ticket se abre igual.
Lo descartado no abre ticket, pero el correo sigue en el buzón y "Revisar buzón ahora" te dice qué
dejó afuera y por qué.
</p>
<label class="block text-xs font-medium text-slate-600 mb-1">Contexto del negocio (opcional)</label>
<textarea x-model="cfg.contexto_negocio" rows="3"
+8 -2
View File
@@ -6,6 +6,7 @@ import (
"log"
"regexp"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/app"
@@ -336,8 +337,13 @@ func RevisarBuzonAhora(c *fiber.Ctx) error {
if encontrados == 0 {
return c.JSON(fiber.Map{"ok": true, "message": "Conectó bien, pero no hay correos sin leer en el buzón"})
}
return c.JSON(fiber.Map{"ok": true, "message": fmt.Sprintf(
"%d correo(s) sin leer, %d convertido(s) en ticket. El resto ya estaba registrado.", encontrados, procesados)})
msg := fmt.Sprintf("%d correo(s) sin leer, %d convertido(s) en ticket. El resto ya estaba registrado.", encontrados, procesados)
// Si el filtro descartó algo hay que decirlo acá: un correo de un cliente
// que se comió la IA es exactamente lo que nadie va a ir a buscar al log.
if descartes := services.UltimosCorreosDescartados(); len(descartes) > 0 {
msg += fmt.Sprintf(" El filtro descartó %d: %s", len(descartes), strings.Join(descartes, " | "))
}
return c.JSON(fiber.Map{"ok": true, "message": msg})
}
// BorradorRespuestaTicket propone una respuesta usando la base de conocimiento.