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>
289 lines
10 KiB
Go
289 lines
10 KiB
Go
package services
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"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 {
|
|
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, ""
|
|
}
|
|
// 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, ""
|
|
}
|
|
|
|
ticket := &models.ProyectoTicket{
|
|
AutorNombre: fromName,
|
|
EmailFrom: fromEmail,
|
|
Titulo: e.Subject,
|
|
Descripcion: contenido,
|
|
Estado: "abierto",
|
|
Origen: "email",
|
|
MessageID: e.MessageID,
|
|
}
|
|
|
|
// 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 {
|
|
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
|
|
}
|
|
|
|
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)
|
|
} else {
|
|
log.Printf("[Soporte] Ticket #%d sin acuse al cliente: la respuesta automática está desactivada", ticket.ID)
|
|
}
|
|
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(¶Notificar)
|
|
return
|
|
}
|
|
DispatchTicketNuevo(¶Notificar, 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")
|
|
}
|
|
|
|
// resolverRemitente ata el ticket a quien escribió: primero por usuario del
|
|
// portal (que además le deja ver el ticket desde su portal) y si no, por el
|
|
// correo del cliente. Solo coincidencia exacta de dirección — ver el comentario
|
|
// de models.GetClientePorEmail sobre por qué no se busca por dominio.
|
|
func resolverRemitente(ticket *models.ProyectoTicket, email string) {
|
|
if email == "" {
|
|
return
|
|
}
|
|
if pu, err := models.GetPortalUserByEmail(email); err == nil && pu != nil {
|
|
id := pu.ID
|
|
ticket.PortalUserID = &id
|
|
if ids := models.GetClienteIDsForPortalUser(pu); len(ids) == 1 {
|
|
ticket.ClienteID = &ids[0]
|
|
}
|
|
return
|
|
}
|
|
if cli, err := models.GetClientePorEmail(email); err == nil && cli != nil {
|
|
id := cli.ID
|
|
ticket.ClienteID = &id
|
|
}
|
|
}
|