diff --git a/go.mod b/go.mod index 28e6567..57184e4 100755 --- a/go.mod +++ b/go.mod @@ -44,6 +44,7 @@ require ( github.com/chai2010/webp v1.4.0 github.com/chromedp/cdproto v0.0.0-20260719223732-95f6af754cfe github.com/chromedp/chromedp v0.16.0 + github.com/emersion/go-imap/v2 v2.0.0-beta.8 github.com/go-sql-driver/mysql v1.8.1 github.com/google/uuid v1.6.0 github.com/lib/pq v1.10.9 @@ -55,6 +56,7 @@ require ( github.com/robfig/cron/v3 v3.0.1 github.com/sirupsen/logrus v1.9.4 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e + github.com/valyala/fasthttp v1.56.0 go.mongodb.org/mongo-driver v1.17.9 golang.org/x/net v0.53.0 golang.org/x/oauth2 v0.23.0 @@ -75,7 +77,6 @@ require ( github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/emersion/go-imap/v2 v2.0.0-beta.8 // indirect github.com/emersion/go-message v0.18.2 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/emicklei/go-restful/v3 v3.12.1 // indirect @@ -137,7 +138,6 @@ require ( github.com/tinylib/msgp v1.6.1 // indirect github.com/tklauser/numcpus v0.8.0 // indirect github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92 // indirect - github.com/valyala/fasthttp v1.56.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect diff --git a/pkg/services/soporte_imap_flujo_test.go b/pkg/services/soporte_imap_flujo_test.go new file mode 100644 index 0000000..fb38142 --- /dev/null +++ b/pkg/services/soporte_imap_flujo_test.go @@ -0,0 +1,135 @@ +package services + +import ( + "io" + "net" + "strings" + "testing" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapclient" + "github.com/emersion/go-imap/v2/imapserver" + "github.com/emersion/go-imap/v2/imapserver/imapmemserver" +) + +// servidorDePrueba levanta un IMAP en memoria con un correo sin leer en INBOX. +func servidorDePrueba(t *testing.T, mensajes ...string) (*imapclient.Client, io.Closer) { + t.Helper() + mem := imapmemserver.New() + user := imapmemserver.NewUser("soporte", "secreta") + if err := user.Create("INBOX", nil); err != nil { + t.Fatal(err) + } + mem.AddUser(user) + + srv := imapserver.New(&imapserver.Options{ + NewSession: func(conn *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) { + return mem.NewSession(), nil, nil + }, + InsecureAuth: true, + }) + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + go srv.Serve(ln) + + c, err := imapclient.DialInsecure(ln.Addr().String(), nil) + if err != nil { + t.Fatal(err) + } + if err := c.Login("soporte", "secreta").Wait(); err != nil { + t.Fatal(err) + } + for _, m := range mensajes { + ac := c.Append("INBOX", int64(len(m)), nil) + if _, err := ac.Write([]byte(m)); err != nil { + t.Fatal(err) + } + if err := ac.Close(); err != nil { + t.Fatal(err) + } + if _, err := ac.Wait(); err != nil { + t.Fatal(err) + } + } + return c, ln +} + +const correoCrudo = "From: Juan \r\n" + + "Subject: No puedo entrar\r\n" + + "Message-Id: \r\n" + + "Content-Type: text/plain; charset=UTF-8\r\n" + + "\r\n" + + "La contrasena no me sirve.\r\n" + +// Este test existe por un bug concreto: la búsqueda usaba Search (números de +// secuencia) y después pedía UIDs, así que la lista salía siempre vacía y el +// buzón no se leía nunca, sin un solo error en el log. +func TestBajarNoLeidosDevuelveLosCorreos(t *testing.T) { + c, ln := servidorDePrueba(t, correoCrudo) + defer ln.Close() + defer c.Close() + + correos, err := BajarNoLeidos(c, "INBOX") + if err != nil { + t.Fatalf("BajarNoLeidos: %v", err) + } + if len(correos) != 1 { + t.Fatalf("esperaba 1 correo sin leer, hay %d", len(correos)) + } + if correos[0].UID == 0 { + t.Error("el UID vino en cero: sin él no se puede marcar como leído") + } + if got := correos[0].Correo.Subject; got != "No puedo entrar" { + t.Errorf("Subject = %q", got) + } + if !strings.Contains(correos[0].Correo.Texto, "contrasena no me sirve") { + t.Errorf("Texto = %q", correos[0].Correo.Texto) + } +} + +// Y este, por el otro lado del mismo problema: si marcar como leído no funciona, +// el mismo correo abre un ticket nuevo cada 2 minutos para siempre. +func TestMarcarLeidoSacaElCorreoDeLaProximaCorrida(t *testing.T) { + c, ln := servidorDePrueba(t, correoCrudo) + defer ln.Close() + defer c.Close() + + correos, err := BajarNoLeidos(c, "INBOX") + if err != nil || len(correos) != 1 { + t.Fatalf("BajarNoLeidos: %v (%d correos)", err, len(correos)) + } + if err := MarcarLeido(c, correos[0].UID); err != nil { + t.Fatalf("MarcarLeido: %v", err) + } + + otra, err := BajarNoLeidos(c, "INBOX") + if err != nil { + t.Fatalf("segunda pasada: %v", err) + } + if len(otra) != 0 { + t.Errorf("el correo ya leído volvió a aparecer (%d)", len(otra)) + } +} + +// Peek: bajarlo sin procesarlo no lo debe marcar, o un fallo a mitad de camino +// perdería el correo para siempre. +func TestBajarNoLeidosNoMarcaSolo(t *testing.T) { + c, ln := servidorDePrueba(t, correoCrudo) + defer ln.Close() + defer c.Close() + + if _, err := BajarNoLeidos(c, "INBOX"); err != nil { + t.Fatal(err) + } + otra, err := BajarNoLeidos(c, "INBOX") + if err != nil { + t.Fatal(err) + } + if len(otra) != 1 { + t.Errorf("el correo debería seguir sin leer, hay %d", len(otra)) + } +} + +var _ = imap.FlagSeen diff --git a/pkg/services/soporte_imap_service.go b/pkg/services/soporte_imap_service.go index b2948b4..91c2226 100644 --- a/pkg/services/soporte_imap_service.go +++ b/pkg/services/soporte_imap_service.go @@ -36,33 +36,60 @@ func RevisarBuzonSoporte() { } defer imapEnCurso.Unlock() - n, err := revisarBuzon(cfg) + encontrados, procesados, err := revisarBuzon(cfg) if err != nil { log.Printf("[SoporteIMAP] %v", err) return } - if n > 0 { - log.Printf("[SoporteIMAP] %d correo(s) procesado(s)", n) + if encontrados > 0 { + log.Printf("[SoporteIMAP] %d sin leer, %d procesado(s)", encontrados, procesados) } } +// RevisarBuzonSoporteConDetalle es la versión que usa el botón "Revisar buzón +// ahora": devuelve los números para poder mostrarlos, en vez de solo loguearlos. +func RevisarBuzonSoporteConDetalle() (encontrados, procesados int, err error) { + cfg, err := models.GetSoporteWebhookActivo() + if err != nil || cfg == nil { + return 0, 0, fmt.Errorf("no hay configuración de soporte guardada") + } + if !cfg.ImapActivo { + return 0, 0, fmt.Errorf("la lectura del buzón está desactivada: tildá \"Leer el buzón cada 2 minutos\" y guardá") + } + if cfg.ImapHost == "" { + return 0, 0, fmt.Errorf("falta el servidor IMAP en la configuración") + } + if !imapEnCurso.TryLock() { + return 0, 0, fmt.Errorf("hay una revisión en curso, probá en unos segundos") + } + defer imapEnCurso.Unlock() + return revisarBuzon(cfg) +} + // ProbarConexionImap valida credenciales sin procesar nada — lo usa el botón // "Probar" de la vista de configuración. -func ProbarConexionImap(cfg *models.SoporteWebhookConfig) error { +func ProbarConexionImap(cfg *models.SoporteWebhookConfig) (string, error) { c, err := conectarImap(cfg) if err != nil { - return err + return "", err } defer c.Close() carpeta := cfg.ImapCarpeta if carpeta == "" { carpeta = "INBOX" } - if _, err := c.Select(carpeta, &imap.SelectOptions{ReadOnly: true}).Wait(); err != nil { - return fmt.Errorf("no se pudo abrir la carpeta %q: %w", carpeta, err) + datos, err := c.Select(carpeta, &imap.SelectOptions{ReadOnly: true}).Wait() + if err != nil { + return "", fmt.Errorf("no se pudo abrir la carpeta %q: %w", carpeta, err) + } + sinLeer := 0 + if r, err := c.UIDSearch(&imap.SearchCriteria{ + NotFlag: []imap.Flag{imap.FlagSeen}, + }, &imap.SearchOptions{ReturnAll: true}).Wait(); err == nil { + sinLeer = len(r.AllUIDs()) } _ = c.Logout().Wait() - return nil + return fmt.Sprintf("Conexión correcta. %s: %d mensajes, %d sin leer.", carpeta, datos.NumMessages, sinLeer), nil } func conectarImap(cfg *models.SoporteWebhookConfig) (*imapclient.Client, error) { @@ -92,30 +119,63 @@ func conectarImap(cfg *models.SoporteWebhookConfig) (*imapclient.Client, error) return c, nil } -func revisarBuzon(cfg *models.SoporteWebhookConfig) (int, error) { +func revisarBuzon(cfg *models.SoporteWebhookConfig) (int, int, error) { c, err := conectarImap(cfg) if err != nil { - return 0, err + return 0, 0, err } defer c.Close() - carpeta := cfg.ImapCarpeta + correos, err := BajarNoLeidos(c, cfg.ImapCarpeta) + if err != nil { + return 0, 0, err + } + if len(correos) == 0 { + return 0, 0, nil + } + + procesados := 0 + for _, correo := range correos { + if IngestarCorreoSoporte(cfg, correo.Correo) { + procesados++ + } + // Se marca leído aunque se haya ignorado por duplicado: si no, se + // vuelve a bajar en cada corrida para siempre. + if err := MarcarLeido(c, correo.UID); err != nil { + log.Printf("[SoporteIMAP] no se pudo marcar leído el uid=%v: %v", correo.UID, err) + } + } + return len(correos), procesados, nil +} + +// CorreoConUID es un correo del buzón junto con su UID, que es lo que hace +// falta después para marcarlo como leído. +type CorreoConUID struct { + UID imap.UID + Correo CorreoSoporte +} + +// BajarNoLeidos abre la carpeta, busca los mensajes sin leer y los devuelve ya +// parseados. No los marca: eso pasa recién cuando se procesaron. +func BajarNoLeidos(c *imapclient.Client, carpeta string) ([]CorreoConUID, error) { if carpeta == "" { carpeta = "INBOX" } if _, err := c.Select(carpeta, nil).Wait(); err != nil { - return 0, fmt.Errorf("no se pudo abrir la carpeta %q: %w", carpeta, err) + return nil, fmt.Errorf("no se pudo abrir la carpeta %q: %w", carpeta, err) } - buscados, err := c.Search(&imap.SearchCriteria{ + // UIDSearch y no Search: Search devuelve números de secuencia, y de ahí no + // salen UIDs — la búsqueda "encontraba" mensajes y la lista quedaba vacía. + buscados, err := c.UIDSearch(&imap.SearchCriteria{ NotFlag: []imap.Flag{imap.FlagSeen}, }, &imap.SearchOptions{ReturnAll: true}).Wait() if err != nil { - return 0, fmt.Errorf("búsqueda de no leídos falló: %w", err) + return nil, fmt.Errorf("búsqueda de no leídos falló: %w", err) } uids := buscados.AllUIDs() if len(uids) == 0 { - return 0, nil + return nil, nil } // ponytail: tope por corrida para no atragantarse con un buzón que nunca // se leyó. Los que sobran quedan sin leer y entran en la corrida siguiente. @@ -124,14 +184,15 @@ func revisarBuzon(cfg *models.SoporteWebhookConfig) (int, error) { uids = uids[:maxPorCorrida] } + // Peek para que el correo quede leído solo si llegamos a procesarlo. msgs, err := c.Fetch(imap.UIDSetNum(uids...), &imap.FetchOptions{ - BodySection: []*imap.FetchItemBodySection{{}}, + BodySection: []*imap.FetchItemBodySection{{Peek: true}}, }).Collect() if err != nil { - return 0, fmt.Errorf("descarga de correos falló: %w", err) + return nil, fmt.Errorf("descarga de correos falló: %w", err) } - procesados := 0 + var out []CorreoConUID for _, m := range msgs { var crudo []byte for _, b := range m.BodySection { @@ -146,20 +207,18 @@ func revisarBuzon(cfg *models.SoporteWebhookConfig) (int, error) { log.Printf("[SoporteIMAP] no se pudo leer un correo (uid=%v): %v", m.UID, err) continue } - if IngestarCorreoSoporte(cfg, correo) { - procesados++ - } - // Se marca leído aunque se haya ignorado por duplicado: si no, se - // vuelve a bajar en cada corrida para siempre. - if err := c.Store(imap.UIDSetNum(m.UID), &imap.StoreFlags{ - Op: imap.StoreFlagsAdd, - Silent: true, - Flags: []imap.Flag{imap.FlagSeen}, - }, nil).Close(); err != nil { - log.Printf("[SoporteIMAP] no se pudo marcar leído el uid=%v: %v", m.UID, err) - } + out = append(out, CorreoConUID{UID: m.UID, Correo: correo}) } - return procesados, nil + return out, nil +} + +// MarcarLeido pone el flag \Seen, que es lo que evita releer el mismo correo. +func MarcarLeido(c *imapclient.Client, uid imap.UID) error { + return c.Store(imap.UIDSetNum(uid), &imap.StoreFlags{ + Op: imap.StoreFlagsAdd, + Silent: true, + Flags: []imap.Flag{imap.FlagSeen}, + }, nil).Close() } // parsearCorreo saca remitente, asunto y cuerpo de texto de un mensaje RFC822. diff --git a/pkg/services/soporte_ingesta_service.go b/pkg/services/soporte_ingesta_service.go index 0941f39..9e23df1 100644 --- a/pkg/services/soporte_ingesta_service.go +++ b/pkg/services/soporte_ingesta_service.go @@ -26,9 +26,15 @@ var ticketRefRe = regexp.MustCompile(`(?i)\[Ticket #(\d+)\]`) // 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 e.From == "" || e.Subject == "" { + 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. diff --git a/rest/controllers/soporte_controller.go b/rest/controllers/soporte_controller.go index 208f209..2c799d2 100644 --- a/rest/controllers/soporte_controller.go +++ b/rest/controllers/soporte_controller.go @@ -2,6 +2,7 @@ package controllers import ( "crypto/subtle" + "fmt" "log" "regexp" "strconv" @@ -305,15 +306,23 @@ func ProbarImapSoporte(c *fiber.Ctx) error { if err != nil || cfg == nil { return c.Status(400).JSON(fiber.Map{"error": "Guardá la configuración antes de probar"}) } - if err := services.ProbarConexionImap(cfg); err != nil { + detalle, err := services.ProbarConexionImap(cfg) + if err != nil { return c.Status(400).JSON(fiber.Map{"error": err.Error()}) } - return c.JSON(fiber.Map{"ok": true, "message": "Conexión IMAP correcta"}) + return c.JSON(fiber.Map{"ok": true, "message": detalle}) } // RevisarBuzonAhora dispara una lectura del buzón sin esperar al cron. // POST /app/api/soporte-webhook/revisar-buzon func RevisarBuzonAhora(c *fiber.Ctx) error { - services.RevisarBuzonSoporte() - return c.JSON(fiber.Map{"ok": true, "message": "Buzón revisado, mirá la lista de tickets"}) + encontrados, procesados, err := services.RevisarBuzonSoporteConDetalle() + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + if encontrados == 0 { + return c.JSON(fiber.Map{"ok": true, "message": "Conectó bien, pero no hay correos sin leer en el buzón"}) + } + return c.JSON(fiber.Map{"ok": true, "message": fmt.Sprintf( + "%d correo(s) sin leer, %d convertido(s) en ticket. El resto ya estaba registrado.", encontrados, procesados)}) }