···2233# 2026-04-18
44- **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
55+- **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
56- **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
6778# 2026-04-17
+8-1
internal/daemon/daemon.go
···6363 ctx, cancel := context.WithCancel(ctx)
6464 defer cancel()
65656666- // Run initial screening immediately
6666+ // Run initial screening immediately (if screener lists are not empty)
6767 d.logger.Info("running initial screening")
6868 if err := d.screenInbox(ctx); err != nil {
6969 d.logger.Error("initial screening failed", "error", err)
···159159160160// screenInbox fetches inbox emails and screens them.
161161func (d *Daemon) screenInbox(ctx context.Context) error {
162162+ // Skip screening if screener lists are empty (mirrors TUI behavior)
163163+ // This prevents sweeping all unknown senders to ToScreen on first run
164164+ if d.screener.IsEmpty() {
165165+ d.logger.Info("screening paused: screener lists are empty (classify your first sender to activate)")
166166+ return nil
167167+ }
168168+162169 inboxFolder := d.cfg.Folders.Inbox
163170164171 // Fetch inbox headers (0 means fetch all)
+66
internal/daemon/daemon_test.go
···6969// Note: Full integration test of Run() requires a real IMAP server
7070// This is tested manually and in integration tests
71717272+func TestScreenInbox_EmptyScreenerLists(t *testing.T) {
7373+ tmpDir := t.TempDir()
7474+ listDir := filepath.Join(tmpDir, "lists")
7575+ if err := os.MkdirAll(listDir, 0755); err != nil {
7676+ t.Fatal(err)
7777+ }
7878+7979+ // Create empty screener list files (first-run scenario)
8080+ for _, name := range []string{"screened_in.txt", "screened_out.txt", "feed.txt", "papertrail.txt", "spam.txt"} {
8181+ if err := os.WriteFile(filepath.Join(listDir, name), []byte{}, 0600); err != nil {
8282+ t.Fatal(err)
8383+ }
8484+ }
8585+8686+ cfg := config.Config{
8787+ UI: config.UIConfig{
8888+ BgSyncInterval: 5,
8989+ },
9090+ Folders: config.FoldersConfig{
9191+ Inbox: "INBOX",
9292+ ToScreen: "ToScreen",
9393+ ScreenedOut: "ScreenedOut",
9494+ Feed: "Feed",
9595+ PaperTrail: "PaperTrail",
9696+ Spam: "Spam",
9797+ Trash: "Trash",
9898+ },
9999+ Screener: config.ScreenerConfig{
100100+ ScreenedIn: filepath.Join(listDir, "screened_in.txt"),
101101+ ScreenedOut: filepath.Join(listDir, "screened_out.txt"),
102102+ Feed: filepath.Join(listDir, "feed.txt"),
103103+ PaperTrail: filepath.Join(listDir, "papertrail.txt"),
104104+ Spam: filepath.Join(listDir, "spam.txt"),
105105+ },
106106+ }
107107+108108+ sc, err := screener.New(screener.Config{
109109+ ScreenedIn: cfg.Screener.ScreenedIn,
110110+ ScreenedOut: cfg.Screener.ScreenedOut,
111111+ Feed: cfg.Screener.Feed,
112112+ PaperTrail: cfg.Screener.PaperTrail,
113113+ Spam: cfg.Screener.Spam,
114114+ })
115115+ if err != nil {
116116+ t.Fatal(err)
117117+ }
118118+119119+ // Verify screener is empty
120120+ if !sc.IsEmpty() {
121121+ t.Fatal("expected screener to be empty")
122122+ }
123123+124124+ d := New(cfg, &imap.Client{}, sc)
125125+126126+ // screenInbox should return early without attempting to fetch/move emails
127127+ err = d.screenInbox(context.Background())
128128+ if err != nil {
129129+ t.Fatalf("screenInbox failed with empty lists: %v", err)
130130+ }
131131+132132+ // This test verifies that:
133133+ // 1. No IMAP operations are attempted (would fail with nil client)
134134+ // 2. The function returns nil (success/no-op)
135135+ // 3. Mirrors TUI behavior of pausing screening when lists are empty
136136+}
137137+72138func TestReloadScreener(t *testing.T) {
73139 tmpDir := t.TempDir()
74140 listDir := filepath.Join(tmpDir, "lists")