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.

fix review

+190 -15
+5
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + # 2026-05-04 4 + - **Fix: `[notifications].folders` allowlist now matches custom IMAP folder names** — the allowlist compared user-configured labels (e.g. `"PaperTrail"`) against runtime IMAP folder names (e.g. `"HEY/Paper Trail"` or `"[Gmail]/All Mail"`) and silently dropped notifications when the two differed. Folders are now normalised to their UI label via a new `FoldersConfig.LabelFor()` helper before the allowlist check, so users with non-default IMAP folder names get notifications correctly. Regression test added in `internal/config/config_test.go` 5 + - **Fix: notifier can no longer freeze the TUI** — `Send` now runs `notify-send` (or whatever `[notifications].command` points to) under a 2-second `context.WithTimeout` plus a 500 ms `cmd.WaitDelay`. A hung notification daemon (broken DBus, mako restarting, …) returns a clear `notify-send: timed out after 2s` status instead of blocking the bubbletea Update loop. Test exercises the timeout path with a script that sleeps 60 s 6 + - **Fix: screener lists now strip inline `# comments`** — `loadList` previously only skipped full-line comments, so `@ssp.sh # everyone at ssp.sh` (as shown in the docs) stored the entire line as the entry and never matched. The loader now strips everything after the first `#` (after trimming) so inline comments work as documented in any screener list (`screened_in`, `feed`, `notify`, …) 7 + 3 8 # 2026-05-03 4 9 - **Desktop notifications for VIP senders** — new opt-in `[notifications]` config block fires `notify-send` (or any `notify-send`-compatible command) only for senders explicitly listed in `~/.config/neomd/lists/notify.txt`; independent of the screener categories so being "screened in" does not automatically mean "notify me", and a sender on a Feed/PaperTrail list can still page you when their mail arrives. Defaults: `enabled = false`, `command = "notify-send"`, `icon = "mail-message-new"`, `expire_ms = 5000`, `folders = ["Inbox"]`. First fetch silently records a per-folder UID baseline at `~/.cache/neomd/notify_state.json` so the existing inbox does not flood you on enable. Status bar reports activity ("Notified N VIP sender(s) in Inbox") so you can verify the pipeline is wired up. TUI-only — the headless daemon never fires notifications so a NAS does not pop popups no one will see. Hooks into background sync (every `bg_sync_interval` minutes) and into manual folder loads, so notifications also fire when the daemon screens a VIP email out of Inbox before the TUI sees it (the TUI then catches it on the destination folder load). Documented in `docs/content/docs/notifications.md` including a wrapper-script recipe for `hyprctl notify` [more](https://neomd.ssp.sh/docs/notifications/) 5 10 - **Whole-domain screening** — any screener list line beginning with `@` (e.g. `@ssp.sh`) now matches every address at that domain; works in `screened_in.txt`, `screened_out.txt`, `feed.txt`, `papertrail.txt`, `spam.txt`, and `notify.txt`. Per-address entries always win over a `@domain` entry across all categories so a single blocked address inside an otherwise-approved domain stays blocked (priority order preserved: spam > out > feed > papertrail > in). New `Di` / `Do` chord (works in inbox and reader) appends `@<domain>` of the cursor or open email's sender to `screened_in.txt` / `screened_out.txt` after a `y/n` confirmation; complements the existing `I` / `O` per-address keys. Domain entries can also be added to the other lists by hand-editing — the matching is shared
+40
internal/config/config.go
··· 116 116 "work": "Work", 117 117 } 118 118 119 + // LabelFor returns the UI label (e.g. "Inbox", "PaperTrail") for a configured 120 + // IMAP folder name. Useful for matching user-facing config (which uses labels) 121 + // against runtime values (which use IMAP names — they may differ, e.g. Gmail's 122 + // "[Gmail]/All Mail" or HEY's "HEY/Paper Trail"). Returns the input 123 + // unchanged if no mapping exists, so the caller can fall back to direct 124 + // string comparison. 125 + func (f FoldersConfig) LabelFor(imapName string) string { 126 + switch imapName { 127 + case f.Inbox: 128 + return "Inbox" 129 + case f.Sent: 130 + return "Sent" 131 + case f.Trash: 132 + return "Trash" 133 + case f.Drafts: 134 + return "Drafts" 135 + case f.ToScreen: 136 + return "ToScreen" 137 + case f.Feed: 138 + return "Feed" 139 + case f.PaperTrail: 140 + return "PaperTrail" 141 + case f.ScreenedOut: 142 + return "ScreenedOut" 143 + case f.Archive: 144 + return "Archive" 145 + case f.Waiting: 146 + return "Waiting" 147 + case f.Scheduled: 148 + return "Scheduled" 149 + case f.Someday: 150 + return "Someday" 151 + case f.Spam: 152 + return "Spam" 153 + case f.Work: 154 + return "Work" 155 + } 156 + return imapName 157 + } 158 + 119 159 // TabLabels returns the UI label names in tab display order. 120 160 // tab_order keys (e.g. "inbox", "to_screen") are resolved to label names 121 161 // (e.g. "Inbox", "ToScreen") that activeFolder() and keyboard shortcuts match against.
+39
internal/config/config_test.go
··· 176 176 } 177 177 } 178 178 179 + func TestLabelFor(t *testing.T) { 180 + // Custom IMAP folder names (e.g. HEY-style labels). 181 + fc := FoldersConfig{ 182 + Inbox: "INBOX", 183 + PaperTrail: "HEY/Paper Trail", 184 + Feed: "Newsletters", 185 + Sent: "Sent Items", 186 + } 187 + tests := []struct { 188 + imap, want string 189 + }{ 190 + {"INBOX", "Inbox"}, 191 + {"HEY/Paper Trail", "PaperTrail"}, 192 + {"Newsletters", "Feed"}, 193 + {"Sent Items", "Sent"}, 194 + {"unknown-folder", "unknown-folder"}, // pass-through fallback 195 + } 196 + for _, tt := range tests { 197 + if got := fc.LabelFor(tt.imap); got != tt.want { 198 + t.Errorf("LabelFor(%q) = %q, want %q", tt.imap, got, tt.want) 199 + } 200 + } 201 + } 202 + 203 + func TestFolderAllowed_MatchesLabelAfterCustomIMAPName(t *testing.T) { 204 + // Regression: when notifications.folders = ["PaperTrail"] but 205 + // folders.papertrail = "HEY/Paper Trail", the caller must convert the 206 + // IMAP name to the label before calling FolderAllowed. 207 + fc := FoldersConfig{PaperTrail: "HEY/Paper Trail"} 208 + nc := NotificationsConfig{Folders: []string{"PaperTrail"}} 209 + 210 + if nc.FolderAllowed("HEY/Paper Trail") { 211 + t.Error("FolderAllowed should not match an IMAP name directly — caller must normalise to label first") 212 + } 213 + if !nc.FolderAllowed(fc.LabelFor("HEY/Paper Trail")) { 214 + t.Error("FolderAllowed should match after caller normalises via LabelFor") 215 + } 216 + } 217 + 179 218 func TestLoad_MissingConfigCreatesDefault(t *testing.T) { 180 219 dir := t.TempDir() 181 220 path := filepath.Join(dir, "neomd", "config.toml")
+26 -5
internal/notify/notify.go
··· 7 7 8 8 import ( 9 9 "bytes" 10 + "context" 10 11 "fmt" 11 12 "os/exec" 12 13 "strconv" 13 14 "strings" 15 + "time" 14 16 15 17 "github.com/sspaeti/neomd/internal/config" 16 18 "github.com/sspaeti/neomd/internal/imap" 17 19 "github.com/sspaeti/neomd/internal/screener" 18 20 ) 19 21 22 + // sendTimeout caps how long we'll wait for the notification command to 23 + // return. MaybeNotify runs inside the bubbletea Update loop, so a hung 24 + // notify-send (broken DBus, mako restarting, …) would otherwise freeze the 25 + // TUI. notify-send normally returns in single-digit milliseconds; 2 s is a 26 + // generous ceiling that still keeps the UI responsive. 27 + const sendTimeout = 2 * time.Second 28 + 20 29 // Notifier wraps a notification command (notify-send by default) and a 21 30 // resolved-defaults config snapshot. 22 31 type Notifier struct { ··· 34 43 return n != nil && n.cfg.Enabled 35 44 } 36 45 37 - // Send fires a single notification synchronously. Safe to call when disabled 38 - // (returns nil). Returned error wraps the command's stderr so callers can 39 - // surface useful diagnostics in the TUI status bar. 46 + // Send fires a single notification with a hard deadline so it can never 47 + // freeze the bubbletea Update loop. Safe to call when disabled (returns 48 + // nil). Returned error wraps the command's stderr so callers can surface 49 + // useful diagnostics in the TUI status bar. 40 50 func (n *Notifier) Send(title, body string) error { 41 51 if !n.cfg.Enabled { 42 52 return nil ··· 48 58 title, 49 59 truncate(body, 200), 50 60 } 51 - cmd := exec.Command(n.cfg.Command, args...) 61 + ctx, cancel := context.WithTimeout(context.Background(), sendTimeout) 62 + defer cancel() 63 + cmd := exec.CommandContext(ctx, n.cfg.Command, args...) 64 + // WaitDelay ensures Wait() returns shortly after the process is killed 65 + // even if its stdout/stderr pipes are still being held open by a child 66 + // process — without this, a hung notifier could keep cmd.Run() blocked 67 + // long after the deadline expired. 68 + cmd.WaitDelay = 500 * time.Millisecond 52 69 var stderr bytes.Buffer 53 70 cmd.Stderr = &stderr 54 - if err := cmd.Run(); err != nil { 71 + err := cmd.Run() 72 + if ctx.Err() == context.DeadlineExceeded { 73 + return fmt.Errorf("%s: timed out after %s", n.cfg.Command, sendTimeout) 74 + } 75 + if err != nil { 55 76 msg := strings.TrimSpace(stderr.String()) 56 77 if msg == "" { 57 78 return err
+28
internal/notify/notify_test.go
··· 3 3 import ( 4 4 "os" 5 5 "path/filepath" 6 + "strings" 6 7 "testing" 8 + "time" 7 9 8 10 "github.com/sspaeti/neomd/internal/config" 9 11 "github.com/sspaeti/neomd/internal/imap" ··· 151 153 uid, ok := reloaded.Get("acct|Inbox") 152 154 if !ok || uid != 42 { 153 155 t.Errorf("reloaded = (%d, %v), want (42, true)", uid, ok) 156 + } 157 + } 158 + 159 + func TestSend_TimeoutCannotBlockTUI(t *testing.T) { 160 + // Drop a tiny shell script that ignores all arguments and sleeps 161 + // forever; Send must return within the configured timeout instead of 162 + // blocking the bubbletea Update loop indefinitely. 163 + dir := t.TempDir() 164 + hung := filepath.Join(dir, "hung-notifier.sh") 165 + if err := os.WriteFile(hung, []byte("#!/bin/sh\nsleep 60\n"), 0700); err != nil { 166 + t.Fatal(err) 167 + } 168 + n := New(config.NotificationsConfig{Enabled: true, Command: hung, Folders: []string{"Inbox"}}) 169 + 170 + start := time.Now() 171 + err := n.Send("title", "body") 172 + elapsed := time.Since(start) 173 + if err == nil { 174 + t.Fatal("expected timeout error from hung notifier, got nil") 175 + } 176 + if !strings.Contains(err.Error(), "timed out") { 177 + t.Fatalf("error should mention timeout, got: %v", err) 178 + } 179 + // 2s timeout + exec overhead — must finish well under 4s. 180 + if elapsed > 4*time.Second { 181 + t.Errorf("Send blocked for %s, exceeds the 2s timeout ceiling", elapsed) 154 182 } 155 183 } 156 184
+9 -1
internal/screener/screener.go
··· 456 456 return nil 457 457 } 458 458 459 - // loadList reads a one-address-per-line file into a set. 459 + // loadList reads a one-address-per-line file into a set. Full-line and 460 + // inline "# comment" suffixes are stripped — `addr@example.com # note` 461 + // stores `addr@example.com`, matching the documented examples. 460 462 func loadList(path string, m map[string]bool) error { 461 463 f, err := os.Open(path) 462 464 if os.IsNotExist(err) { ··· 471 473 line := strings.TrimSpace(sc.Text()) 472 474 if line == "" || strings.HasPrefix(line, "#") { 473 475 continue 476 + } 477 + if i := strings.IndexByte(line, '#'); i >= 0 { 478 + line = strings.TrimSpace(line[:i]) 479 + if line == "" { 480 + continue 481 + } 474 482 } 475 483 m[normalise(line)] = true 476 484 }
+24
internal/screener/screener_test.go
··· 248 248 } 249 249 }) 250 250 251 + t.Run("New strips inline # comments after entries", func(t *testing.T) { 252 + // The documented example showed `@ssp.sh # everyone at ssp.sh …` — 253 + // loadList must strip the inline comment so the entry actually matches. 254 + dir := t.TempDir() 255 + cfg := makeCfg(dir) 256 + content := "@ssp.sh # everyone at ssp.sh is approved\nalice@example.com # personal contact\n#bob@example.com # full-line still works\n" 257 + if err := os.WriteFile(cfg.ScreenedIn, []byte(content), 0600); err != nil { 258 + t.Fatal(err) 259 + } 260 + s, err := New(cfg) 261 + if err != nil { 262 + t.Fatal(err) 263 + } 264 + if s.Classify("anyone@ssp.sh") != CategoryInbox { 265 + t.Error("@ssp.sh entry with inline comment should match anyone@ssp.sh") 266 + } 267 + if s.Classify("alice@example.com") != CategoryInbox { 268 + t.Error("alice@example.com with trailing comment should match") 269 + } 270 + if s.Classify("bob@example.com") != CategoryToScreen { 271 + t.Error("bob (full-line commented out) should NOT match") 272 + } 273 + }) 274 + 251 275 t.Run("Approve adds to screened_in removes from screened_out and spam", func(t *testing.T) { 252 276 dir := t.TempDir() 253 277 cfg := makeCfg(dir)
+19 -9
internal/ui/model.go
··· 1446 1446 1447 1447 // maybeNotifyInbox dispatches desktop notifications for the freshly fetched 1448 1448 // emails. moves describes where each email is heading after auto-screening 1449 - // (pass nil if no auto-screen pass ran). Folder is the IMAP source folder 1450 - // label the fetch came from. No-op when the notifier is disabled. 1449 + // (pass nil if no auto-screen pass ran). folder is the IMAP source-folder 1450 + // name the fetch came from. No-op when the notifier is disabled. 1451 + // 1452 + // All folder values are converted to UI labels ("Inbox", "PaperTrail", …) 1453 + // before the notifier sees them so [notifications].folders works regardless 1454 + // of whether the user kept default IMAP names or set custom ones (e.g. 1455 + // folders.papertrail = "HEY/Paper Trail"). 1451 1456 // 1452 1457 // Reports activity on the TUI status bar so the user can confirm the pipeline 1453 1458 // is wired up — notifications themselves are easy to miss. ··· 1455 1460 if !m.notifier.Enabled() || len(emails) == 0 { 1456 1461 return 1457 1462 } 1463 + folderLabel := m.cfg.Folders.LabelFor(folder) 1458 1464 dstByUID := make(map[uint32]string, len(moves)) 1459 1465 for _, mv := range moves { 1460 1466 if mv.email != nil { 1461 - dstByUID[mv.email.UID] = mv.dst 1467 + dstByUID[mv.email.UID] = m.cfg.Folders.LabelFor(mv.dst) 1462 1468 } 1463 1469 } 1464 - res := m.notifier.MaybeNotify(m.activeAccount().Name, folder, emails, dstByUID, m.screener, m.notifyState) 1470 + res := m.notifier.MaybeNotify(m.activeAccount().Name, folderLabel, emails, dstByUID, m.screener, m.notifyState) 1465 1471 switch { 1466 1472 case res.Failed > 0: 1467 1473 m.status = fmt.Sprintf("Notification command failed (%d): %s", res.Failed, res.Err) 1468 1474 m.isError = true 1469 1475 case res.Sent > 0: 1470 - m.status = fmt.Sprintf("Notified %d VIP sender(s) in %s.", res.Sent, folder) 1476 + m.status = fmt.Sprintf("Notified %d VIP sender(s) in %s.", res.Sent, folderLabel) 1471 1477 case !res.HadBaseline: 1472 - m.status = fmt.Sprintf("Notify baseline set for %s (UID %d). New mail from now on will notify.", folder, res.MaxUIDObserved) 1478 + m.status = fmt.Sprintf("Notify baseline set for %s (UID %d). New mail from now on will notify.", folderLabel, res.MaxUIDObserved) 1473 1479 case res.NewSinceBase == 0: 1474 1480 // No new emails — silent (avoid noisy status on every refresh). 1475 1481 case res.MatchedNotify == 0: 1476 1482 m.status = fmt.Sprintf("Notify check %s: %d new email(s), 0 from VIPs (baseline UID %d → %d).", 1477 - folder, res.NewSinceBase, res.Baseline, res.MaxUIDObserved) 1483 + folderLabel, res.NewSinceBase, res.Baseline, res.MaxUIDObserved) 1478 1484 case res.FolderAllowed == 0: 1479 1485 m.status = fmt.Sprintf("Notify check %s: %d VIP email(s), but destination not in allowlist (folders=%v).", 1480 - folder, res.MatchedNotify, m.cfg.Notifications.Resolved().Folders) 1486 + folderLabel, res.MatchedNotify, m.cfg.Notifications.Resolved().Folders) 1481 1487 } 1482 1488 } 1483 1489 ··· 1965 1971 // screened a VIP email out of Inbox into PaperTrail/Feed before the 1966 1972 // TUI sees it; the notification still fires when the user (or the 1967 1973 // next refresh) loads that destination folder. 1968 - if m.cfg.Notifications.FolderAllowed(msg.folder) { 1974 + // 1975 + // Convert the IMAP folder name to its UI label first so allowlists 1976 + // written with labels (e.g. "PaperTrail") still match when the IMAP 1977 + // folder name is custom (e.g. "HEY/Paper Trail"). 1978 + if m.cfg.Notifications.FolderAllowed(m.cfg.Folders.LabelFor(msg.folder)) { 1969 1979 m.maybeNotifyInbox(msg.folder, msg.emails, nil) 1970 1980 } 1971 1981 return m, tea.Batch(sortCmd, m.fetchFolderCountsCmd())