feat(soporte): leer solo los correos recientes, no todo el buzón
Sin ventana, la primera corrida convierte en tickets todo lo que haya sin leer, que en una casilla de años es una avalancha. Ahora hay un desplegable de antigüedad máxima —6h, 12h, 24h, 3 días o todo— y por defecto 12 horas. El corte se hace en dos pasos porque IMAP no da para más: al servidor se le pide SINCE con un día de margen (SINCE compara solo la fecha, no la hora) y el corte fino por hora se aplica contra la fecha real de cada mensaje. Afinar el SINCE sería perder los correos del borde. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d7c266f111
commit
6afad025c2
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user