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>
This commit is contained in:
Lizandro Guarnizo
2026-08-17 19:36:01 -05:00
co-authored by Claude Opus 5
parent 5875f9e36b
commit d7c266f111
9 changed files with 306 additions and 27 deletions
+36
View File
@@ -1,12 +1,14 @@
package services
import (
"errors"
"log"
"regexp"
"strconv"
"strings"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"gorm.io/gorm"
)
// CorreoSoporte es un correo entrante ya normalizado, sin importar por dónde
@@ -18,6 +20,9 @@ type CorreoSoporte struct {
Subject string
Texto string
MessageID string
// Automatico marca los correos masivos o generados por una máquina, según
// sus propios encabezados (List-Unsubscribe, Precedence, Auto-Submitted).
Automatico bool
}
var ticketRefRe = regexp.MustCompile(`(?i)\[Ticket #(\d+)\]`)
@@ -69,6 +74,10 @@ func IngestarCorreoSoporte(cfg *models.SoporteWebhookConfig, e CorreoSoporte) bo
MessageID: e.MessageID,
}
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
}
log.Printf("[Soporte] Error agregando mensaje al ticket #%d: %v", hilo.ID, err)
return false
}
@@ -80,6 +89,16 @@ func IngestarCorreoSoporte(cfg *models.SoporteWebhookConfig, e CorreoSoporte) bo
return true
}
// Filtro 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 {
if cl := ClasificarCorreoSoporte(e, cfg.ContextoNegocio); !cl.EsSoporte {
log.Printf("[Soporte] Descartado por el filtro (%s): %q de %s — %s",
cl.Categoria, e.Subject, fromEmail, cl.Motivo)
return false
}
}
ticket := &models.ProyectoTicket{
AutorNombre: fromName,
EmailFrom: fromEmail,
@@ -93,6 +112,12 @@ func IngestarCorreoSoporte(cfg *models.SoporteWebhookConfig, e CorreoSoporte) bo
ticket.AsignadoA = cfg.AsignarA
}
if err := models.CreateProyectoTicket(ticket); err != nil {
// El índice único sobre message_id es la última defensa contra el mismo
// 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
}
log.Printf("[Soporte] Error creando ticket: %v", err)
return false
}
@@ -206,3 +231,14 @@ func ResumirTextoSoporte(texto string) string {
}
return string([]rune(t)[:700]) + "…"
}
// esCorreoDuplicado reconoce el rechazo del índice único de message_id.
func esCorreoDuplicado(err error) bool {
if err == nil {
return false
}
if errors.Is(err, gorm.ErrDuplicatedKey) {
return true
}
return strings.Contains(strings.ToLower(err.Error()), "duplicate key value")
}