A minimal email TUI where you read with Markdown and write in Neovim. neomd.ssp.sh/docs
email markdown neovim tui
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

securely remove and add from screener lists

+187 -5
+1
CHANGELOG.md
··· 3 3 # 2026-04-18 4 4 - **Headless daemon mode (`--headless`)** — run neomd as a background daemon on a server (NAS, VPS, always-on device) to continuously screen emails without launching the TUI; daemon fetches inbox and auto-screens every `bg_sync_interval` minutes (configurable in config); watches screener list files (`~/.config/neomd/lists/*.txt`) and reloads when they change (designed for Syncthing multi-device sync); graceful shutdown on SIGTERM/SIGINT; structured logging to stdout with `log/slog`; use case: run daemon on NAS with `bg_sync_interval = 5`, disable background sync on laptop/Android with `bg_sync_interval = 0`, classify senders in TUI, Syncthing syncs lists to NAS, daemon auto-screens incoming emails so mobile IMAP apps see correctly filtered folders; perfect for using neomd's screener with native mobile email clients; daemon only reads screener lists and moves emails (never modifies lists), all sender classification happens in TUI; includes comprehensive tests for classification logic and daemon lifecycle; documented in `docs/configurations/headless.md` with Syncthing setup guide, systemd service example, and multi-device workflow; `make daemon` target for testing 5 5 - **Fix: headless screening paused when lists empty** — daemon now checks `screener.IsEmpty()` and skips screening when all lists are empty, mirroring TUI behavior; prevents sweeping entire inbox to ToScreen on first run or before Syncthing completes initial sync; logs "screening paused: screener lists are empty (classify your first sender to activate)" until first classification exists; regression test added 6 + - **Fix: cross-list cleanup on reclassification** — all 5 screener classification functions (`Approve`, `Block`, `MarkSpam`, `MarkFeed`, `MarkPaperTrail`) now remove email addresses from ALL conflicting lists before adding to the target list; fixes bug where reclassifying a sender (e.g., Feed → ScreenedOut) would leave the address in both `feed.txt` and `screened_out.txt`, causing duplicates in screener state and sync conflicts across devices; previously only `Approve`, `Block`, and `MarkSpam` had partial cleanup logic, while `MarkFeed` and `MarkPaperTrail` only appended without removing; comprehensive regression test added covering Feed→ScreenedOut, PaperTrail→Feed, full reclassification chain (Inbox→Feed→PaperTrail→ScreenedOut→Spam→Inbox), and persistence after reload; all cleanup operations verified both in-memory and on-disk 6 7 - **Cross-compile FreeBSD binary on build** — `make build` now automatically creates `neomd-freebsd` static binary alongside Linux binary; FreeBSD binary can be copied to FreeBSD/OpenBSD servers without needing Go installed; `make sync-headless` target copies to remote server via scp 7 8 8 9 # 2026-04-17
+19 -5
internal/screener/screener.go
··· 145 145 return s.Classify(from), addr 146 146 } 147 147 148 - // Approve adds addr to screened_in.txt and removes it from screened_out/spam. 148 + // Approve adds addr to screened_in.txt and removes it from all conflicting lists. 149 149 func (s *Screener) Approve(from string) error { 150 150 _ = s.removeFromList(s.cfg.ScreenedOut, s.screenedOut, from) 151 + _ = s.removeFromList(s.cfg.Feed, s.feed, from) 152 + _ = s.removeFromList(s.cfg.PaperTrail, s.paperTrail, from) 151 153 _ = s.removeFromList(s.cfg.Spam, s.spam, from) 152 154 return s.addToList(s.cfg.ScreenedIn, s.screenedIn, from) 153 155 } 154 156 155 - // Block adds addr to screened_out.txt and removes it from screened_in/spam. 157 + // Block adds addr to screened_out.txt and removes it from all conflicting lists. 156 158 func (s *Screener) Block(from string) error { 157 159 _ = s.removeFromList(s.cfg.ScreenedIn, s.screenedIn, from) 160 + _ = s.removeFromList(s.cfg.Feed, s.feed, from) 161 + _ = s.removeFromList(s.cfg.PaperTrail, s.paperTrail, from) 158 162 _ = s.removeFromList(s.cfg.Spam, s.spam, from) 159 163 return s.addToList(s.cfg.ScreenedOut, s.screenedOut, from) 160 164 } 161 165 162 - // MarkSpam adds addr to spam.txt and removes it from screened_in/screened_out. 166 + // MarkSpam adds addr to spam.txt and removes it from all conflicting lists. 163 167 func (s *Screener) MarkSpam(from string) error { 164 168 _ = s.removeFromList(s.cfg.ScreenedIn, s.screenedIn, from) 165 169 _ = s.removeFromList(s.cfg.ScreenedOut, s.screenedOut, from) 170 + _ = s.removeFromList(s.cfg.Feed, s.feed, from) 171 + _ = s.removeFromList(s.cfg.PaperTrail, s.paperTrail, from) 166 172 return s.addToList(s.cfg.Spam, s.spam, from) 167 173 } 168 174 169 - // MarkFeed adds addr to feed.txt and updates the in-memory set. 175 + // MarkFeed adds addr to feed.txt and removes it from all conflicting lists. 170 176 func (s *Screener) MarkFeed(from string) error { 177 + _ = s.removeFromList(s.cfg.ScreenedIn, s.screenedIn, from) 178 + _ = s.removeFromList(s.cfg.ScreenedOut, s.screenedOut, from) 179 + _ = s.removeFromList(s.cfg.PaperTrail, s.paperTrail, from) 180 + _ = s.removeFromList(s.cfg.Spam, s.spam, from) 171 181 return s.addToList(s.cfg.Feed, s.feed, from) 172 182 } 173 183 174 - // MarkPaperTrail adds addr to papertrail.txt and updates the in-memory set. 184 + // MarkPaperTrail adds addr to papertrail.txt and removes it from all conflicting lists. 175 185 func (s *Screener) MarkPaperTrail(from string) error { 186 + _ = s.removeFromList(s.cfg.ScreenedIn, s.screenedIn, from) 187 + _ = s.removeFromList(s.cfg.ScreenedOut, s.screenedOut, from) 188 + _ = s.removeFromList(s.cfg.Feed, s.feed, from) 189 + _ = s.removeFromList(s.cfg.Spam, s.spam, from) 176 190 return s.addToList(s.cfg.PaperTrail, s.paperTrail, from) 177 191 } 178 192
+167
internal/screener/screener_test.go
··· 4 4 "os" 5 5 "path/filepath" 6 6 "sort" 7 + "strings" 7 8 "testing" 8 9 ) 9 10 ··· 424 425 } 425 426 }) 426 427 } 428 + 429 + // --------------------------------------------------------------------------- 430 + // Cross-list cleanup regression test 431 + // --------------------------------------------------------------------------- 432 + 433 + func TestCrossListCleanup_Reclassification(t *testing.T) { 434 + makeCfg := func(dir string) Config { 435 + return Config{ 436 + ScreenedIn: filepath.Join(dir, "screened_in.txt"), 437 + ScreenedOut: filepath.Join(dir, "screened_out.txt"), 438 + Feed: filepath.Join(dir, "feed.txt"), 439 + PaperTrail: filepath.Join(dir, "papertrail.txt"), 440 + Spam: filepath.Join(dir, "spam.txt"), 441 + } 442 + } 443 + 444 + verifyOnlyInList := func(t *testing.T, s *Screener, cfg Config, addr string, expectedCat Category) { 445 + t.Helper() 446 + // Verify in-memory classification 447 + if got := s.Classify(addr); got != expectedCat { 448 + t.Errorf("Classify(%q) = %v, want %v", addr, got, expectedCat) 449 + } 450 + 451 + // Verify on-disk: email should exist in ONLY the expected file 452 + files := map[Category]string{ 453 + CategoryInbox: cfg.ScreenedIn, 454 + CategoryScreenedOut: cfg.ScreenedOut, 455 + CategoryFeed: cfg.Feed, 456 + CategoryPaperTrail: cfg.PaperTrail, 457 + CategorySpam: cfg.Spam, 458 + } 459 + 460 + for cat, path := range files { 461 + data, err := os.ReadFile(path) 462 + if err != nil && !os.IsNotExist(err) { 463 + t.Fatalf("ReadFile(%s): %v", path, err) 464 + } 465 + contains := false 466 + if len(data) > 0 { 467 + for _, line := range strings.Split(string(data), "\n") { 468 + if normalise(line) == normalise(addr) { 469 + contains = true 470 + break 471 + } 472 + } 473 + } 474 + 475 + if cat == expectedCat { 476 + if !contains { 477 + t.Errorf("%s should contain %q but doesn't; file contents: %q", path, addr, data) 478 + } 479 + } else { 480 + if contains { 481 + t.Errorf("%s should NOT contain %q but does; file contents: %q", path, addr, data) 482 + } 483 + } 484 + } 485 + } 486 + 487 + t.Run("Feed to ScreenedOut removes from feed.txt", func(t *testing.T) { 488 + dir := t.TempDir() 489 + cfg := makeCfg(dir) 490 + addr := "reclassify@example.com" 491 + 492 + s, err := New(cfg) 493 + if err != nil { 494 + t.Fatal(err) 495 + } 496 + 497 + // Start: mark as Feed 498 + if err := s.MarkFeed(addr); err != nil { 499 + t.Fatalf("MarkFeed: %v", err) 500 + } 501 + verifyOnlyInList(t, s, cfg, addr, CategoryFeed) 502 + 503 + // Reclassify: mark as ScreenedOut 504 + if err := s.Block(addr); err != nil { 505 + t.Fatalf("Block: %v", err) 506 + } 507 + verifyOnlyInList(t, s, cfg, addr, CategoryScreenedOut) 508 + }) 509 + 510 + t.Run("PaperTrail to Feed removes from papertrail.txt", func(t *testing.T) { 511 + dir := t.TempDir() 512 + cfg := makeCfg(dir) 513 + addr := "newsletter@example.com" 514 + 515 + s, err := New(cfg) 516 + if err != nil { 517 + t.Fatal(err) 518 + } 519 + 520 + // Start: mark as PaperTrail 521 + if err := s.MarkPaperTrail(addr); err != nil { 522 + t.Fatalf("MarkPaperTrail: %v", err) 523 + } 524 + verifyOnlyInList(t, s, cfg, addr, CategoryPaperTrail) 525 + 526 + // Reclassify: mark as Feed 527 + if err := s.MarkFeed(addr); err != nil { 528 + t.Fatalf("MarkFeed: %v", err) 529 + } 530 + verifyOnlyInList(t, s, cfg, addr, CategoryFeed) 531 + }) 532 + 533 + t.Run("Full reclassification chain", func(t *testing.T) { 534 + dir := t.TempDir() 535 + cfg := makeCfg(dir) 536 + addr := "chain@example.com" 537 + 538 + s, err := New(cfg) 539 + if err != nil { 540 + t.Fatal(err) 541 + } 542 + 543 + // Chain: Inbox → Feed → PaperTrail → ScreenedOut → Spam → Inbox 544 + steps := []struct { 545 + fn func(string) error 546 + cat Category 547 + }{ 548 + {s.Approve, CategoryInbox}, 549 + {s.MarkFeed, CategoryFeed}, 550 + {s.MarkPaperTrail, CategoryPaperTrail}, 551 + {s.Block, CategoryScreenedOut}, 552 + {s.MarkSpam, CategorySpam}, 553 + {s.Approve, CategoryInbox}, 554 + } 555 + 556 + for i, step := range steps { 557 + if err := step.fn(addr); err != nil { 558 + t.Fatalf("step %d: %v", i, err) 559 + } 560 + verifyOnlyInList(t, s, cfg, addr, step.cat) 561 + } 562 + }) 563 + 564 + t.Run("Reclassification persists after reload", func(t *testing.T) { 565 + dir := t.TempDir() 566 + cfg := makeCfg(dir) 567 + addr := "persist@example.com" 568 + 569 + s, err := New(cfg) 570 + if err != nil { 571 + t.Fatal(err) 572 + } 573 + 574 + // Start in Feed 575 + if err := s.MarkFeed(addr); err != nil { 576 + t.Fatal(err) 577 + } 578 + 579 + // Reclassify to ScreenedOut 580 + if err := s.Block(addr); err != nil { 581 + t.Fatal(err) 582 + } 583 + 584 + // Reload from disk 585 + s2, err := New(cfg) 586 + if err != nil { 587 + t.Fatal(err) 588 + } 589 + 590 + // Verify cleanup persisted 591 + verifyOnlyInList(t, s2, cfg, addr, CategoryScreenedOut) 592 + }) 593 + }