package mailingress import ( "context" "crypto/tls" "github.com/emersion/go-imap/backend/memory" "github.com/emersion/go-imap/server" "github.com/example/notify-gateway/internal/gateway" "github.com/example/notify-gateway/internal/outbox" "github.com/example/notify-gateway/internal/testutil" "io" "log" "path/filepath" "strings" "testing" "time" "github.com/example/notify-gateway/internal/config" ) func TestIMAPCheckpointsAndDurableAcceptance(t *testing.T) { ctx := context.Background() serverTLS, clientTLS := testutil.TLSConfigs() listener, err := tls.Listen("tcp", "127.0.0.1:0", serverTLS) if err != nil { t.Fatal(err) } backend := memory.New() srv := server.New(backend) srv.ErrorLog = log.New(io.Discard, "", 0) done := make(chan struct{}) go func() { defer close(done); srv.Serve(listener) }() defer func() { srv.Close(); <-done }() dir := t.TempDir() cfg, _ := config.Open(filepath.Join(dir, "config.json")) db, err := outbox.Open(filepath.Join(dir, "outbox.db")) if err != nil { t.Fatal(err) } defer db.Close() c := cfg.Get() c.Mappings = []config.Mapping{{ID: "mail", Enabled: true, Source: "mail", Target: "news", TextTemplate: "{{.Message}}"}} if err := cfg.Replace(c); err != nil { t.Fatal(err) } q := &gateway.Queue{Store: db, Dispatcher: gateway.New(cfg, nil)} p := &Poller{Config: cfg, Queue: q} m := config.MailIngress{ID: "mail", Address: listener.Addr().String(), Username: "username", Password: "password", Folder: "INBOX", Channel: "mail"} // Existing UID 6 is skipped on first setup; mailbox remains read-only. if err := p.poll(ctx, m, clientTLS); err != nil { t.Fatal(err) } rows, _ := db.List(ctx, 50, 0) if len(rows) != 0 { t.Fatal("old mail imported unexpectedly") } cpID := gateway.ScopedKey("imap", m.ID, m.Address, m.Username, m.Folder) old, _ := db.Checkpoint(ctx, cpID) user, _ := backend.Login(nil, "username", "password") mailbox, _ := user.GetMailbox("INBOX") appendMail := func(body string) { t.Helper() if err := mailbox.CreateMessage(nil, time.Now(), strings.NewReader("From: sender@example.org\r\nSubject: Test\r\nContent-Type: text/plain\r\n\r\n"+body)); err != nil { t.Fatal(err) } } appendMail("new mail") if err := p.poll(ctx, m, clientTLS); err != nil { t.Fatal(err) } rows, _ = db.List(ctx, 50, 0) if len(rows) != 1 { t.Fatalf("new mail jobs=%d", len(rows)) } // Simulate a crash after receipt commit but before checkpoint persistence. if err := db.SetCheckpoint(ctx, cpID, old); err != nil { t.Fatal(err) } if err := p.poll(ctx, m, clientTLS); err != nil { t.Fatal(err) } rows, _ = db.List(ctx, 50, 0) if len(rows) != 1 { t.Fatal("duplicate IMAP delivery") } saved, _ := db.Checkpoint(ctx, cpID) c.Mappings[0].Enabled = false cfg.Replace(c) appendMail("pending routing") if err := p.poll(ctx, m, clientTLS); err == nil { t.Fatal("missing mapping accepted") } after, _ := db.Checkpoint(ctx, cpID) if after != saved { t.Fatal("checkpoint advanced past unaccepted mail") } c.Mappings[0].Enabled = true cfg.Replace(c) if err := p.poll(ctx, m, clientTLS); err != nil { t.Fatal(err) } rows, _ = db.List(ctx, 50, 0) if len(rows) != 2 { t.Fatal("mail was lost") } if err := p.Poll(ctx, m); err == nil { t.Fatal("untrusted IMAP certificate accepted") } } func TestMIMEFiltersAndLoopProtection(t *testing.T) { raw := "From: Sender \r\nTo: ops@example.org\r\nSubject: =?UTF-8?Q?Gr=C3=BC=C3=9Fe?=\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=test\r\n\r\n--test\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\nSGFsbG8=\r\n--test\r\nContent-Type: application/octet-stream\r\nContent-Disposition: attachment; filename=test.txt\r\n\r\nnot forwarded\r\n--test--\r\n" cfg := config.MailIngress{Channel: "ops", From: []string{"sender@example.org"}, To: []string{"ops@example.org"}} msg, skip, err := Parse([]byte(raw), cfg) if err != nil || skip || msg.Title != "Grüße" || msg.Message != "Hallo" || msg.Source != "mail" { t.Fatalf("%+v %v %v", msg, skip, err) } if _, skip, err := Parse([]byte("X-Notify-Gateway: 1\r\n"+raw), cfg); err != nil || !skip { t.Fatalf("loop: %v %v", skip, err) } cfg.From = []string{"other@example.org"} if _, skip, err := Parse([]byte(raw), cfg); err != nil || !skip { t.Fatalf("sender filter: %v %v", skip, err) } if _, _, err := Parse([]byte(strings.Repeat("x", maxMailBytes+1)), cfg); err == nil { t.Fatal("oversize accepted") } if _, _, err := Parse([]byte("Content-Type: text/html\r\n\r\nhello"), config.MailIngress{}); err == nil { t.Fatal("HTML-only mail should require attention") } }