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:
co-authored by
Claude Opus 5
parent
1b599606f7
commit
4674413257
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user