···33# 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
66+- **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
67- **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
7889# 2026-04-17
+19-5
internal/screener/screener.go
···145145 return s.Classify(from), addr
146146}
147147148148-// Approve adds addr to screened_in.txt and removes it from screened_out/spam.
148148+// Approve adds addr to screened_in.txt and removes it from all conflicting lists.
149149func (s *Screener) Approve(from string) error {
150150 _ = s.removeFromList(s.cfg.ScreenedOut, s.screenedOut, from)
151151+ _ = s.removeFromList(s.cfg.Feed, s.feed, from)
152152+ _ = s.removeFromList(s.cfg.PaperTrail, s.paperTrail, from)
151153 _ = s.removeFromList(s.cfg.Spam, s.spam, from)
152154 return s.addToList(s.cfg.ScreenedIn, s.screenedIn, from)
153155}
154156155155-// Block adds addr to screened_out.txt and removes it from screened_in/spam.
157157+// Block adds addr to screened_out.txt and removes it from all conflicting lists.
156158func (s *Screener) Block(from string) error {
157159 _ = s.removeFromList(s.cfg.ScreenedIn, s.screenedIn, from)
160160+ _ = s.removeFromList(s.cfg.Feed, s.feed, from)
161161+ _ = s.removeFromList(s.cfg.PaperTrail, s.paperTrail, from)
158162 _ = s.removeFromList(s.cfg.Spam, s.spam, from)
159163 return s.addToList(s.cfg.ScreenedOut, s.screenedOut, from)
160164}
161165162162-// MarkSpam adds addr to spam.txt and removes it from screened_in/screened_out.
166166+// MarkSpam adds addr to spam.txt and removes it from all conflicting lists.
163167func (s *Screener) MarkSpam(from string) error {
164168 _ = s.removeFromList(s.cfg.ScreenedIn, s.screenedIn, from)
165169 _ = s.removeFromList(s.cfg.ScreenedOut, s.screenedOut, from)
170170+ _ = s.removeFromList(s.cfg.Feed, s.feed, from)
171171+ _ = s.removeFromList(s.cfg.PaperTrail, s.paperTrail, from)
166172 return s.addToList(s.cfg.Spam, s.spam, from)
167173}
168174169169-// MarkFeed adds addr to feed.txt and updates the in-memory set.
175175+// MarkFeed adds addr to feed.txt and removes it from all conflicting lists.
170176func (s *Screener) MarkFeed(from string) error {
177177+ _ = s.removeFromList(s.cfg.ScreenedIn, s.screenedIn, from)
178178+ _ = s.removeFromList(s.cfg.ScreenedOut, s.screenedOut, from)
179179+ _ = s.removeFromList(s.cfg.PaperTrail, s.paperTrail, from)
180180+ _ = s.removeFromList(s.cfg.Spam, s.spam, from)
171181 return s.addToList(s.cfg.Feed, s.feed, from)
172182}
173183174174-// MarkPaperTrail adds addr to papertrail.txt and updates the in-memory set.
184184+// MarkPaperTrail adds addr to papertrail.txt and removes it from all conflicting lists.
175185func (s *Screener) MarkPaperTrail(from string) error {
186186+ _ = s.removeFromList(s.cfg.ScreenedIn, s.screenedIn, from)
187187+ _ = s.removeFromList(s.cfg.ScreenedOut, s.screenedOut, from)
188188+ _ = s.removeFromList(s.cfg.Feed, s.feed, from)
189189+ _ = s.removeFromList(s.cfg.Spam, s.spam, from)
176190 return s.addToList(s.cfg.PaperTrail, s.paperTrail, from)
177191}
178192