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>
167 lines
4.6 KiB
Go
167 lines
4.6 KiB
Go
package services
|
|
|
|
import (
|
|
"io"
|
|
"net"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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", 0)
|
|
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", 0)
|
|
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", 0)
|
|
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", 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
otra, err := BajarNoLeidos(c, "INBOX", 0)
|
|
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
|
|
|
|
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")
|
|
}
|
|
}
|