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.

add helper makefile for remote server and add handler of zero emails

+94 -1
+1
CHANGELOG.md
··· 2 2 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 + - **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 5 6 - **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 6 7 7 8 # 2026-04-17
+8 -1
internal/daemon/daemon.go
··· 63 63 ctx, cancel := context.WithCancel(ctx) 64 64 defer cancel() 65 65 66 - // Run initial screening immediately 66 + // Run initial screening immediately (if screener lists are not empty) 67 67 d.logger.Info("running initial screening") 68 68 if err := d.screenInbox(ctx); err != nil { 69 69 d.logger.Error("initial screening failed", "error", err) ··· 159 159 160 160 // screenInbox fetches inbox emails and screens them. 161 161 func (d *Daemon) screenInbox(ctx context.Context) error { 162 + // Skip screening if screener lists are empty (mirrors TUI behavior) 163 + // This prevents sweeping all unknown senders to ToScreen on first run 164 + if d.screener.IsEmpty() { 165 + d.logger.Info("screening paused: screener lists are empty (classify your first sender to activate)") 166 + return nil 167 + } 168 + 162 169 inboxFolder := d.cfg.Folders.Inbox 163 170 164 171 // Fetch inbox headers (0 means fetch all)
+66
internal/daemon/daemon_test.go
··· 69 69 // Note: Full integration test of Run() requires a real IMAP server 70 70 // This is tested manually and in integration tests 71 71 72 + func TestScreenInbox_EmptyScreenerLists(t *testing.T) { 73 + tmpDir := t.TempDir() 74 + listDir := filepath.Join(tmpDir, "lists") 75 + if err := os.MkdirAll(listDir, 0755); err != nil { 76 + t.Fatal(err) 77 + } 78 + 79 + // Create empty screener list files (first-run scenario) 80 + for _, name := range []string{"screened_in.txt", "screened_out.txt", "feed.txt", "papertrail.txt", "spam.txt"} { 81 + if err := os.WriteFile(filepath.Join(listDir, name), []byte{}, 0600); err != nil { 82 + t.Fatal(err) 83 + } 84 + } 85 + 86 + cfg := config.Config{ 87 + UI: config.UIConfig{ 88 + BgSyncInterval: 5, 89 + }, 90 + Folders: config.FoldersConfig{ 91 + Inbox: "INBOX", 92 + ToScreen: "ToScreen", 93 + ScreenedOut: "ScreenedOut", 94 + Feed: "Feed", 95 + PaperTrail: "PaperTrail", 96 + Spam: "Spam", 97 + Trash: "Trash", 98 + }, 99 + Screener: config.ScreenerConfig{ 100 + ScreenedIn: filepath.Join(listDir, "screened_in.txt"), 101 + ScreenedOut: filepath.Join(listDir, "screened_out.txt"), 102 + Feed: filepath.Join(listDir, "feed.txt"), 103 + PaperTrail: filepath.Join(listDir, "papertrail.txt"), 104 + Spam: filepath.Join(listDir, "spam.txt"), 105 + }, 106 + } 107 + 108 + sc, err := screener.New(screener.Config{ 109 + ScreenedIn: cfg.Screener.ScreenedIn, 110 + ScreenedOut: cfg.Screener.ScreenedOut, 111 + Feed: cfg.Screener.Feed, 112 + PaperTrail: cfg.Screener.PaperTrail, 113 + Spam: cfg.Screener.Spam, 114 + }) 115 + if err != nil { 116 + t.Fatal(err) 117 + } 118 + 119 + // Verify screener is empty 120 + if !sc.IsEmpty() { 121 + t.Fatal("expected screener to be empty") 122 + } 123 + 124 + d := New(cfg, &imap.Client{}, sc) 125 + 126 + // screenInbox should return early without attempting to fetch/move emails 127 + err = d.screenInbox(context.Background()) 128 + if err != nil { 129 + t.Fatalf("screenInbox failed with empty lists: %v", err) 130 + } 131 + 132 + // This test verifies that: 133 + // 1. No IMAP operations are attempted (would fail with nil client) 134 + // 2. The function returns nil (success/no-op) 135 + // 3. Mirrors TUI behavior of pausing screening when lists are empty 136 + } 137 + 72 138 func TestReloadScreener(t *testing.T) { 73 139 tmpDir := t.TempDir() 74 140 listDir := filepath.Join(tmpDir, "lists")
+19
scripts/headless-server/Makefile
··· 1 + .DEFAULT_GOAL := run-headless 2 + 3 + ## check-go: verify Go is installed 4 + runn-headless: 5 + nohup neomd --headless >> ~/.local/share/neomd/daemon.log 2>&1 & 6 + 7 + log: 8 + cat ~/.local/share/neomd/daemon.log 9 + 10 + restart: 11 + pkill neomd || true 12 + sleep 1 13 + nohup neomd --headless >> ~/.local/share/neomd/daemon.log 2>&1 & 14 + 15 + stop: 16 + pkill neomd || true 17 + 18 + status: 19 + ps aux | grep neomd | grep -v grep || echo "neomd is not running"