Files
soft_usite/pkg/services/soporte_ingesta_service.go
T
Lizandro GuarnizoandClaude Opus 5 5875f9e36b fix(soporte): el buzón IMAP nunca se leía — la búsqueda pedía UIDs a una búsqueda por secuencia
Usé c.Search, que devuelve números de secuencia, y después le pedí los UIDs al
resultado. go-imap devuelve nil ahí, así que la lista salía siempre vacía: el
cron conectaba bien, buscaba bien, y se iba sin leer nada ni escribir un solo
error en el log. Va con UIDSearch.

Con eso se arreglan las dos cosas: no leía y no creaba tickets.

Además:
- Fetch con Peek: el correo queda marcado como leído solo si se llegó a
  procesar. Antes, un fallo a mitad de camino lo perdía para siempre.
- "Probar conexión" ahora informa cuántos mensajes hay en la carpeta y cuántos
  sin leer; "Revisar buzón ahora" informa cuántos encontró y cuántos convirtió
  en ticket, y avisa si la lectura está desactivada.
- Un correo sin asunto abre ticket como "(sin asunto)" en vez de descartarse en
  silencio, y los descartes quedan en el log.

Los tres tests nuevos corren contra un servidor IMAP en memoria: bajar los no
leídos, que marcar como leído los saque de la próxima corrida, y que bajarlos
sin procesarlos no los marque. El primero falla si se vuelve a poner Search.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:20:27 -05:00

209 lines
6.8 KiB
Go

package services
import (
"log"
"regexp"
"strconv"
"strings"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// 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
}
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 {
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,
}
if cfg != nil && cfg.AsignarA != nil {
ticket.AsignadoA = cfg.AsignarA
}
if err := models.CreateProyectoTicket(ticket); err != nil {
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]) + "…"
}