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 " 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 ". 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 " ("" 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 } }