diff --git a/pkg/services/soporte_imap_service.go b/pkg/services/soporte_imap_service.go index a7ead75..dc4567d 100644 --- a/pkg/services/soporte_imap_service.go +++ b/pkg/services/soporte_imap_service.go @@ -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. // diff --git a/pkg/services/soporte_ingesta_service.go b/pkg/services/soporte_ingesta_service.go index 28030da..a88659b 100644 --- a/pkg/services/soporte_ingesta_service.go +++ b/pkg/services/soporte_ingesta_service.go @@ -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: diff --git a/resources/views/soporte_webhook.html b/resources/views/soporte_webhook.html index 893ca6a..ce17b02 100644 --- a/resources/views/soporte_webhook.html +++ b/resources/views/soporte_webhook.html @@ -205,10 +205,12 @@
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). + Solo se filtra a los desconocidos: 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é.