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 headless option to run on server

+1039 -75
+1
.gitignore
··· 1 1 # Binary (root-level compiled binary only, not cmd/neomd/ source dir) 2 2 /neomd 3 + /neomd-freebsd 3 4 /neomd-android 4 5 5 6 # Go build cache
+4
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + # 2026-04-18 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 + - **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 + 3 7 # 2026-04-17 4 8 - **GitHub/Obsidian-style callouts in emails** — compose emails with callout syntax `> [!note]`, `> [!tip]`, `> [!warning]` for styled alert boxes in HTML emails; rendered with colored left borders, subtle backgrounds, and emoji icons using Kanagawa theme colors (crystalBlue, springGreen, carpYellow, oniViolet, autumnRed); compact spacing with emoji and title matching body text size (15px) for minimal visual intrusion; supports custom titles (`> [!note] Custom Title`), multiple paragraphs, and nested callouts; always expanded (no collapsible behavior), no JavaScript required; works in both syntaxes: `> [!note]` (with space) or `>[!note]` (without space); plain text emails format callouts as emoji text without blockquote markers (readable in neomd reader and plain text clients); uses local fork of goldmark-obsidian-callout with email-optimized rendering; same syntax used in neomd's README now works in your composed emails 5 9 - **Timer-based mark-as-read** — emails are no longer marked as read immediately when opened; instead, a configurable timer (default 7 seconds) starts when you enter the reader; if you stay for the full duration, the email is marked as `\Seen`; if you exit early (quick peek), it stays unread; prevents accidental marking when browsing through emails
+8 -2
Makefile
··· 4 4 VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") 5 5 LDFLAGS := -ldflags "-X main.version=$(VERSION)" 6 6 7 - .PHONY: build run install clean test test-integration send-test vet fmt tidy release docs help check-go demo demo-reset demo-hp demo-hp-reset benchmark 7 + .PHONY: build run install daemon clean test test-integration send-test vet fmt tidy release docs help check-go demo demo-reset demo-hp demo-hp-reset benchmark 8 8 9 9 10 10 .DEFAULT_GOAL := install ··· 29 29 ## build: compile ./neomd (version from git tag) 30 30 build: check-go docs 31 31 go build $(LDFLAGS) -o $(BINARY) $(CMD) 32 - 32 + CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -o neomd-freebsd ./cmd/neomd # Build static binary for FreeBSD 33 33 ## run: build and run 34 34 run: build 35 35 ./$(BINARY) $(ARGS) ··· 39 39 install -Dm755 $(BINARY) $(INSTALL)/$(BINARY) 40 40 @echo "Installed to $(INSTALL)/$(BINARY)" 41 41 42 + ## daemon: run in headless daemon mode 43 + daemon: build 44 + ./$(BINARY) --headless 42 45 43 46 ## demo: run neomd with demo account (~/.config/neomd-demo/config.toml) 44 47 demo: build ··· 139 142 ## docs-clean: remove generated Hugo files 140 143 docs-clean: 141 144 $(MAKE) -C docs clean 145 + 146 + sync-headless: 147 + scp neomd-freebsd ti:~/neomd 142 148 143 149 ## help: print this list 144 150 help:
+1
README.md
··· 159 159 - **Multi-select** — `m` marks emails, then batch-delete, move, or screen them all at once [[more](https://ssp-data.github.io/neomd/docs/keybindings/#multi-select--undo)] 160 160 - **Auto-screen on load** — screener runs automatically every time the Inbox loads (startup, `R`); keeps your inbox clean without pressing `S` (configurable, on by default) [[more](https://ssp-data.github.io/neomd/docs/screener/#auto-screen-and-background-sync)] 161 161 - **Background sync** — while neomd is open, inbox is fetched and screened every 5 minutes in the background; interval configurable, set to `0` to disable [[more](https://ssp-data.github.io/neomd/docs/screener/#auto-screen-and-background-sync)] 162 + - **Headless daemon mode** — run `neomd --headless` on a server to continuously screen emails in the background without the TUI; watches screener list files for changes via Syncthing; emails are auto-screened every `bg_sync_interval` minutes so mobile apps see correctly filtered IMAP folders; perfect for running on a NAS while using the TUI on laptop/Android [[more](https://ssp-data.github.io/neomd/docs/configurations/headless/)] 162 163 - **Kanagawa theme** — colors from the [kanagawa.nvim](https://github.com/rebelot/kanagawa.nvim) palette 163 164 - **IMAP + SMTP** — direct connection via RFC 6851 MOVE, no local sync daemon required and keeps it in sync if you use it on mobile or different device [[more](https://ssp-data.github.io/neomd/docs/configuration/)] 164 165
+23 -10
cmd/neomd/main.go
··· 11 11 12 12 tea "github.com/charmbracelet/bubbletea" 13 13 "github.com/sspaeti/neomd/internal/config" 14 + "github.com/sspaeti/neomd/internal/daemon" 14 15 goIMAP "github.com/sspaeti/neomd/internal/imap" 15 16 "github.com/sspaeti/neomd/internal/oauth2" 16 17 "github.com/sspaeti/neomd/internal/screener" ··· 23 24 func main() { 24 25 cfgPath := flag.String("config", "", "path to config.toml (default: ~/.config/neomd/config.toml)") 25 26 showVersion := flag.Bool("version", false, "print version and exit") 27 + headless := flag.Bool("headless", false, "run in headless daemon mode (no TUI)") 26 28 flag.Parse() 27 29 28 30 if *showVersion { ··· 119 121 os.Exit(1) 120 122 } 121 123 122 - ui.Version = version 123 - model := ui.New(cfg, imapClients, sc) 124 + // Fork: run either headless daemon or TUI 125 + if *headless { 126 + // Headless daemon mode: run background screening loop 127 + d := daemon.New(*cfg, imapClients[0], sc) 128 + if err := d.Run(ctx); err != nil { 129 + fmt.Fprintf(os.Stderr, "neomd: daemon error: %v\n", err) 130 + os.Exit(1) 131 + } 132 + } else { 133 + // TUI mode: run interactive interface 134 + ui.Version = version 135 + model := ui.New(cfg, imapClients, sc) 124 136 125 - p := tea.NewProgram( 126 - model, 127 - tea.WithAltScreen(), 128 - tea.WithMouseCellMotion(), 129 - ) 130 - if _, err := p.Run(); err != nil { 131 - fmt.Fprintf(os.Stderr, "neomd: %v\n", err) 132 - os.Exit(1) 137 + p := tea.NewProgram( 138 + model, 139 + tea.WithAltScreen(), 140 + tea.WithMouseCellMotion(), 141 + ) 142 + if _, err := p.Run(); err != nil { 143 + fmt.Fprintf(os.Stderr, "neomd: %v\n", err) 144 + os.Exit(1) 145 + } 133 146 } 134 147 } 135 148
+30 -28
docs/content/docs/_index.md
··· 82 82 *all colored boxes represent neomd folders* 83 83 84 84 **Key principles:** 85 - - **Screener first**: Unknown senders never clutter your Inbox — they wait in ToScreen for classification 86 - - **One-time decision**: Once you classify a sender (`I/O/F/P`), all future emails from them are automatically routed 87 - - **GTD processing**: Emails in Inbox are processed once — if < 2 min, do it or keep it in inbox as doing *Next* otherwise move to Waiting, Someday, or Scheduled 88 - - **Minimal filing**: Only Archive when done; no complex folder hierarchies — use search to find old emails 89 - - **Separate contexts**: Feed for newsletters (read when you want), PaperTrail for receipts (search when needed) 85 + - **Screener first**: Unknown senders never clutter your Inbox — they wait in ToScreen for classification [[more](https://ssp-data.github.io/neomd/docs/screener/)] 86 + - **One-time decision**: Once you classify a sender (`I/O/F/P`), all future emails from them are automatically routed [[more](https://ssp-data.github.io/neomd/docs/screener/#how-classification-works)] 87 + - **GTD processing**: Emails in Inbox are processed once — if < 2 min, do it or keep it in inbox as doing *Next* otherwise move to Waiting, Someday, or Scheduled 88 + - **Minimal filing**: Only Archive when done; no complex folder hierarchies — use search to find old emails 89 + - **Separate contexts**: Feed for newsletters (read when you want), PaperTrail for receipts (search when needed) 90 + - See full features list below. 90 91 91 92 92 93 ## Screenshots ··· 143 144 144 145 ## Features 145 146 146 - - **Write in Markdown, send beautifully** — compose in `$EDITOR` (defaults to `nvim`), send as `multipart/alternative`: raw Markdown as plain text + goldmark-rendered HTML so recipients get clickable links, bold, headers, inline code, and code blocks 147 - - **Pre-send review** — after closing the editor, review To/Subject/body before sending; attach files, save to Drafts, or re-open the editor — no accidental sends 148 - - **Attachments** — attach files from the pre-send screen via yazi (`a`); images appear inline in the email body, other files as attachments; also attach from within neovim via `<leader>a`; the reader lists all attachments (including inline images) and `1`–`9` downloads and opens them 149 - - **Link opener** — links in emails are numbered `[1]`-`[0]` in the reader header; press `space+digit` to open in `$BROWSER` 150 - - **CC, BCC, Reply-all** — optional Cc/Bcc fields (toggle with `ctrl+b`); `R` in the reader replies to sender + all CC recipients 151 - - **Emoji reactions** — press `ctrl+e` from inbox or reader to react with emoji (👍 ❤️ 😂 🎉 🙏 💯 👀 ✅); instant send with proper threading and quoted message history, no editor needed; reactions appear in conversation threads with neomd branding 152 - - **GitHub/Obsidian-style callouts in emails** — compose emails with callout syntax `> [!note]`, `> [!tip]`, `> [!warning]` for styled alert boxes in HTML emails; rendered with colored left borders, subtle backgrounds, and emoji icons 153 - - **Drafts** — `d` in pre-send saves to Drafts (IMAP APPEND); `E` in the reader re-opens a draft as an editable compose; compose sessions are auto-backed up to `~/.cache/neomd/drafts/` so you never lose an unsent email (`:recover` to reopen) 154 - - **HTML signatures** — configure separate text and HTML signatures; text signature appears in editor and plain text part, HTML signature in HTML part only; use `[html-signature]` placeholder to control inclusion per-email 155 - - **Multiple From addresses** — define SMTP-only `[[senders]]` aliases (e.g. `s@ssp.sh` through an existing account); cycle with `ctrl+f` in compose and pre-send; sent copies always land in the Sent folder 156 - - **Undo** — `u` reverses the last move or delete (`x`, `A`, `M*`) using the UIDPLUS destination UID 157 - - **Search** — `/` filters loaded emails in-memory; `space /` or `:search` runs IMAP SEARCH across all folders (only fetching header capped at 100 per folder) with results in a temporary "Search" tab; supports `from:`, `subject:`, `to:` prefixes 158 - - **Address autocomplete** — To/Cc/Bcc fields autocomplete from screener lists; navigate with `ctrl+n`/`ctrl+p`, accept with `tab` 159 - - **Everything view** — `ge` or `:everything` shows the 50 most recent emails across all folders; find emails that were screened out, moved to spam, or otherwise hard to locate 160 - - **Threaded inbox** — related emails are grouped together in the inbox list with a vertical connector line (`│`/`╰`), Twitter-style; threads are detected via `In-Reply-To`/`Message-ID` headers with a subject+participant fallback; newest reply on top, root at bottom; `·` reply indicator shows which emails you've answered 161 - - **Conversation view** — `T` or `:thread` shows the full conversation across folders (Inbox, Sent, Archive, etc.) in a temporary tab with `[Folder]` prefix; see your replies alongside received emails 162 - - **Glamour reading** — incoming emails rendered as styled Markdown in the terminal 163 - - **HEY-style screener** — unknown senders land in `ToScreen`; press `I/O/F/P` to approve, block, mark as Feed, or mark as PaperTrail; reuses your existing `screened_in.txt` lists from neomutt 164 - - **Folder tabs** — Inbox, ToScreen, Feed, PaperTrail, Archive, Waiting, Someday, Scheduled, Sent, Trash, ScreenedOut 165 - - **Multi-select** — `m` marks emails, then batch-delete, move, or screen them all at once 166 - - **Auto-screen on load** — screener runs automatically every time the Inbox loads (startup, `R`); keeps your inbox clean without pressing `S` (configurable, on by default) 167 - - **Background sync** — while neomd is open, inbox is fetched and screened every 5 minutes in the background; interval configurable, set to `0` to disable 147 + - **Write in Markdown, send beautifully** — compose in `$EDITOR` (defaults to `nvim`), send as `multipart/alternative`: raw Markdown as plain text + goldmark-rendered HTML so recipients get clickable links, bold, headers, inline code, and code blocks [[more](https://ssp-data.github.io/neomd/docs/sending/)] 148 + - **Pre-send review** — after closing the editor, review To/Subject/body before sending; attach files, save to Drafts, or re-open the editor — no accidental sends [[more](https://ssp-data.github.io/neomd/docs/sending/#pre-send-review)] 149 + - **Attachments** — attach files from the pre-send screen via yazi (`a`); images appear inline in the email body, other files as attachments; also attach from within neovim via `<leader>a`; the reader lists all attachments (including inline images) and `1`–`9` downloads and opens them [[more](https://ssp-data.github.io/neomd/docs/sending/#attachments)] 150 + - **Link opener** — links in emails are numbered `[1]`-`[0]` in the reader header; press `space+digit` to open in `$BROWSER` [[more](https://ssp-data.github.io/neomd/docs/reading/#links)] 151 + - **CC, BCC, Reply-all** — optional Cc/Bcc fields (toggle with `ctrl+b`); `R` in the reader replies to sender + all CC recipients [[more](https://ssp-data.github.io/neomd/docs/sending/#cc-bcc-reply-all-and-forward)] 152 + - **Drafts** — `d` in pre-send saves to Drafts (IMAP APPEND); `E` in the reader re-opens a draft as an editable compose; compose sessions are auto-backed up to `~/.cache/neomd/drafts/` so you never lose an unsent email (`:recover` to reopen) [[more](https://ssp-data.github.io/neomd/docs/sending/#drafts)] 153 + - **HTML signatures** — configure separate text and HTML signatures; text signature appears in editor and plain text part, HTML signature in HTML part only; use `[html-signature]` placeholder to control inclusion per-email [[more](https://ssp-data.github.io/neomd/docs/configuration/#html-signatures)] 154 + - **Multiple From addresses** — define SMTP-only `[[senders]]` aliases (e.g. `s@ssp.sh` through an existing account); cycle with `ctrl+f` in compose and pre-send; sent copies always land in the Sent folder [[more](https://ssp-data.github.io/neomd/docs/sending/#multiple-from-addresses)] 155 + - **Undo** — `u` reverses the last move or delete (`x`, `A`, `M*`) using the UIDPLUS destination UID [[more](https://ssp-data.github.io/neomd/docs/keybindings/#multi-select--undo)] 156 + - **Search** — `/` filters loaded emails in-memory; `space /` or `:search` runs IMAP SEARCH across all folders (only fetching header capped at 100 per folder) with results in a temporary "Search" tab; supports `from:`, `subject:`, `to:` prefixes [[more](https://ssp-data.github.io/neomd/docs/keybindings/#leader-key-mappings-space-prefix)] 157 + - **Address autocomplete** — To/Cc/Bcc fields autocomplete from screener lists; navigate with `ctrl+n`/`ctrl+p`, accept with `tab` 158 + - **Everything view** — `ge` or `:everything` shows the 50 most recent emails across all folders; find emails that were screened out, moved to spam, or otherwise hard to locate [[more](https://ssp-data.github.io/neomd/docs/keybindings/#folders)] 159 + - **Threaded inbox** — related emails are grouped together in the inbox list with a vertical connector line (`│`/`╰`), Twitter-style; threads are detected via `In-Reply-To`/`Message-ID` headers with a subject+participant fallback; newest reply on top, root at bottom; `·` reply indicator shows which emails you've answered [[more](https://ssp-data.github.io/neomd/docs/reading/#threaded-inbox)] 160 + - **Conversation view** — `T` or `:thread` shows the full conversation across folders (Inbox, Sent, Archive, etc.) in a temporary tab with `[Folder]` prefix; see your replies alongside received emails [[more](https://ssp-data.github.io/neomd/docs/reading/#conversation-view)] 161 + - **Glamour reading** — incoming emails rendered as styled Markdown in the terminal [[more](https://ssp-data.github.io/neomd/docs/reading/)] 162 + - **HEY-style screener** — unknown senders land in `ToScreen`; press `I/O/F/P` to approve, block, mark as Feed, or mark as PaperTrail; reuses your existing `screened_in.txt` lists from neomutt [[more](https://ssp-data.github.io/neomd/docs/screener/)] 163 + - **Folder tabs** — Inbox, ToScreen, Feed, PaperTrail, Archive, Waiting, Someday, Scheduled, Sent, Trash, ScreenedOut [[more](https://ssp-data.github.io/neomd/docs/keybindings/#folders)] 164 + - **Emoji reactions** — press `ctrl+e` from inbox or reader to react with emoji (👍 ❤️ 😂 🎉 🙏 💯 👀 ✅); instant send with proper threading and quoted message history, no editor needed; reactions appear in conversation threads with neomd branding [[more](https://ssp-data.github.io/neomd/docs/sending/#emoji-reactions)] 165 + - **GitHub/Obsidian-style callouts in emails** — compose emails with callout syntax `> [!note]`, `> [!tip]`, `> [!warning]` for styled alert boxes in HTML emails; rendered with colored left borders, subtle backgrounds, and emoji icons [[more](https://ssp-data.github.io/neomd/docs/sending/#callouts-admonition)] 166 + - **Multi-select** — `m` marks emails, then batch-delete, move, or screen them all at once [[more](https://ssp-data.github.io/neomd/docs/keybindings/#multi-select--undo)] 167 + - **Auto-screen on load** — screener runs automatically every time the Inbox loads (startup, `R`); keeps your inbox clean without pressing `S` (configurable, on by default) [[more](https://ssp-data.github.io/neomd/docs/screener/#auto-screen-and-background-sync)] 168 + - **Background sync** — while neomd is open, inbox is fetched and screened every 5 minutes in the background; interval configurable, set to `0` to disable [[more](https://ssp-data.github.io/neomd/docs/screener/#auto-screen-and-background-sync)] 169 + - **Headless daemon mode** — run `neomd --headless` on a server to continuously screen emails in the background without the TUI; watches screener list files for changes via Syncthing; emails are auto-screened every `bg_sync_interval` minutes so mobile apps see correctly filtered IMAP folders; perfect for running on a NAS while using the TUI on laptop/Android [[more](https://ssp-data.github.io/neomd/docs/configurations/headless/)] 168 170 - **Kanagawa theme** — colors from the [kanagawa.nvim](https://github.com/rebelot/kanagawa.nvim) palette 169 - - **IMAP + SMTP** — direct connection via RFC 6851 MOVE, no local sync daemon required and keeps it in sync if you use it on mobile or different device 171 + - **IMAP + SMTP** — direct connection via RFC 6851 MOVE, no local sync daemon required and keeps it in sync if you use it on mobile or different device [[more](https://ssp-data.github.io/neomd/docs/configuration/)] 170 172 171 173 ## Install 172 174
+271
docs/content/docs/configurations/headless.md
··· 1 + --- 2 + title: Headless Daemon Mode 3 + weight: 4 4 + --- 5 + 6 + Neomd can run in headless daemon mode to continuously screen emails in the background without launching the TUI. This is useful for running neomd on a server (like a NAS) that screens emails automatically, while you use the TUI on your laptop or mobile device. 7 + 8 + ## Overview 9 + 10 + When running in headless mode, neomd: 11 + - **Screens emails automatically** every `bg_sync_interval` minutes 12 + - **Watches screener list files** and reloads when they change (via Syncthing) 13 + - **Runs in the background** as a standard process 14 + - **Logs to stdout** for monitoring 15 + 16 + ## Quick Start 17 + 18 + ```sh 19 + # Run in foreground (for testing) 20 + neomd --headless 21 + 22 + # Run in background 23 + neomd --headless & 24 + 25 + # Run in background with logging 26 + nohup neomd --headless > /var/log/neomd.log 2>&1 & 27 + 28 + # Or redirect to a file 29 + neomd --headless >> ~/.local/share/neomd/daemon.log 2>&1 & 30 + ``` 31 + 32 + ## Multi-Device Setup with Syncthing 33 + 34 + The headless daemon is designed to work with [Syncthing](https://syncthing.net/) to keep screener lists synchronized across multiple devices. 35 + 36 + ### Architecture 37 + 38 + 1. **NAS/Server**: Runs `neomd --headless` continuously, screening emails every 5 minutes 39 + 2. **Laptop**: Runs TUI with `bg_sync_interval = 0` (disabled), classifies senders manually 40 + 3. **Android/Mobile**: Runs TUI with `bg_sync_interval = 0` (disabled), classifies senders manually 41 + 4. **Syncthing**: Syncs screener list files across all devices 42 + 43 + ### Benefits 44 + 45 + - **Automatic screening**: Emails are screened on the server even when your laptop/phone is offline 46 + - **Mobile email apps work**: Your phone's native email app sees screened emails in the correct IMAP folders 47 + - **No conflicts**: Only the daemon moves emails; TUI instances only classify senders 48 + - **Instant sync**: Classification decisions propagate to all devices via Syncthing 49 + 50 + ## Configuration 51 + 52 + ### Server Config (Daemon) 53 + 54 + On your NAS/server, set `bg_sync_interval` to enable periodic screening: 55 + 56 + ```toml 57 + # ~/.config/neomd/config.toml (server) 58 + [ui] 59 + bg_sync_interval = 5 # Screen inbox every 5 minutes 60 + ``` 61 + 62 + ### Laptop/Mobile Config (TUI) 63 + 64 + On devices where you run the TUI, **disable background sync** to avoid duplicate moves: 65 + 66 + ```toml 67 + # ~/.config/neomd/config.toml (laptop/mobile) 68 + [ui] 69 + bg_sync_interval = 0 # Disable background screening (daemon handles it) 70 + ``` 71 + 72 + ## Syncthing Setup 73 + 74 + ### Files to Sync 75 + 76 + Configure Syncthing to sync the following files across all your devices: 77 + 78 + 1. **Screener list directory**: `~/.config/neomd/lists/` 79 + - `screened_in.txt` 80 + - `screened_out.txt` 81 + - `feed.txt` 82 + - `papertrail.txt` 83 + - `spam.txt` 84 + 85 + 2. **Config file** (optional): `~/.config/neomd/config.toml` 86 + - Useful for keeping settings consistent across devices 87 + - Be careful with account-specific settings (passwords, paths) 88 + 89 + ### Syncthing Folder Setup 90 + 91 + 1. **Install Syncthing** on all devices (NAS, laptop, Android) 92 + 2. **Create a shared folder** named "neomd-lists" 93 + 3. **Set folder path** to `~/.config/neomd/lists/` on each device 94 + 4. **Connect devices** and wait for initial sync 95 + 96 + Example Syncthing folder configuration: 97 + - **Folder ID**: `neomd-lists` 98 + - **Folder Path**: `/home/user/.config/neomd/lists/` 99 + - **Folder Type**: Send & Receive 100 + - **File Versioning**: Simple File Versioning (keep 5 versions) 101 + 102 + ### Conflict Handling 103 + 104 + - **File-level conflicts**: Syncthing creates `.sync-conflict` files if two devices modify the same file simultaneously 105 + - **Email-level**: IMAP is the source of truth; no local email state to conflict 106 + - **Screener lists**: Append-only operations are safe; duplicates are harmless (normalized automatically) 107 + 108 + The daemon watches for file changes and reloads screener lists automatically when Syncthing updates them. 109 + 110 + ## Systemd Service (Optional) 111 + 112 + For servers with systemd, you can create a service unit for automatic startup and logging: 113 + 114 + ```ini 115 + # /etc/systemd/user/neomd.service 116 + [Unit] 117 + Description=Neomd Headless Email Screener 118 + After=network.target 119 + 120 + [Service] 121 + Type=simple 122 + ExecStart=%h/.local/bin/neomd --headless 123 + Restart=on-failure 124 + RestartSec=10 125 + StandardOutput=journal 126 + StandardError=journal 127 + 128 + [Install] 129 + WantedBy=default.target 130 + ``` 131 + 132 + Enable and start the service: 133 + 134 + ```sh 135 + # Install neomd to ~/.local/bin 136 + make install 137 + 138 + # Reload systemd 139 + systemctl --user daemon-reload 140 + 141 + # Enable auto-start on login 142 + systemctl --user enable neomd 143 + 144 + # Start now 145 + systemctl --user start neomd 146 + 147 + # Check status 148 + systemctl --user status neomd 149 + 150 + # View logs 151 + journalctl --user -u neomd -f 152 + ``` 153 + 154 + ## Monitoring 155 + 156 + ### View Logs 157 + 158 + If running with `nohup` or redirected output: 159 + 160 + ```sh 161 + tail -f /var/log/neomd.log 162 + ``` 163 + 164 + If running as systemd service: 165 + 166 + ```sh 167 + journalctl --user -u neomd -f 168 + ``` 169 + 170 + ### Log Format 171 + 172 + The daemon logs structured output with timestamps: 173 + 174 + ``` 175 + time=2025-04-18T10:00:00Z level=INFO msg="neomd daemon starting" version=headless 176 + time=2025-04-18T10:00:00Z level=INFO msg="screening interval configured" minutes=5 177 + time=2025-04-18T10:00:00Z level=INFO msg="watching directory for changes" dir=/home/user/.config/neomd/lists 178 + time=2025-04-18T10:00:00Z level=INFO msg="daemon running" interval=5m0s 179 + time=2025-04-18T10:00:05Z level=INFO msg="running initial screening" 180 + time=2025-04-18T10:00:05Z level=INFO msg="fetched inbox emails" count=42 181 + time=2025-04-18T10:00:05Z level=INFO msg="emails to screen" count=12 182 + time=2025-04-18T10:00:05Z level=INFO msg="screened email" index=1 total=12 from="newsletter@example.com" subject="Weekly Update" dst=Feed 183 + ... 184 + time=2025-04-18T10:00:06Z level=INFO msg="screening complete" moved=12 total=12 185 + ``` 186 + 187 + ### Graceful Shutdown 188 + 189 + Send SIGTERM or SIGINT to stop the daemon: 190 + 191 + ```sh 192 + # If running in foreground 193 + Ctrl+C 194 + 195 + # If running in background 196 + kill <pid> 197 + 198 + # With systemd 199 + systemctl --user stop neomd 200 + ``` 201 + 202 + The daemon will finish the current screening operation before exiting. 203 + 204 + ## Troubleshooting 205 + 206 + ### Daemon exits immediately 207 + 208 + Check that `bg_sync_interval` is set to a value > 0: 209 + 210 + ```sh 211 + grep bg_sync_interval ~/.config/neomd/config.toml 212 + ``` 213 + 214 + ### Screener lists not reloading 215 + 216 + Check file watcher logs: 217 + 218 + ```sh 219 + tail -f /var/log/neomd.log | grep "watching directory" 220 + ``` 221 + 222 + Verify Syncthing is running and syncing: 223 + 224 + ```sh 225 + # Check Syncthing web UI (usually http://localhost:8384) 226 + ``` 227 + 228 + ### Emails not being screened 229 + 230 + 1. Check daemon is running: `ps aux | grep neomd` 231 + 2. Check IMAP connection in logs 232 + 3. Verify screener list files exist and contain email addresses 233 + 4. Check folder configuration in config.toml 234 + 235 + ### Duplicate screening 236 + 237 + If emails are being moved twice (once by daemon, once by TUI): 238 + 239 + - Set `bg_sync_interval = 0` on TUI devices 240 + - Only run one daemon instance per account 241 + 242 + ## Android Termux Example 243 + 244 + >[!NOTE] 245 + > See Android Termux Setup at [Android Docs](android.md) 246 + 247 + On Android, you can run the daemon in a Termux session: 248 + 249 + ```sh 250 + # Install Termux:Boot from F-Droid to auto-start on device boot 251 + pkg install termux-boot 252 + 253 + # Create boot script 254 + mkdir -p ~/.termux/boot 255 + cat > ~/.termux/boot/neomd-daemon.sh <<'EOF' 256 + #!/data/data/com.termux/files/usr/bin/bash 257 + cd ~/neomd 258 + nohup ./neomd --headless >> ~/neomd-daemon.log 2>&1 & 259 + EOF 260 + chmod +x ~/.termux/boot/neomd-daemon.sh 261 + 262 + # Reboot device to auto-start daemon 263 + ``` 264 + 265 + ## Notes 266 + 267 + - The daemon only **reads** screener list files and **moves** emails via IMAP 268 + - All sender classification (adding to lists) happens in the TUI 269 + - File watching requires the screener list directory to exist 270 + - The daemon uses the first configured account from `config.toml` 271 + - IMAP connection is kept alive and automatically reconnects on failures
+1
go.mod
··· 12 12 github.com/emersion/go-imap/v2 v2.0.0-beta.8 13 13 github.com/emersion/go-message v0.18.2 14 14 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 15 + github.com/fsnotify/fsnotify v1.9.0 15 16 github.com/sspaeti/goldmark-obsidian-callout-for-neomd v0.1.1 16 17 github.com/yuin/goldmark v1.7.8 17 18 golang.org/x/oauth2 v0.35.0
+2
go.sum
··· 56 56 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= 57 57 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 58 58 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 59 + github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 60 + github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 59 61 github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 60 62 github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 61 63 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
+226
internal/daemon/daemon.go
··· 1 + // Package daemon provides a headless background mode for neomd that 2 + // continuously screens emails without launching the TUI. 3 + package daemon 4 + 5 + import ( 6 + "context" 7 + "fmt" 8 + "log/slog" 9 + "os" 10 + "os/signal" 11 + "path/filepath" 12 + "syscall" 13 + "time" 14 + 15 + "github.com/fsnotify/fsnotify" 16 + "github.com/sspaeti/neomd/internal/config" 17 + "github.com/sspaeti/neomd/internal/imap" 18 + "github.com/sspaeti/neomd/internal/screener" 19 + ) 20 + 21 + // Daemon runs headless email screening in the background. 22 + type Daemon struct { 23 + cfg config.Config 24 + imapCli *imap.Client 25 + screener *screener.Screener 26 + logger *slog.Logger 27 + } 28 + 29 + // New creates a new daemon instance. 30 + func New(cfg config.Config, imapCli *imap.Client, sc *screener.Screener) *Daemon { 31 + return &Daemon{ 32 + cfg: cfg, 33 + imapCli: imapCli, 34 + screener: sc, 35 + logger: slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})), 36 + } 37 + } 38 + 39 + // Run starts the daemon and blocks until interrupted by a signal. 40 + func (d *Daemon) Run(ctx context.Context) error { 41 + d.logger.Info("neomd daemon starting", "version", "headless") 42 + 43 + // Check bg_sync_interval 44 + intervalMins := d.cfg.UI.BgSyncInterval 45 + if intervalMins <= 0 { 46 + return fmt.Errorf("bg_sync_interval must be > 0 for daemon mode (got %d)", intervalMins) 47 + } 48 + interval := time.Duration(intervalMins) * time.Minute 49 + d.logger.Info("screening interval configured", "minutes", intervalMins) 50 + 51 + // Set up signal handling for graceful shutdown 52 + sigChan := make(chan os.Signal, 1) 53 + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) 54 + 55 + // Set up file watcher for screener list changes 56 + watcher, err := d.setupFileWatcher() 57 + if err != nil { 58 + return fmt.Errorf("setup file watcher: %w", err) 59 + } 60 + defer watcher.Close() 61 + 62 + // Create a context that gets cancelled on signal 63 + ctx, cancel := context.WithCancel(ctx) 64 + defer cancel() 65 + 66 + // Run initial screening immediately 67 + d.logger.Info("running initial screening") 68 + if err := d.screenInbox(ctx); err != nil { 69 + d.logger.Error("initial screening failed", "error", err) 70 + } 71 + 72 + // Set up ticker for periodic screening 73 + ticker := time.NewTicker(interval) 74 + defer ticker.Stop() 75 + 76 + d.logger.Info("daemon running", "interval", interval.String()) 77 + 78 + // Main event loop 79 + for { 80 + select { 81 + case <-ticker.C: 82 + d.logger.Info("running scheduled screening") 83 + if err := d.screenInbox(ctx); err != nil { 84 + d.logger.Error("screening failed", "error", err) 85 + } 86 + 87 + case event := <-watcher.Events: 88 + if event.Op&(fsnotify.Write|fsnotify.Create) != 0 { 89 + d.logger.Info("screener list changed, reloading", "file", filepath.Base(event.Name)) 90 + if err := d.reloadScreener(); err != nil { 91 + d.logger.Error("failed to reload screener", "error", err) 92 + } 93 + } 94 + 95 + case err := <-watcher.Errors: 96 + d.logger.Error("file watcher error", "error", err) 97 + 98 + case sig := <-sigChan: 99 + d.logger.Info("received signal, shutting down", "signal", sig.String()) 100 + cancel() 101 + return nil 102 + } 103 + } 104 + } 105 + 106 + // setupFileWatcher creates a file watcher for all screener list files. 107 + func (d *Daemon) setupFileWatcher() (*fsnotify.Watcher, error) { 108 + watcher, err := fsnotify.NewWatcher() 109 + if err != nil { 110 + return nil, err 111 + } 112 + 113 + // Watch all screener list files 114 + paths := []string{ 115 + d.cfg.Screener.ScreenedIn, 116 + d.cfg.Screener.ScreenedOut, 117 + d.cfg.Screener.Feed, 118 + d.cfg.Screener.PaperTrail, 119 + d.cfg.Screener.Spam, 120 + } 121 + 122 + watchedDirs := make(map[string]bool) 123 + for _, path := range paths { 124 + if path == "" { 125 + continue 126 + } 127 + // Watch the directory containing the file, since some editors 128 + // create temp files and rename them (which breaks file watches) 129 + dir := filepath.Dir(path) 130 + if !watchedDirs[dir] { 131 + if err := watcher.Add(dir); err != nil { 132 + d.logger.Warn("failed to watch directory", "dir", dir, "error", err) 133 + } else { 134 + d.logger.Info("watching directory for changes", "dir", dir) 135 + watchedDirs[dir] = true 136 + } 137 + } 138 + } 139 + 140 + return watcher, nil 141 + } 142 + 143 + // reloadScreener reloads the screener from disk. 144 + func (d *Daemon) reloadScreener() error { 145 + newScreener, err := screener.New(screener.Config{ 146 + ScreenedIn: d.cfg.Screener.ScreenedIn, 147 + ScreenedOut: d.cfg.Screener.ScreenedOut, 148 + Feed: d.cfg.Screener.Feed, 149 + PaperTrail: d.cfg.Screener.PaperTrail, 150 + Spam: d.cfg.Screener.Spam, 151 + }) 152 + if err != nil { 153 + return fmt.Errorf("reload screener: %w", err) 154 + } 155 + d.screener = newScreener 156 + d.logger.Info("screener reloaded successfully") 157 + return nil 158 + } 159 + 160 + // screenInbox fetches inbox emails and screens them. 161 + func (d *Daemon) screenInbox(ctx context.Context) error { 162 + inboxFolder := d.cfg.Folders.Inbox 163 + 164 + // Fetch inbox headers (0 means fetch all) 165 + emails, err := d.imapCli.FetchHeaders(ctx, inboxFolder, 0) 166 + if err != nil { 167 + return fmt.Errorf("fetch inbox headers: %w", err) 168 + } 169 + 170 + if len(emails) == 0 { 171 + d.logger.Info("inbox is empty, nothing to screen") 172 + return nil 173 + } 174 + 175 + d.logger.Info("fetched inbox emails", "count", len(emails)) 176 + 177 + // Classify emails using shared screener logic 178 + moves, err := screener.ClassifyForScreen(d.screener, emails, d.cfg.Folders) 179 + if err != nil { 180 + return fmt.Errorf("classify emails: %w", err) 181 + } 182 + 183 + if len(moves) == 0 { 184 + d.logger.Info("no emails need screening") 185 + return nil 186 + } 187 + 188 + d.logger.Info("emails to screen", "count", len(moves)) 189 + 190 + // Execute moves 191 + movedCount := 0 192 + for i, mv := range moves { 193 + uid := mv.Email.UID 194 + from := mv.Email.From 195 + subject := mv.Email.Subject 196 + dst := mv.Dst 197 + 198 + if err := ctx.Err(); err != nil { 199 + d.logger.Warn("screening interrupted", "moved", movedCount, "total", len(moves)) 200 + return err 201 + } 202 + 203 + _, err := d.imapCli.MoveMessage(ctx, inboxFolder, uid, dst) 204 + if err != nil { 205 + d.logger.Error("failed to move email", 206 + "index", i+1, 207 + "uid", uid, 208 + "from", from, 209 + "subject", subject, 210 + "dst", dst, 211 + "error", err) 212 + continue 213 + } 214 + 215 + movedCount++ 216 + d.logger.Info("screened email", 217 + "index", i+1, 218 + "total", len(moves), 219 + "from", from, 220 + "subject", subject, 221 + "dst", dst) 222 + } 223 + 224 + d.logger.Info("screening complete", "moved", movedCount, "total", len(moves)) 225 + return nil 226 + }
+130
internal/daemon/daemon_test.go
··· 1 + package daemon 2 + 3 + import ( 4 + "context" 5 + "os" 6 + "path/filepath" 7 + "testing" 8 + 9 + "github.com/sspaeti/neomd/internal/config" 10 + "github.com/sspaeti/neomd/internal/imap" 11 + "github.com/sspaeti/neomd/internal/screener" 12 + ) 13 + 14 + func TestNew(t *testing.T) { 15 + cfg := config.Config{ 16 + UI: config.UIConfig{ 17 + BgSyncInterval: 5, 18 + }, 19 + Folders: config.FoldersConfig{ 20 + Inbox: "INBOX", 21 + ScreenedOut: "ScreenedOut", 22 + Feed: "Feed", 23 + PaperTrail: "PaperTrail", 24 + Spam: "Spam", 25 + Trash: "Trash", 26 + }, 27 + } 28 + 29 + imapCli := &imap.Client{} 30 + sc := &screener.Screener{} 31 + 32 + d := New(cfg, imapCli, sc) 33 + 34 + if d == nil { 35 + t.Fatal("New() returned nil") 36 + } 37 + if d.cfg.UI.BgSyncInterval != 5 { 38 + t.Errorf("expected BgSyncInterval=5, got %d", d.cfg.UI.BgSyncInterval) 39 + } 40 + if d.imapCli != imapCli { 41 + t.Error("IMAP client not set correctly") 42 + } 43 + if d.screener != sc { 44 + t.Error("Screener not set correctly") 45 + } 46 + if d.logger == nil { 47 + t.Error("Logger not initialized") 48 + } 49 + } 50 + 51 + func TestRun_InvalidInterval(t *testing.T) { 52 + cfg := config.Config{ 53 + UI: config.UIConfig{ 54 + BgSyncInterval: 0, // Invalid for daemon mode 55 + }, 56 + } 57 + 58 + d := New(cfg, &imap.Client{}, &screener.Screener{}) 59 + 60 + err := d.Run(context.Background()) 61 + if err == nil { 62 + t.Fatal("expected error for bg_sync_interval=0, got nil") 63 + } 64 + if err.Error() != "bg_sync_interval must be > 0 for daemon mode (got 0)" { 65 + t.Errorf("unexpected error message: %v", err) 66 + } 67 + } 68 + 69 + // Note: Full integration test of Run() requires a real IMAP server 70 + // This is tested manually and in integration tests 71 + 72 + func TestReloadScreener(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 + screenedInPath := filepath.Join(listDir, "screened_in.txt") 80 + if err := os.WriteFile(screenedInPath, []byte("test@example.com\n"), 0600); err != nil { 81 + t.Fatal(err) 82 + } 83 + 84 + for _, name := range []string{"screened_out.txt", "feed.txt", "papertrail.txt", "spam.txt"} { 85 + if err := os.WriteFile(filepath.Join(listDir, name), []byte{}, 0600); err != nil { 86 + t.Fatal(err) 87 + } 88 + } 89 + 90 + cfg := config.Config{ 91 + UI: config.UIConfig{ 92 + BgSyncInterval: 5, 93 + }, 94 + Screener: config.ScreenerConfig{ 95 + ScreenedIn: screenedInPath, 96 + ScreenedOut: filepath.Join(listDir, "screened_out.txt"), 97 + Feed: filepath.Join(listDir, "feed.txt"), 98 + PaperTrail: filepath.Join(listDir, "papertrail.txt"), 99 + Spam: filepath.Join(listDir, "spam.txt"), 100 + }, 101 + } 102 + 103 + sc, err := screener.New(screener.Config{ 104 + ScreenedIn: cfg.Screener.ScreenedIn, 105 + ScreenedOut: cfg.Screener.ScreenedOut, 106 + Feed: cfg.Screener.Feed, 107 + PaperTrail: cfg.Screener.PaperTrail, 108 + Spam: cfg.Screener.Spam, 109 + }) 110 + if err != nil { 111 + t.Fatal(err) 112 + } 113 + 114 + d := New(cfg, &imap.Client{}, sc) 115 + 116 + // Add another email to the list 117 + if err := os.WriteFile(screenedInPath, []byte("test@example.com\nnew@example.com\n"), 0600); err != nil { 118 + t.Fatal(err) 119 + } 120 + 121 + // Reload screener 122 + if err := d.reloadScreener(); err != nil { 123 + t.Fatalf("reloadScreener failed: %v", err) 124 + } 125 + 126 + // Verify new screener has both emails 127 + if d.screener.Classify("new@example.com") != screener.CategoryInbox { 128 + t.Error("reloaded screener should classify new@example.com as Inbox") 129 + } 130 + }
+65
internal/screener/classify.go
··· 1 + package screener 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/sspaeti/neomd/internal/config" 7 + "github.com/sspaeti/neomd/internal/imap" 8 + ) 9 + 10 + // ScreenMove represents a planned (not yet executed) IMAP move for auto-screening. 11 + type ScreenMove struct { 12 + Email *imap.Email 13 + Dst string 14 + } 15 + 16 + // ClassifyForScreen classifies a slice of inbox emails in-memory (O(1) map 17 + // lookups) and returns planned moves. emails must live at least as long as the 18 + // returned moves (pointers into the slice are stored). 19 + func ClassifyForScreen(screener *Screener, emails []imap.Email, folderCfg config.FoldersConfig) ([]ScreenMove, error) { 20 + // Validate screener safety (check that no screening folder points to Trash) 21 + if err := ValidateScreenerSafety(folderCfg); err != nil { 22 + return nil, err 23 + } 24 + 25 + inboxFolder := folderCfg.Inbox 26 + var moves []ScreenMove 27 + for i := range emails { 28 + e := &emails[i] 29 + cat := screener.Classify(e.From) 30 + var dst string 31 + switch cat { 32 + case CategorySpam: 33 + dst = folderCfg.Spam 34 + case CategoryScreenedOut: 35 + dst = folderCfg.ScreenedOut 36 + case CategoryFeed: 37 + dst = folderCfg.Feed 38 + case CategoryPaperTrail: 39 + dst = folderCfg.PaperTrail 40 + case CategoryToScreen: 41 + dst = folderCfg.ToScreen 42 + } 43 + if dst != "" && dst != inboxFolder { 44 + moves = append(moves, ScreenMove{Email: e, Dst: dst}) 45 + } 46 + } 47 + return moves, nil 48 + } 49 + 50 + // ValidateScreenerSafety ensures that no screener destination folder points to Trash. 51 + func ValidateScreenerSafety(folderCfg config.FoldersConfig) error { 52 + dests := map[string]string{ 53 + "ToScreen": folderCfg.ToScreen, 54 + "ScreenedOut": folderCfg.ScreenedOut, 55 + "Feed": folderCfg.Feed, 56 + "PaperTrail": folderCfg.PaperTrail, 57 + "Spam": folderCfg.Spam, 58 + } 59 + for name, folder := range dests { 60 + if folder != "" && folder == folderCfg.Trash { 61 + return fmt.Errorf("unsafe folder config: %s points to Trash (%s); refusing to screen until config is fixed", name, folder) 62 + } 63 + } 64 + return nil 65 + }
+269
internal/screener/classify_test.go
··· 1 + package screener 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "testing" 7 + 8 + "github.com/sspaeti/neomd/internal/config" 9 + "github.com/sspaeti/neomd/internal/imap" 10 + ) 11 + 12 + func TestClassifyForScreen(t *testing.T) { 13 + tmpDir := t.TempDir() 14 + 15 + // Create test screener lists 16 + screenedInPath := filepath.Join(tmpDir, "screened_in.txt") 17 + screenedOutPath := filepath.Join(tmpDir, "screened_out.txt") 18 + feedPath := filepath.Join(tmpDir, "feed.txt") 19 + papertrailPath := filepath.Join(tmpDir, "papertrail.txt") 20 + spamPath := filepath.Join(tmpDir, "spam.txt") 21 + 22 + if err := os.WriteFile(screenedInPath, []byte("approved@example.com\n"), 0600); err != nil { 23 + t.Fatal(err) 24 + } 25 + if err := os.WriteFile(screenedOutPath, []byte("blocked@example.com\n"), 0600); err != nil { 26 + t.Fatal(err) 27 + } 28 + if err := os.WriteFile(feedPath, []byte("newsletter@example.com\n"), 0600); err != nil { 29 + t.Fatal(err) 30 + } 31 + if err := os.WriteFile(papertrailPath, []byte("receipts@example.com\n"), 0600); err != nil { 32 + t.Fatal(err) 33 + } 34 + if err := os.WriteFile(spamPath, []byte("spam@example.com\n"), 0600); err != nil { 35 + t.Fatal(err) 36 + } 37 + 38 + sc, err := New(Config{ 39 + ScreenedIn: screenedInPath, 40 + ScreenedOut: screenedOutPath, 41 + Feed: feedPath, 42 + PaperTrail: papertrailPath, 43 + Spam: spamPath, 44 + }) 45 + if err != nil { 46 + t.Fatal(err) 47 + } 48 + 49 + folderCfg := config.FoldersConfig{ 50 + Inbox: "INBOX", 51 + ToScreen: "ToScreen", 52 + ScreenedOut: "ScreenedOut", 53 + Feed: "Feed", 54 + PaperTrail: "PaperTrail", 55 + Spam: "Spam", 56 + Trash: "Trash", 57 + } 58 + 59 + tests := []struct { 60 + name string 61 + emails []imap.Email 62 + expectedMoves int 63 + expectedDsts []string 64 + }{ 65 + { 66 + name: "screened out email", 67 + emails: []imap.Email{ 68 + {UID: 1, From: "blocked@example.com", Subject: "Test"}, 69 + }, 70 + expectedMoves: 1, 71 + expectedDsts: []string{"ScreenedOut"}, 72 + }, 73 + { 74 + name: "feed email", 75 + emails: []imap.Email{ 76 + {UID: 2, From: "newsletter@example.com", Subject: "News"}, 77 + }, 78 + expectedMoves: 1, 79 + expectedDsts: []string{"Feed"}, 80 + }, 81 + { 82 + name: "papertrail email", 83 + emails: []imap.Email{ 84 + {UID: 3, From: "receipts@example.com", Subject: "Receipt"}, 85 + }, 86 + expectedMoves: 1, 87 + expectedDsts: []string{"PaperTrail"}, 88 + }, 89 + { 90 + name: "spam email", 91 + emails: []imap.Email{ 92 + {UID: 4, From: "spam@example.com", Subject: "Spam"}, 93 + }, 94 + expectedMoves: 1, 95 + expectedDsts: []string{"Spam"}, 96 + }, 97 + { 98 + name: "approved email (stays in inbox)", 99 + emails: []imap.Email{ 100 + {UID: 5, From: "approved@example.com", Subject: "Good"}, 101 + }, 102 + expectedMoves: 0, 103 + expectedDsts: []string{}, 104 + }, 105 + { 106 + name: "unknown sender (moves to ToScreen)", 107 + emails: []imap.Email{ 108 + {UID: 6, From: "unknown@example.com", Subject: "Unknown"}, 109 + }, 110 + expectedMoves: 1, 111 + expectedDsts: []string{"ToScreen"}, 112 + }, 113 + { 114 + name: "mixed batch", 115 + emails: []imap.Email{ 116 + {UID: 7, From: "blocked@example.com", Subject: "1"}, 117 + {UID: 8, From: "approved@example.com", Subject: "2"}, 118 + {UID: 9, From: "newsletter@example.com", Subject: "3"}, 119 + {UID: 10, From: "spam@example.com", Subject: "4"}, 120 + {UID: 11, From: "unknown@example.com", Subject: "5"}, 121 + }, 122 + expectedMoves: 4, 123 + expectedDsts: []string{"ScreenedOut", "Feed", "Spam", "ToScreen"}, 124 + }, 125 + } 126 + 127 + for _, tt := range tests { 128 + t.Run(tt.name, func(t *testing.T) { 129 + moves, err := ClassifyForScreen(sc, tt.emails, folderCfg) 130 + if err != nil { 131 + t.Fatalf("ClassifyForScreen failed: %v", err) 132 + } 133 + 134 + if len(moves) != tt.expectedMoves { 135 + t.Errorf("expected %d moves, got %d", tt.expectedMoves, len(moves)) 136 + } 137 + 138 + for i, mv := range moves { 139 + if i >= len(tt.expectedDsts) { 140 + t.Errorf("unexpected move %d: %s -> %s", i, mv.Email.From, mv.Dst) 141 + continue 142 + } 143 + if mv.Dst != tt.expectedDsts[i] { 144 + t.Errorf("move %d: expected dst=%s, got %s", i, tt.expectedDsts[i], mv.Dst) 145 + } 146 + } 147 + }) 148 + } 149 + } 150 + 151 + func TestValidateScreenerSafety(t *testing.T) { 152 + tests := []struct { 153 + name string 154 + folderCfg config.FoldersConfig 155 + expectError bool 156 + }{ 157 + { 158 + name: "safe config", 159 + folderCfg: config.FoldersConfig{ 160 + Inbox: "INBOX", 161 + ToScreen: "ToScreen", 162 + ScreenedOut: "ScreenedOut", 163 + Feed: "Feed", 164 + PaperTrail: "PaperTrail", 165 + Spam: "Spam", 166 + Trash: "Trash", 167 + }, 168 + expectError: false, 169 + }, 170 + { 171 + name: "ToScreen points to Trash", 172 + folderCfg: config.FoldersConfig{ 173 + ToScreen: "Trash", 174 + Trash: "Trash", 175 + }, 176 + expectError: true, 177 + }, 178 + { 179 + name: "ScreenedOut points to Trash", 180 + folderCfg: config.FoldersConfig{ 181 + ScreenedOut: "Trash", 182 + Trash: "Trash", 183 + }, 184 + expectError: true, 185 + }, 186 + { 187 + name: "Feed points to Trash", 188 + folderCfg: config.FoldersConfig{ 189 + Feed: "Trash", 190 + Trash: "Trash", 191 + }, 192 + expectError: true, 193 + }, 194 + { 195 + name: "PaperTrail points to Trash", 196 + folderCfg: config.FoldersConfig{ 197 + PaperTrail: "Trash", 198 + Trash: "Trash", 199 + }, 200 + expectError: true, 201 + }, 202 + { 203 + name: "Spam points to Trash", 204 + folderCfg: config.FoldersConfig{ 205 + Spam: "Trash", 206 + Trash: "Trash", 207 + }, 208 + expectError: true, 209 + }, 210 + } 211 + 212 + for _, tt := range tests { 213 + t.Run(tt.name, func(t *testing.T) { 214 + err := ValidateScreenerSafety(tt.folderCfg) 215 + if tt.expectError && err == nil { 216 + t.Error("expected error, got nil") 217 + } 218 + if !tt.expectError && err != nil { 219 + t.Errorf("expected no error, got: %v", err) 220 + } 221 + }) 222 + } 223 + } 224 + 225 + func TestClassifyForScreen_EmptyInbox(t *testing.T) { 226 + tmpDir := t.TempDir() 227 + 228 + screenedInPath := filepath.Join(tmpDir, "screened_in.txt") 229 + if err := os.WriteFile(screenedInPath, []byte("test@example.com\n"), 0600); err != nil { 230 + t.Fatal(err) 231 + } 232 + 233 + for _, name := range []string{"screened_out.txt", "feed.txt", "papertrail.txt", "spam.txt"} { 234 + if err := os.WriteFile(filepath.Join(tmpDir, name), []byte{}, 0600); err != nil { 235 + t.Fatal(err) 236 + } 237 + } 238 + 239 + sc, err := New(Config{ 240 + ScreenedIn: screenedInPath, 241 + ScreenedOut: filepath.Join(tmpDir, "screened_out.txt"), 242 + Feed: filepath.Join(tmpDir, "feed.txt"), 243 + PaperTrail: filepath.Join(tmpDir, "papertrail.txt"), 244 + Spam: filepath.Join(tmpDir, "spam.txt"), 245 + }) 246 + if err != nil { 247 + t.Fatal(err) 248 + } 249 + 250 + folderCfg := config.FoldersConfig{ 251 + Inbox: "INBOX", 252 + ToScreen: "ToScreen", 253 + ScreenedOut: "ScreenedOut", 254 + Feed: "Feed", 255 + PaperTrail: "PaperTrail", 256 + Spam: "Spam", 257 + Trash: "Trash", 258 + } 259 + 260 + // Empty inbox should return no moves 261 + moves, err := ClassifyForScreen(sc, []imap.Email{}, folderCfg) 262 + if err != nil { 263 + t.Fatalf("ClassifyForScreen failed: %v", err) 264 + } 265 + 266 + if len(moves) != 0 { 267 + t.Errorf("expected 0 moves for empty inbox, got %d", len(moves)) 268 + } 269 + }
+8 -35
internal/ui/model.go
··· 995 995 return paths, nil 996 996 } 997 997 998 + // validateScreenerSafety wraps the shared screener validation logic. 998 999 func (m Model) validateScreenerSafety() error { 999 - dests := map[string]string{ 1000 - "ToScreen": m.cfg.Folders.ToScreen, 1001 - "ScreenedOut": m.cfg.Folders.ScreenedOut, 1002 - "Feed": m.cfg.Folders.Feed, 1003 - "PaperTrail": m.cfg.Folders.PaperTrail, 1004 - "Spam": m.cfg.Folders.Spam, 1005 - } 1006 - for name, folder := range dests { 1007 - if folder != "" && folder == m.cfg.Folders.Trash { 1008 - return fmt.Errorf("unsafe folder config: %s points to Trash (%s); refusing to screen until config is fixed", name, folder) 1009 - } 1010 - } 1011 - return nil 1000 + return screener.ValidateScreenerSafety(m.cfg.Folders) 1012 1001 } 1013 1002 1014 1003 func (m Model) inboxPageStep() int { ··· 1272 1261 // lookups) and returns planned moves. emails must live at least as long as the 1273 1262 // returned moves (pointers into the slice are stored). 1274 1263 func (m Model) classifyForScreen(emails []imap.Email) []autoScreenMove { 1275 - if m.validateScreenerSafety() != nil { 1264 + screenMoves, err := screener.ClassifyForScreen(m.screener, emails, m.cfg.Folders) 1265 + if err != nil { 1276 1266 return nil 1277 1267 } 1278 - inboxFolder := m.cfg.Folders.Inbox 1279 - var moves []autoScreenMove 1280 - for i := range emails { 1281 - e := &emails[i] 1282 - cat := m.screener.Classify(e.From) 1283 - var dst string 1284 - switch cat { 1285 - case screener.CategorySpam: 1286 - dst = m.cfg.Folders.Spam 1287 - case screener.CategoryScreenedOut: 1288 - dst = m.cfg.Folders.ScreenedOut 1289 - case screener.CategoryFeed: 1290 - dst = m.cfg.Folders.Feed 1291 - case screener.CategoryPaperTrail: 1292 - dst = m.cfg.Folders.PaperTrail 1293 - case screener.CategoryToScreen: 1294 - dst = m.cfg.Folders.ToScreen 1295 - } 1296 - if dst != "" && dst != inboxFolder { 1297 - moves = append(moves, autoScreenMove{email: e, dst: dst}) 1298 - } 1268 + // Convert screener.ScreenMove to UI autoScreenMove 1269 + moves := make([]autoScreenMove, len(screenMoves)) 1270 + for i, sm := range screenMoves { 1271 + moves[i] = autoScreenMove{email: sm.Email, dst: sm.Dst} 1299 1272 } 1300 1273 return moves 1301 1274 }