diff --git a/pkg/models/soporte_webhook.go b/pkg/models/soporte_webhook.go index ba7b0b9..6c960e5 100644 --- a/pkg/models/soporte_webhook.go +++ b/pkg/models/soporte_webhook.go @@ -35,6 +35,10 @@ type SoporteWebhookConfig struct { ImapPasswordEnc string `json:"-" gorm:"column:imap_password_enc;type:text"` // AES-GCM con APP_KEY ImapEncryption string `json:"imap_encryption" gorm:"column:imap_encryption;size:20;default:'ssl'"` // ssl|starttls ImapCarpeta string `json:"imap_carpeta" gorm:"column:imap_carpeta;size:100;default:'INBOX'"` + // Ventana hacia atrás, en horas: solo se leen los correos recibidos dentro + // de ella. 0 = sin límite (todo el buzón sin leer). Es lo que evita que la + // primera corrida se coma años de correo viejo. + ImapHorasAtras int `json:"imap_horas_atras" gorm:"column:imap_horas_atras;default:12"` // Filtro con IA: no todo lo que llega al buzón es soporte (newsletters, // notificaciones de bancos, facturas de proveedores). Si está prendido, se diff --git a/pkg/services/soporte_imap_flujo_test.go b/pkg/services/soporte_imap_flujo_test.go index fb38142..ac15590 100644 --- a/pkg/services/soporte_imap_flujo_test.go +++ b/pkg/services/soporte_imap_flujo_test.go @@ -5,6 +5,7 @@ import ( "net" "strings" "testing" + "time" "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/imapclient" @@ -71,7 +72,7 @@ func TestBajarNoLeidosDevuelveLosCorreos(t *testing.T) { defer ln.Close() defer c.Close() - correos, err := BajarNoLeidos(c, "INBOX") + correos, err := BajarNoLeidos(c, "INBOX", 0) if err != nil { t.Fatalf("BajarNoLeidos: %v", err) } @@ -96,7 +97,7 @@ func TestMarcarLeidoSacaElCorreoDeLaProximaCorrida(t *testing.T) { defer ln.Close() defer c.Close() - correos, err := BajarNoLeidos(c, "INBOX") + correos, err := BajarNoLeidos(c, "INBOX", 0) if err != nil || len(correos) != 1 { t.Fatalf("BajarNoLeidos: %v (%d correos)", err, len(correos)) } @@ -104,7 +105,7 @@ func TestMarcarLeidoSacaElCorreoDeLaProximaCorrida(t *testing.T) { t.Fatalf("MarcarLeido: %v", err) } - otra, err := BajarNoLeidos(c, "INBOX") + otra, err := BajarNoLeidos(c, "INBOX", 0) if err != nil { t.Fatalf("segunda pasada: %v", err) } @@ -120,10 +121,10 @@ func TestBajarNoLeidosNoMarcaSolo(t *testing.T) { defer ln.Close() defer c.Close() - if _, err := BajarNoLeidos(c, "INBOX"); err != nil { + if _, err := BajarNoLeidos(c, "INBOX", 0); err != nil { t.Fatal(err) } - otra, err := BajarNoLeidos(c, "INBOX") + otra, err := BajarNoLeidos(c, "INBOX", 0) if err != nil { t.Fatal(err) } @@ -133,3 +134,33 @@ func TestBajarNoLeidosNoMarcaSolo(t *testing.T) { } var _ = imap.FlagSeen + +func TestVentanaDeAntiguedad(t *testing.T) { + ahora := time.Now() + casos := []struct { + nombre string + recibido time.Time + horas int + want bool + }{ + {"de hace una hora, ventana 12", ahora.Add(-1 * time.Hour), 12, true}, + {"de hace 13 horas, ventana 12", ahora.Add(-13 * time.Hour), 12, false}, + {"viejísimo, sin ventana", ahora.AddDate(-2, 0, 0), 0, true}, + {"sin fecha del servidor", time.Time{}, 12, true}, + } + for _, c := range casos { + if got := dentroDeLaVentana(c.recibido, c.horas); got != c.want { + t.Errorf("%s: dentroDeLaVentana = %v, want %v", c.nombre, got, c.want) + } + } + + // El SINCE que se le pide al servidor tiene que ser MÁS amplio que la + // ventana real: IMAP compara solo la fecha, y afinar de más pierde correos. + c := criterioNoLeidos(12) + if !c.Since.Before(ahora.Add(-12 * time.Hour)) { + t.Errorf("Since = %v, debería ser anterior al corte real", c.Since) + } + if !criterioNoLeidos(0).Since.IsZero() { + t.Error("sin ventana no debería mandarse SINCE") + } +} diff --git a/pkg/services/soporte_imap_service.go b/pkg/services/soporte_imap_service.go index 092c28d..a7ead75 100644 --- a/pkg/services/soporte_imap_service.go +++ b/pkg/services/soporte_imap_service.go @@ -11,6 +11,7 @@ import ( "net/mail" "strings" "sync" + "time" "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/imapclient" @@ -83,13 +84,17 @@ func ProbarConexionImap(cfg *models.SoporteWebhookConfig) (string, error) { 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 { + if r, err := c.UIDSearch(criterioNoLeidos(cfg.ImapHorasAtras), &imap.SearchOptions{ReturnAll: true}).Wait(); err == nil { sinLeer = len(r.AllUIDs()) } _ = c.Logout().Wait() - return fmt.Sprintf("Conexión correcta. %s: %d mensajes, %d sin leer.", carpeta, datos.NumMessages, sinLeer), nil + // El conteo del servidor es por día; el corte por hora se aplica al leer, + // así que este número puede ser un poco mayor que el que se va a procesar. + ventana := "sin leer" + if cfg.ImapHorasAtras > 0 { + ventana = fmt.Sprintf("sin leer de las últimas ~%d horas", cfg.ImapHorasAtras) + } + return fmt.Sprintf("Conexión correcta. %s: %d mensajes, %d %s.", carpeta, datos.NumMessages, sinLeer, ventana), nil } func conectarImap(cfg *models.SoporteWebhookConfig) (*imapclient.Client, error) { @@ -126,7 +131,7 @@ func revisarBuzon(cfg *models.SoporteWebhookConfig) (int, int, error) { } defer c.Close() - correos, err := BajarNoLeidos(c, cfg.ImapCarpeta) + correos, err := BajarNoLeidos(c, cfg.ImapCarpeta, cfg.ImapHorasAtras) if err != nil { return 0, 0, err } @@ -155,6 +160,29 @@ func revisarBuzon(cfg *models.SoporteWebhookConfig) (int, int, error) { return len(correos), procesados, nil } +// criterioNoLeidos arma la búsqueda: no leídos y, si hay ventana configurada, +// recibidos dentro de ella. +// +// SINCE de IMAP compara solo la fecha, no la hora, así que acá se pide un día +// de más y el corte fino por hora se hace después contra la fecha real de cada +// mensaje (ver dentroDeLaVentana). Pedirle al servidor que filtre de más sería +// perder correos del borde. +func criterioNoLeidos(horasAtras int) *imap.SearchCriteria { + c := &imap.SearchCriteria{NotFlag: []imap.Flag{imap.FlagSeen}} + if horasAtras > 0 { + c.Since = time.Now().Add(-time.Duration(horasAtras)*time.Hour).AddDate(0, 0, -1) + } + return c +} + +// dentroDeLaVentana es el corte por hora que IMAP no puede hacer. +func dentroDeLaVentana(recibido time.Time, horasAtras int) bool { + if horasAtras <= 0 || recibido.IsZero() { + return true + } + return recibido.After(time.Now().Add(-time.Duration(horasAtras) * time.Hour)) +} + // 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 { @@ -164,7 +192,7 @@ type CorreoConUID struct { // 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) { +func BajarNoLeidos(c *imapclient.Client, carpeta string, horasAtras int) ([]CorreoConUID, error) { if carpeta == "" { carpeta = "INBOX" } @@ -174,9 +202,7 @@ func BajarNoLeidos(c *imapclient.Client, carpeta string) ([]CorreoConUID, error) // 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() + buscados, err := c.UIDSearch(criterioNoLeidos(horasAtras), &imap.SearchOptions{ReturnAll: true}).Wait() if err != nil { return nil, fmt.Errorf("búsqueda de no leídos falló: %w", err) } @@ -193,7 +219,8 @@ func BajarNoLeidos(c *imapclient.Client, carpeta string) ([]CorreoConUID, error) // 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{{Peek: true}}, + InternalDate: true, + BodySection: []*imap.FetchItemBodySection{{Peek: true}}, }).Collect() if err != nil { return nil, fmt.Errorf("descarga de correos falló: %w", err) @@ -201,6 +228,9 @@ func BajarNoLeidos(c *imapclient.Client, carpeta string) ([]CorreoConUID, error) var out []CorreoConUID for _, m := range msgs { + if !dentroDeLaVentana(m.InternalDate, horasAtras) { + continue + } var crudo []byte for _, b := range m.BodySection { crudo = b.Bytes diff --git a/resources/views/soporte_webhook.html b/resources/views/soporte_webhook.html index cee7399..506d1db 100644 --- a/resources/views/soporte_webhook.html +++ b/resources/views/soporte_webhook.html @@ -165,6 +165,21 @@ +
+ + +

+ Los correos más viejos que esto no se tocan, aunque estén sin leer. Es lo que evita que la + primera corrida convierta años de buzón en tickets. +

+
+