Files
soft_usite/pkg/services/soporte_ingesta_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

245 lines
8.1 KiB
Go

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
// llegó (webhook de SendGrid/Mailgun o buzón IMAP). Los dos caminos terminan
// acá para que enhebrado, deduplicación y auto-respuesta se comporten igual.
type CorreoSoporte struct {
From string // puede venir como "Nombre <mail@x.com>"
FromName string
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+)\]`)
// IngestarCorreoSoporte convierte un correo en ticket nuevo o en respuesta a un
// 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 {
if strings.TrimSpace(e.From) == "" {
log.Printf("[Soporte] Correo sin remitente, ignorado (asunto=%q)", e.Subject)
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.
if strings.TrimSpace(e.Subject) == "" {
e.Subject = "(sin asunto)"
}
// Deduplicación: el proveedor puede reintentar la entrega, y el poller IMAP
// 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
}
fromEmail := ExtraerEmail(e.From)
fromName := e.FromName
if fromName == "" {
fromName = ExtraerNombre(e.From)
}
if fromName == "" {
fromName = fromEmail
}
contenido := strings.TrimSpace(e.Texto)
if len(contenido) > 5000 {
contenido = contenido[:5000]
}
// Enhebrado: si el asunto trae "[Ticket #N]" (lo agregamos nosotros en el
// auto-ack) y ese ticket es del mismo remitente, es una respuesta — se
// agrega como mensaje en vez de abrir un ticket nuevo.
if hilo := buscarTicketDeHilo(fromEmail, e.Subject); hilo != nil {
msg := &models.TicketMensaje{
TicketID: hilo.ID,
Contenido: contenido,
EsAdmin: false,
AutorNombre: fromName,
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
}
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
}
// 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,
Titulo: e.Subject,
Descripcion: contenido,
Estado: "abierto",
Origen: "email",
MessageID: e.MessageID,
}
if cfg != nil && cfg.AsignarA != nil {
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
}
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)
}
return true
}
// buscarTicketDeHilo intenta encontrar el ticket al que pertenece una respuesta:
// primero por el token "[Ticket #N]" en el asunto (verificando que sea del mismo
// remitente, para que nadie pueda inyectar mensajes en el ticket de otro
// adivinando el número), y si no hay token, por el último ticket abierto de ese
// remitente cuando el asunto tiene pinta de respuesta (Re:/RE:/Fwd:).
func buscarTicketDeHilo(fromEmail, subject string) *models.ProyectoTicket {
if m := ticketRefRe.FindStringSubmatch(subject); m != nil {
id, _ := strconv.ParseUint(m[1], 10, 32)
if id > 0 {
t, err := models.GetTicketByID(uint(id))
if err == nil && strings.EqualFold(t.EmailFrom, fromEmail) {
return t
}
}
}
lower := strings.ToLower(strings.TrimSpace(subject))
if strings.HasPrefix(lower, "re:") || strings.HasPrefix(lower, "fwd:") || strings.HasPrefix(lower, "fw:") {
if t, err := models.GetUltimoTicketAbiertoPorEmail(fromEmail); err == nil {
return t
}
}
return nil
}
// ExtraerEmail saca la dirección de un "Nombre <mail@x.com>".
func ExtraerEmail(s string) string {
s = strings.TrimSpace(s)
if idx := strings.LastIndex(s, "<"); idx >= 0 {
s = s[idx+1:]
}
if idx := strings.LastIndex(s, ">"); idx >= 0 {
s = s[:idx]
}
return strings.TrimSpace(s)
}
// ExtraerNombre saca el nombre de un "Nombre <mail@x.com>" ("" si no trae).
func ExtraerNombre(s string) string {
s = strings.TrimSpace(s)
if idx := strings.Index(s, "<"); idx >= 0 {
return strings.TrimSpace(s[:idx])
}
return ""
}
// notificarTicketDeCorreo avisa al staff de un correo entrante por los canales
// que estén prendidos en /app/notif-config (in-app, correo y Telegram), con el
// mismo formato que los tickets del portal.
//
// El cuerpo que viaja en la notificación es el resumen, no el correo entero:
// el ticket guarda el texto completo igual.
func notificarTicketDeCorreo(ticket *models.ProyectoTicket, contenido string, esRespuesta bool) {
resumen := ResumirTextoSoporte(contenido)
origen := "Correo"
if ticket.EmailFrom != "" {
origen = "Correo · " + ticket.EmailFrom
}
if esRespuesta {
DispatchTicketRespuestaCliente(ticket, resumen, origen)
return
}
// Copia para no pisar el texto completo que ya se guardó en el ticket.
paraNotificar := *ticket
paraNotificar.Descripcion = resumen
if models.GetNotifConfig("ticket_nuevo", "admin") == nil {
// Sin configuración de notificaciones se mantiene lo de siempre: correo
// al admin. Si no, activar el módulo dejaría a alguien sin avisos.
SendSoporteNotifAdmin(&paraNotificar)
return
}
DispatchTicketNuevo(&paraNotificar, nil, origen)
}
// ResumirTextoSoporte deja el correo en algo que se pueda leer de un vistazo en
// Telegram. Los correos cortos van tal cual: pedirle a la IA que resuma tres
// renglones es gastar una llamada para decir lo mismo. Los largos —hilos
// reenviados, capturas pegadas— sí se resumen, y si la IA falla se recorta.
func ResumirTextoSoporte(texto string) string {
t := strings.TrimSpace(texto)
if len([]rune(t)) <= 700 {
return t
}
resumen, err := CompletarTextoIA("ia",
"Resumís correos de soporte para avisarle al equipo por Telegram. "+
"Máximo 3 renglones. Decí qué pide o reporta la persona y, si los hay, "+
"incluí datos concretos (número de factura, pedido, fecha, monto). "+
"Sin saludos, sin despedidas, sin repetir el asunto, sin inventar nada.",
t)
if err != nil {
log.Printf("[Soporte] No se pudo resumir el correo, se manda recortado: %v", err)
return string([]rune(t)[:700]) + "…"
}
if r := strings.TrimSpace(resumen); r != "" {
return r
}
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")
}