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>
This commit is contained in:
Lizandro Guarnizo
2026-08-17 19:20:27 -05:00
co-authored by Claude Opus 5
parent d1bbb4a508
commit 5875f9e36b
5 changed files with 247 additions and 38 deletions
+2 -2
View File
@@ -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
+135
View File
@@ -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 <juan@cliente.com>\r\n" +
"Subject: No puedo entrar\r\n" +
"Message-Id: <uno@cliente.com>\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
+90 -31
View File
@@ -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.
+7 -1
View File
@@ -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.
+13 -4
View File
@@ -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)})
}