···11# Changelog
2233+# 2026-05-04
44+- **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`
55+- **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
66+- **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`, …)
77+38# 2026-05-03
49- **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/)
510- **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
···116116 "work": "Work",
117117}
118118119119+// LabelFor returns the UI label (e.g. "Inbox", "PaperTrail") for a configured
120120+// IMAP folder name. Useful for matching user-facing config (which uses labels)
121121+// against runtime values (which use IMAP names — they may differ, e.g. Gmail's
122122+// "[Gmail]/All Mail" or HEY's "HEY/Paper Trail"). Returns the input
123123+// unchanged if no mapping exists, so the caller can fall back to direct
124124+// string comparison.
125125+func (f FoldersConfig) LabelFor(imapName string) string {
126126+ switch imapName {
127127+ case f.Inbox:
128128+ return "Inbox"
129129+ case f.Sent:
130130+ return "Sent"
131131+ case f.Trash:
132132+ return "Trash"
133133+ case f.Drafts:
134134+ return "Drafts"
135135+ case f.ToScreen:
136136+ return "ToScreen"
137137+ case f.Feed:
138138+ return "Feed"
139139+ case f.PaperTrail:
140140+ return "PaperTrail"
141141+ case f.ScreenedOut:
142142+ return "ScreenedOut"
143143+ case f.Archive:
144144+ return "Archive"
145145+ case f.Waiting:
146146+ return "Waiting"
147147+ case f.Scheduled:
148148+ return "Scheduled"
149149+ case f.Someday:
150150+ return "Someday"
151151+ case f.Spam:
152152+ return "Spam"
153153+ case f.Work:
154154+ return "Work"
155155+ }
156156+ return imapName
157157+}
158158+119159// TabLabels returns the UI label names in tab display order.
120160// tab_order keys (e.g. "inbox", "to_screen") are resolved to label names
121161// (e.g. "Inbox", "ToScreen") that activeFolder() and keyboard shortcuts match against.
+39
internal/config/config_test.go
···176176 }
177177}
178178179179+func TestLabelFor(t *testing.T) {
180180+ // Custom IMAP folder names (e.g. HEY-style labels).
181181+ fc := FoldersConfig{
182182+ Inbox: "INBOX",
183183+ PaperTrail: "HEY/Paper Trail",
184184+ Feed: "Newsletters",
185185+ Sent: "Sent Items",
186186+ }
187187+ tests := []struct {
188188+ imap, want string
189189+ }{
190190+ {"INBOX", "Inbox"},
191191+ {"HEY/Paper Trail", "PaperTrail"},
192192+ {"Newsletters", "Feed"},
193193+ {"Sent Items", "Sent"},
194194+ {"unknown-folder", "unknown-folder"}, // pass-through fallback
195195+ }
196196+ for _, tt := range tests {
197197+ if got := fc.LabelFor(tt.imap); got != tt.want {
198198+ t.Errorf("LabelFor(%q) = %q, want %q", tt.imap, got, tt.want)
199199+ }
200200+ }
201201+}
202202+203203+func TestFolderAllowed_MatchesLabelAfterCustomIMAPName(t *testing.T) {
204204+ // Regression: when notifications.folders = ["PaperTrail"] but
205205+ // folders.papertrail = "HEY/Paper Trail", the caller must convert the
206206+ // IMAP name to the label before calling FolderAllowed.
207207+ fc := FoldersConfig{PaperTrail: "HEY/Paper Trail"}
208208+ nc := NotificationsConfig{Folders: []string{"PaperTrail"}}
209209+210210+ if nc.FolderAllowed("HEY/Paper Trail") {
211211+ t.Error("FolderAllowed should not match an IMAP name directly — caller must normalise to label first")
212212+ }
213213+ if !nc.FolderAllowed(fc.LabelFor("HEY/Paper Trail")) {
214214+ t.Error("FolderAllowed should match after caller normalises via LabelFor")
215215+ }
216216+}
217217+179218func TestLoad_MissingConfigCreatesDefault(t *testing.T) {
180219 dir := t.TempDir()
181220 path := filepath.Join(dir, "neomd", "config.toml")
+26-5
internal/notify/notify.go
···7788import (
99 "bytes"
1010+ "context"
1011 "fmt"
1112 "os/exec"
1213 "strconv"
1314 "strings"
1515+ "time"
14161517 "github.com/sspaeti/neomd/internal/config"
1618 "github.com/sspaeti/neomd/internal/imap"
1719 "github.com/sspaeti/neomd/internal/screener"
1820)
19212222+// sendTimeout caps how long we'll wait for the notification command to
2323+// return. MaybeNotify runs inside the bubbletea Update loop, so a hung
2424+// notify-send (broken DBus, mako restarting, …) would otherwise freeze the
2525+// TUI. notify-send normally returns in single-digit milliseconds; 2 s is a
2626+// generous ceiling that still keeps the UI responsive.
2727+const sendTimeout = 2 * time.Second
2828+2029// Notifier wraps a notification command (notify-send by default) and a
2130// resolved-defaults config snapshot.
2231type Notifier struct {
···3443 return n != nil && n.cfg.Enabled
3544}
36453737-// Send fires a single notification synchronously. Safe to call when disabled
3838-// (returns nil). Returned error wraps the command's stderr so callers can
3939-// surface useful diagnostics in the TUI status bar.
4646+// Send fires a single notification with a hard deadline so it can never
4747+// freeze the bubbletea Update loop. Safe to call when disabled (returns
4848+// nil). Returned error wraps the command's stderr so callers can surface
4949+// useful diagnostics in the TUI status bar.
4050func (n *Notifier) Send(title, body string) error {
4151 if !n.cfg.Enabled {
4252 return nil
···4858 title,
4959 truncate(body, 200),
5060 }
5151- cmd := exec.Command(n.cfg.Command, args...)
6161+ ctx, cancel := context.WithTimeout(context.Background(), sendTimeout)
6262+ defer cancel()
6363+ cmd := exec.CommandContext(ctx, n.cfg.Command, args...)
6464+ // WaitDelay ensures Wait() returns shortly after the process is killed
6565+ // even if its stdout/stderr pipes are still being held open by a child
6666+ // process — without this, a hung notifier could keep cmd.Run() blocked
6767+ // long after the deadline expired.
6868+ cmd.WaitDelay = 500 * time.Millisecond
5269 var stderr bytes.Buffer
5370 cmd.Stderr = &stderr
5454- if err := cmd.Run(); err != nil {
7171+ err := cmd.Run()
7272+ if ctx.Err() == context.DeadlineExceeded {
7373+ return fmt.Errorf("%s: timed out after %s", n.cfg.Command, sendTimeout)
7474+ }
7575+ if err != nil {
5576 msg := strings.TrimSpace(stderr.String())
5677 if msg == "" {
5778 return err
+28
internal/notify/notify_test.go
···33import (
44 "os"
55 "path/filepath"
66+ "strings"
67 "testing"
88+ "time"
79810 "github.com/sspaeti/neomd/internal/config"
911 "github.com/sspaeti/neomd/internal/imap"
···151153 uid, ok := reloaded.Get("acct|Inbox")
152154 if !ok || uid != 42 {
153155 t.Errorf("reloaded = (%d, %v), want (42, true)", uid, ok)
156156+ }
157157+}
158158+159159+func TestSend_TimeoutCannotBlockTUI(t *testing.T) {
160160+ // Drop a tiny shell script that ignores all arguments and sleeps
161161+ // forever; Send must return within the configured timeout instead of
162162+ // blocking the bubbletea Update loop indefinitely.
163163+ dir := t.TempDir()
164164+ hung := filepath.Join(dir, "hung-notifier.sh")
165165+ if err := os.WriteFile(hung, []byte("#!/bin/sh\nsleep 60\n"), 0700); err != nil {
166166+ t.Fatal(err)
167167+ }
168168+ n := New(config.NotificationsConfig{Enabled: true, Command: hung, Folders: []string{"Inbox"}})
169169+170170+ start := time.Now()
171171+ err := n.Send("title", "body")
172172+ elapsed := time.Since(start)
173173+ if err == nil {
174174+ t.Fatal("expected timeout error from hung notifier, got nil")
175175+ }
176176+ if !strings.Contains(err.Error(), "timed out") {
177177+ t.Fatalf("error should mention timeout, got: %v", err)
178178+ }
179179+ // 2s timeout + exec overhead — must finish well under 4s.
180180+ if elapsed > 4*time.Second {
181181+ t.Errorf("Send blocked for %s, exceeds the 2s timeout ceiling", elapsed)
154182 }
155183}
156184
+9-1
internal/screener/screener.go
···456456 return nil
457457}
458458459459-// loadList reads a one-address-per-line file into a set.
459459+// loadList reads a one-address-per-line file into a set. Full-line and
460460+// inline "# comment" suffixes are stripped — `addr@example.com # note`
461461+// stores `addr@example.com`, matching the documented examples.
460462func loadList(path string, m map[string]bool) error {
461463 f, err := os.Open(path)
462464 if os.IsNotExist(err) {
···471473 line := strings.TrimSpace(sc.Text())
472474 if line == "" || strings.HasPrefix(line, "#") {
473475 continue
476476+ }
477477+ if i := strings.IndexByte(line, '#'); i >= 0 {
478478+ line = strings.TrimSpace(line[:i])
479479+ if line == "" {
480480+ continue
481481+ }
474482 }
475483 m[normalise(line)] = true
476484 }
+24
internal/screener/screener_test.go
···248248 }
249249 })
250250251251+ t.Run("New strips inline # comments after entries", func(t *testing.T) {
252252+ // The documented example showed `@ssp.sh # everyone at ssp.sh …` —
253253+ // loadList must strip the inline comment so the entry actually matches.
254254+ dir := t.TempDir()
255255+ cfg := makeCfg(dir)
256256+ content := "@ssp.sh # everyone at ssp.sh is approved\nalice@example.com # personal contact\n#bob@example.com # full-line still works\n"
257257+ if err := os.WriteFile(cfg.ScreenedIn, []byte(content), 0600); err != nil {
258258+ t.Fatal(err)
259259+ }
260260+ s, err := New(cfg)
261261+ if err != nil {
262262+ t.Fatal(err)
263263+ }
264264+ if s.Classify("anyone@ssp.sh") != CategoryInbox {
265265+ t.Error("@ssp.sh entry with inline comment should match anyone@ssp.sh")
266266+ }
267267+ if s.Classify("alice@example.com") != CategoryInbox {
268268+ t.Error("alice@example.com with trailing comment should match")
269269+ }
270270+ if s.Classify("bob@example.com") != CategoryToScreen {
271271+ t.Error("bob (full-line commented out) should NOT match")
272272+ }
273273+ })
274274+251275 t.Run("Approve adds to screened_in removes from screened_out and spam", func(t *testing.T) {
252276 dir := t.TempDir()
253277 cfg := makeCfg(dir)
+19-9
internal/ui/model.go
···1446144614471447// maybeNotifyInbox dispatches desktop notifications for the freshly fetched
14481448// emails. moves describes where each email is heading after auto-screening
14491449-// (pass nil if no auto-screen pass ran). Folder is the IMAP source folder
14501450-// label the fetch came from. No-op when the notifier is disabled.
14491449+// (pass nil if no auto-screen pass ran). folder is the IMAP source-folder
14501450+// name the fetch came from. No-op when the notifier is disabled.
14511451+//
14521452+// All folder values are converted to UI labels ("Inbox", "PaperTrail", …)
14531453+// before the notifier sees them so [notifications].folders works regardless
14541454+// of whether the user kept default IMAP names or set custom ones (e.g.
14551455+// folders.papertrail = "HEY/Paper Trail").
14511456//
14521457// Reports activity on the TUI status bar so the user can confirm the pipeline
14531458// is wired up — notifications themselves are easy to miss.
···14551460 if !m.notifier.Enabled() || len(emails) == 0 {
14561461 return
14571462 }
14631463+ folderLabel := m.cfg.Folders.LabelFor(folder)
14581464 dstByUID := make(map[uint32]string, len(moves))
14591465 for _, mv := range moves {
14601466 if mv.email != nil {
14611461- dstByUID[mv.email.UID] = mv.dst
14671467+ dstByUID[mv.email.UID] = m.cfg.Folders.LabelFor(mv.dst)
14621468 }
14631469 }
14641464- res := m.notifier.MaybeNotify(m.activeAccount().Name, folder, emails, dstByUID, m.screener, m.notifyState)
14701470+ res := m.notifier.MaybeNotify(m.activeAccount().Name, folderLabel, emails, dstByUID, m.screener, m.notifyState)
14651471 switch {
14661472 case res.Failed > 0:
14671473 m.status = fmt.Sprintf("Notification command failed (%d): %s", res.Failed, res.Err)
14681474 m.isError = true
14691475 case res.Sent > 0:
14701470- m.status = fmt.Sprintf("Notified %d VIP sender(s) in %s.", res.Sent, folder)
14761476+ m.status = fmt.Sprintf("Notified %d VIP sender(s) in %s.", res.Sent, folderLabel)
14711477 case !res.HadBaseline:
14721472- m.status = fmt.Sprintf("Notify baseline set for %s (UID %d). New mail from now on will notify.", folder, res.MaxUIDObserved)
14781478+ m.status = fmt.Sprintf("Notify baseline set for %s (UID %d). New mail from now on will notify.", folderLabel, res.MaxUIDObserved)
14731479 case res.NewSinceBase == 0:
14741480 // No new emails — silent (avoid noisy status on every refresh).
14751481 case res.MatchedNotify == 0:
14761482 m.status = fmt.Sprintf("Notify check %s: %d new email(s), 0 from VIPs (baseline UID %d → %d).",
14771477- folder, res.NewSinceBase, res.Baseline, res.MaxUIDObserved)
14831483+ folderLabel, res.NewSinceBase, res.Baseline, res.MaxUIDObserved)
14781484 case res.FolderAllowed == 0:
14791485 m.status = fmt.Sprintf("Notify check %s: %d VIP email(s), but destination not in allowlist (folders=%v).",
14801480- folder, res.MatchedNotify, m.cfg.Notifications.Resolved().Folders)
14861486+ folderLabel, res.MatchedNotify, m.cfg.Notifications.Resolved().Folders)
14811487 }
14821488}
14831489···19651971 // screened a VIP email out of Inbox into PaperTrail/Feed before the
19661972 // TUI sees it; the notification still fires when the user (or the
19671973 // next refresh) loads that destination folder.
19681968- if m.cfg.Notifications.FolderAllowed(msg.folder) {
19741974+ //
19751975+ // Convert the IMAP folder name to its UI label first so allowlists
19761976+ // written with labels (e.g. "PaperTrail") still match when the IMAP
19771977+ // folder name is custom (e.g. "HEY/Paper Trail").
19781978+ if m.cfg.Notifications.FolderAllowed(m.cfg.Folders.LabelFor(msg.folder)) {
19691979 m.maybeNotifyInbox(msg.folder, msg.emails, nil)
19701980 }
19711981 return m, tea.Batch(sortCmd, m.fetchFolderCountsCmd())