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.

Merge pull request #11 from ssp-data/headless-sync

Feature: Add headless option to run on server to sync

authored by

Simon Späti and committed by
GitHub
b3d4a419 28a04c26

+1296 -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
+5
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 + - **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 + - **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 + 3 8 # 2026-04-17 4 9 - **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 10 - **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
+435
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 + ### What Gets Synced 75 + 76 + **Screener list directory**: `~/.config/neomd/lists/` - you can also sync the entire `neomd` folder, if you don't have passwords stored in there, only ENVs: 77 + - `screened_in.txt` 78 + - `screened_out.txt` 79 + - `feed.txt` 80 + - `papertrail.txt` 81 + - `spam.txt` 82 + 83 + ### Step-by-Step Setup 84 + 85 + #### 1. Install Syncthing 86 + 87 + **On Arch Linux / Server:** 88 + ```sh 89 + sudo pacman -S syncthing 90 + systemctl --user enable syncthing 91 + systemctl --user start syncthing 92 + ``` 93 + 94 + **On other systems**: See [Syncthing installation docs](https://docs.syncthing.net/intro/getting-started.html) 95 + 96 + > **Note**: Use `systemctl --user` instead of adding to window manager autostart scripts. This ensures Syncthing runs on login, works across different environments, and continues running independently of your desktop session. 97 + 98 + #### 2. Access Web UI 99 + 100 + Open http://localhost:8384 on each device 101 + 102 + #### 3. Connect Devices 103 + 104 + **On Device A (e.g., your laptop):** 105 + 1. Go to **Actions** → **Show ID** to get your Device ID 106 + 2. Copy the long alphanumeric Device ID 107 + 108 + **On Device B (e.g., your server):** 109 + 1. Click **Add Remote Device** 110 + 2. Paste Device A's Device ID 111 + 3. Name it (e.g., "laptop") 112 + 4. Click **Save** 113 + 114 + **Back on Device A:** 115 + 1. Accept the connection notification 116 + 2. Name Device B (e.g., "server") 117 + 3. Click **Save** 118 + 119 + Repeat for all devices (laptop, server, Android). 120 + 121 + #### 4. Create Shared Folder 122 + 123 + **On one device (e.g., server):** 124 + 1. Click **Add Folder** 125 + 2. Set **Folder Label**: `neomd-lists` 126 + 3. Set **Folder ID**: `neomd-lists` (same on all devices) 127 + 4. Set **Folder Path**: `/home/user/.config/neomd/lists/` (or `~/.config/neomd/` if syncing entire folder) 128 + 5. Go to **Sharing** tab → check all other devices 129 + 6. Go to **File Versioning** tab: 130 + - Select **Simple File Versioning** 131 + - Keep Versions: `5` 132 + 7. Click **Save** 133 + 134 + **On other devices:** 135 + 1. Accept the folder share notification 136 + 2. Verify/set the correct path for that device 137 + 3. Enable **File Versioning** (same as above) 138 + 4. Click **Save** 139 + 140 + #### 5. Backup First (Important!) 141 + 142 + Before syncing existing data, **backup your lists**: 143 + 144 + ```sh 145 + cp -r ~/.config/neomd/lists ~/.config/neomd/lists.backup-$(date +%Y%m%d) 146 + ``` 147 + 148 + #### 6. Wait for Initial Sync 149 + 150 + Watch the folder status in the web UI. It will show "Syncing" with progress, then "Up to Date" when complete. 151 + 152 + ### Server Setup (FreeBSD / No GUI) 153 + 154 + If running neomd headless on a FreeBSD server without a desktop environment, use SSH port forwarding to access the Syncthing web UI: 155 + 156 + #### 1. Start Syncthing on FreeBSD 157 + 158 + ```sh 159 + # Enable and start as system service 160 + sudo sysrc syncthing_enable="YES" 161 + sudo sysrc syncthing_user="sspaeti" 162 + sudo service syncthing start 163 + 164 + # Or run as user service (no sudo) 165 + syncthing & 166 + 167 + # Or with nohup for persistent operation 168 + nohup syncthing > ~/syncthing.log 2>&1 & 169 + ``` 170 + 171 + Check it's running: 172 + ```sh 173 + ps aux | grep syncthing 174 + ``` 175 + 176 + #### 2. SSH Port Forwarding 177 + 178 + From your **local machine** (laptop/desktop with browser), create an SSH tunnel: 179 + 180 + ```sh 181 + ssh -L 8385:localhost:8384 your-server 182 + ``` 183 + 184 + This forwards `localhost:8385` on your local machine to `localhost:8384` on the server. 185 + 186 + Now open in your **local browser**: http://localhost:8385 187 + 188 + You'll see the server's Syncthing web UI! 189 + 190 + #### 3. Get Server Device ID 191 + 192 + In the web UI at http://localhost:8385: 193 + 1. Go to **Actions** → **Show ID** 194 + 2. Copy the Device ID 195 + 196 + #### 4. Connect Your Devices 197 + 198 + **On your local machine's Syncthing** (http://localhost:8384): 199 + 1. Click **Add Remote Device** 200 + 2. Paste the server's Device ID 201 + 3. Name it (e.g., "freebsd-server") 202 + 4. Click **Save** 203 + 204 + **On the server's UI** (http://localhost:8385 via SSH tunnel): 205 + 1. Accept the connection notification 206 + 2. Name your local device (e.g., "laptop") 207 + 3. Click **Save** 208 + 209 + #### 5. Share the Folder 210 + 211 + **On your local machine** (http://localhost:8384): 212 + 1. Find your existing `neomd-lists` folder 213 + 2. Click **Edit** 214 + 3. Go to **Sharing** tab 215 + 4. Check the box next to your server device 216 + 5. Click **Save** 217 + 218 + **On the server** (http://localhost:8385): 219 + 1. Accept the folder share notification 220 + 2. Set **Folder Path**: `/home/user/.config/neomd/lists/` (or `~/.config/neomd/` if syncing entire folder) 221 + 3. Go to **File Versioning** tab: 222 + - Select **Simple File Versioning** 223 + - Keep Versions: `5` 224 + 4. Click **Save** 225 + 226 + #### 6. Handle Existing Files 227 + 228 + Before syncing, **backup the server's existing lists**: 229 + 230 + ```sh 231 + # On server 232 + cp -r ~/.config/neomd/lists ~/.config/neomd/lists.backup-$(date +%Y%m%d) 233 + ``` 234 + 235 + Syncthing will merge files from both sides. To **start fresh from your local machine's data**: 236 + 237 + ```sh 238 + # On server - remove existing files (after backup!) 239 + rm -rf ~/.config/neomd/lists/* 240 + ``` 241 + 242 + #### 7. Close SSH Tunnel 243 + 244 + Once setup is complete, you can close the SSH tunnel (Ctrl+C in the SSH session). Devices will continue syncing in the background. 245 + 246 + For future configuration changes, create the SSH tunnel again when needed: 247 + ```sh 248 + ssh -L 8385:localhost:8384 your-server 249 + ``` 250 + 251 + ### Verify Sync is Working 252 + 253 + ```sh 254 + # Check files exist 255 + ls -la ~/.config/neomd/lists/ 256 + 257 + # Watch real-time sync in logs 258 + journalctl --user -u syncthing -f 259 + ``` 260 + 261 + The daemon watches for file changes and reloads screener lists automatically when Syncthing updates them. 262 + 263 + ### Conflict Handling 264 + 265 + - **File-level conflicts**: Syncthing creates `.sync-conflict-*` files if two devices modify the same file simultaneously 266 + - **Email-level**: IMAP is the source of truth; no local email state to conflict 267 + - **Screener lists**: Append-only operations are safe; duplicates are harmless (normalized automatically) 268 + 269 + Check for conflicts periodically: 270 + ```sh 271 + find ~/.config/neomd/lists -name "*.sync-conflict-*" 272 + ``` 273 + 274 + ## Systemd Service (Optional) 275 + 276 + For servers with systemd, you can create a service unit for automatic startup and logging: 277 + 278 + ```ini 279 + # /etc/systemd/user/neomd.service 280 + [Unit] 281 + Description=Neomd Headless Email Screener 282 + After=network.target 283 + 284 + [Service] 285 + Type=simple 286 + ExecStart=%h/.local/bin/neomd --headless 287 + Restart=on-failure 288 + RestartSec=10 289 + StandardOutput=journal 290 + StandardError=journal 291 + 292 + [Install] 293 + WantedBy=default.target 294 + ``` 295 + 296 + Enable and start the service: 297 + 298 + ```sh 299 + # Install neomd to ~/.local/bin 300 + make install 301 + 302 + # Reload systemd 303 + systemctl --user daemon-reload 304 + 305 + # Enable auto-start on login 306 + systemctl --user enable neomd 307 + 308 + # Start now 309 + systemctl --user start neomd 310 + 311 + # Check status 312 + systemctl --user status neomd 313 + 314 + # View logs 315 + journalctl --user -u neomd -f 316 + ``` 317 + 318 + ## Monitoring 319 + 320 + ### View Logs 321 + 322 + If running with `nohup` or redirected output: 323 + 324 + ```sh 325 + tail -f /var/log/neomd.log 326 + ``` 327 + 328 + If running as systemd service: 329 + 330 + ```sh 331 + journalctl --user -u neomd -f 332 + ``` 333 + 334 + ### Log Format 335 + 336 + The daemon logs structured output with timestamps: 337 + 338 + ``` 339 + time=2025-04-18T10:00:00Z level=INFO msg="neomd daemon starting" version=headless 340 + time=2025-04-18T10:00:00Z level=INFO msg="screening interval configured" minutes=5 341 + time=2025-04-18T10:00:00Z level=INFO msg="watching directory for changes" dir=/home/user/.config/neomd/lists 342 + time=2025-04-18T10:00:00Z level=INFO msg="daemon running" interval=5m0s 343 + time=2025-04-18T10:00:05Z level=INFO msg="running initial screening" 344 + time=2025-04-18T10:00:05Z level=INFO msg="fetched inbox emails" count=42 345 + time=2025-04-18T10:00:05Z level=INFO msg="emails to screen" count=12 346 + time=2025-04-18T10:00:05Z level=INFO msg="screened email" index=1 total=12 from="newsletter@example.com" subject="Weekly Update" dst=Feed 347 + ... 348 + time=2025-04-18T10:00:06Z level=INFO msg="screening complete" moved=12 total=12 349 + ``` 350 + 351 + ### Graceful Shutdown 352 + 353 + Send SIGTERM or SIGINT to stop the daemon: 354 + 355 + ```sh 356 + # If running in foreground 357 + Ctrl+C 358 + 359 + # If running in background 360 + kill <pid> 361 + 362 + # With systemd 363 + systemctl --user stop neomd 364 + ``` 365 + 366 + The daemon will finish the current screening operation before exiting. 367 + 368 + ## Troubleshooting 369 + 370 + ### Daemon exits immediately 371 + 372 + Check that `bg_sync_interval` is set to a value > 0: 373 + 374 + ```sh 375 + grep bg_sync_interval ~/.config/neomd/config.toml 376 + ``` 377 + 378 + ### Screener lists not reloading 379 + 380 + Check file watcher logs: 381 + 382 + ```sh 383 + tail -f /var/log/neomd.log | grep "watching directory" 384 + ``` 385 + 386 + Verify Syncthing is running and syncing: 387 + 388 + ```sh 389 + # Check Syncthing web UI (usually http://localhost:8384) 390 + ``` 391 + 392 + ### Emails not being screened 393 + 394 + 1. Check daemon is running: `ps aux | grep neomd` 395 + 2. Check IMAP connection in logs 396 + 3. Verify screener list files exist and contain email addresses 397 + 4. Check folder configuration in config.toml 398 + 399 + ### Duplicate screening 400 + 401 + If emails are being moved twice (once by daemon, once by TUI): 402 + 403 + - Set `bg_sync_interval = 0` on TUI devices 404 + - Only run one daemon instance per account 405 + 406 + ## Android Termux Example 407 + 408 + >[!NOTE] 409 + > See Android Termux Setup at [Android Docs](android.md) 410 + 411 + On Android, you can run the daemon in a Termux session: 412 + 413 + ```sh 414 + # Install Termux:Boot from F-Droid to auto-start on device boot 415 + pkg install termux-boot 416 + 417 + # Create boot script 418 + mkdir -p ~/.termux/boot 419 + cat > ~/.termux/boot/neomd-daemon.sh <<'EOF' 420 + #!/data/data/com.termux/files/usr/bin/bash 421 + cd ~/neomd 422 + nohup ./neomd --headless >> ~/neomd-daemon.log 2>&1 & 423 + EOF 424 + chmod +x ~/.termux/boot/neomd-daemon.sh 425 + 426 + # Reboot device to auto-start daemon 427 + ``` 428 + 429 + ## Notes 430 + 431 + - The daemon only **reads** screener list files and **moves** emails via IMAP 432 + - All sender classification (adding to lists) happens in the TUI 433 + - File watching requires the screener list directory to exist 434 + - The daemon uses the first configured account from `config.toml` 435 + - 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=
+233
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 (if screener lists are not empty) 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 + // 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 + 169 + inboxFolder := d.cfg.Folders.Inbox 170 + 171 + // Fetch inbox headers (0 means fetch all) 172 + emails, err := d.imapCli.FetchHeaders(ctx, inboxFolder, 0) 173 + if err != nil { 174 + return fmt.Errorf("fetch inbox headers: %w", err) 175 + } 176 + 177 + if len(emails) == 0 { 178 + d.logger.Info("inbox is empty, nothing to screen") 179 + return nil 180 + } 181 + 182 + d.logger.Info("fetched inbox emails", "count", len(emails)) 183 + 184 + // Classify emails using shared screener logic 185 + moves, err := screener.ClassifyForScreen(d.screener, emails, d.cfg.Folders) 186 + if err != nil { 187 + return fmt.Errorf("classify emails: %w", err) 188 + } 189 + 190 + if len(moves) == 0 { 191 + d.logger.Info("no emails need screening") 192 + return nil 193 + } 194 + 195 + d.logger.Info("emails to screen", "count", len(moves)) 196 + 197 + // Execute moves 198 + movedCount := 0 199 + for i, mv := range moves { 200 + uid := mv.Email.UID 201 + from := mv.Email.From 202 + subject := mv.Email.Subject 203 + dst := mv.Dst 204 + 205 + if err := ctx.Err(); err != nil { 206 + d.logger.Warn("screening interrupted", "moved", movedCount, "total", len(moves)) 207 + return err 208 + } 209 + 210 + _, err := d.imapCli.MoveMessage(ctx, inboxFolder, uid, dst) 211 + if err != nil { 212 + d.logger.Error("failed to move email", 213 + "index", i+1, 214 + "uid", uid, 215 + "from", from, 216 + "subject", subject, 217 + "dst", dst, 218 + "error", err) 219 + continue 220 + } 221 + 222 + movedCount++ 223 + d.logger.Info("screened email", 224 + "index", i+1, 225 + "total", len(moves), 226 + "from", from, 227 + "subject", subject, 228 + "dst", dst) 229 + } 230 + 231 + d.logger.Info("screening complete", "moved", movedCount, "total", len(moves)) 232 + return nil 233 + }
+196
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 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 + 138 + func TestReloadScreener(t *testing.T) { 139 + tmpDir := t.TempDir() 140 + listDir := filepath.Join(tmpDir, "lists") 141 + if err := os.MkdirAll(listDir, 0755); err != nil { 142 + t.Fatal(err) 143 + } 144 + 145 + screenedInPath := filepath.Join(listDir, "screened_in.txt") 146 + if err := os.WriteFile(screenedInPath, []byte("test@example.com\n"), 0600); err != nil { 147 + t.Fatal(err) 148 + } 149 + 150 + for _, name := range []string{"screened_out.txt", "feed.txt", "papertrail.txt", "spam.txt"} { 151 + if err := os.WriteFile(filepath.Join(listDir, name), []byte{}, 0600); err != nil { 152 + t.Fatal(err) 153 + } 154 + } 155 + 156 + cfg := config.Config{ 157 + UI: config.UIConfig{ 158 + BgSyncInterval: 5, 159 + }, 160 + Screener: config.ScreenerConfig{ 161 + ScreenedIn: screenedInPath, 162 + ScreenedOut: filepath.Join(listDir, "screened_out.txt"), 163 + Feed: filepath.Join(listDir, "feed.txt"), 164 + PaperTrail: filepath.Join(listDir, "papertrail.txt"), 165 + Spam: filepath.Join(listDir, "spam.txt"), 166 + }, 167 + } 168 + 169 + sc, err := screener.New(screener.Config{ 170 + ScreenedIn: cfg.Screener.ScreenedIn, 171 + ScreenedOut: cfg.Screener.ScreenedOut, 172 + Feed: cfg.Screener.Feed, 173 + PaperTrail: cfg.Screener.PaperTrail, 174 + Spam: cfg.Screener.Spam, 175 + }) 176 + if err != nil { 177 + t.Fatal(err) 178 + } 179 + 180 + d := New(cfg, &imap.Client{}, sc) 181 + 182 + // Add another email to the list 183 + if err := os.WriteFile(screenedInPath, []byte("test@example.com\nnew@example.com\n"), 0600); err != nil { 184 + t.Fatal(err) 185 + } 186 + 187 + // Reload screener 188 + if err := d.reloadScreener(); err != nil { 189 + t.Fatalf("reloadScreener failed: %v", err) 190 + } 191 + 192 + // Verify new screener has both emails 193 + if d.screener.Classify("new@example.com") != screener.CategoryInbox { 194 + t.Error("reloaded screener should classify new@example.com as Inbox") 195 + } 196 + }
+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 }
+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"