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.

notify.txt fix

+382 -27
+12
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + # 2026-05-03 4 + - **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 + - **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 6 + - **`:notify-test` (`:nt`) command** — fires a single test desktop notification with the current `[notifications]` config so you can verify `notify-send`, the icon theme, and the notification daemon (mako/dunst/swaync) are all working without waiting for a real VIP email to arrive 7 + - **`:debug` reports notifications** — diagnostic report now includes the resolved `[notifications]` config, the contents of `notify_state.json` (per-folder baseline UIDs), the path to `notify.txt`, and the list of non-Inbox folders being polled in the background for VIP mail 8 + - **Visible `notify-send` errors** — the notifier now captures the underlying command's stderr (`mako not running`, missing icon theme, etc.) and surfaces it on the status bar instead of swallowing the error silently — easier to diagnose when notifications stop firing 9 + - **`<space>n` / `<space>N` (reader)** — append the open email's sender (exact email, lowercase) or its `@domain` to `notify.txt` directly from the reader; instant write with confirmation in the status bar; complements the `[notifications]` opt-in flow so you can curate the VIP list while reading without leaving neomd 10 + - **Fix: `:debug` panicked on accounts with `imap_disabled = true`** — `writeDebugReport` dereferenced the nil IMAP client that `imap_disabled` accounts intentionally produce, taking the whole TUI down. Now those accounts render as "**Name** — IMAP disabled (send-only)" in the report instead of crashing 11 + - **Fix: nil-client guard in VIP folder polling** — the new background notification poll used to crash if the active tab was an `imap_disabled` account when the bg sync ticked; now skips the fetch silently and waits for the next tick 12 + - **Notification diagnostics** — when a notification doesn't fire, the status bar now explains why instead of staying silent: `Notify baseline set for INBOX (UID 604221)` on first run; `Notify check INBOX: 5 new email(s), 0 from VIPs (baseline UID 604232 → 604239)` when nothing matches the notify list; `Notify check INBOX: 2 VIP email(s), but destination not in allowlist (folders=[Inbox PaperTrail])` when the VIP mail landed in a folder you didn't list. Removes the guesswork around "did it just not fire?" 13 + - **Fix: notify.txt was never loaded into the screener** — `cmd/neomd/main.go` and `internal/daemon/daemon.go` constructed `screener.Config` without the new `Notify` path field, so the in-memory notify set stayed empty even when `notify.txt` had entries; `ShouldNotify` always returned false and no notification ever fired. Both call sites now pass `Notify: cfg.Screener.Notify`. Regression test added so this can't recur 14 + 3 15 # 2026-04-30 4 16 - **Send-only accounts (`imap_disabled = true`)** — accounts can be marked as send-only by setting `imap_disabled = true`; neomd skips IMAP connection, folder fetching, and screening for that account; the account remains available as a From address via `ctrl+f` in compose/pre-send; `ctrl+a` account cycling skips disabled accounts; useful for adding Gmail or other providers purely for sending without fetching thousands of emails; `:debug` shows "(imap disabled)" label 5 17
+1
cmd/neomd/main.go
··· 129 129 Feed: cfg.Screener.Feed, 130 130 PaperTrail: cfg.Screener.PaperTrail, 131 131 Spam: cfg.Screener.Spam, 132 + Notify: cfg.Screener.Notify, 132 133 }) 133 134 if err != nil { 134 135 fmt.Fprintf(os.Stderr, "neomd: screener error: %v\n", err)
+3
docs/content/docs/keybindings.md
··· 100 100 | `<space>/` | IMAP search ALL emails on server (From + Subject) | 101 101 | `<space>S` | scan current folder for spy pixels (skips already scanned) | 102 102 | `<space>d (reader)` | download raw email source (.eml) to ~/Downloads | 103 + | `<space>n (reader)` | append open email's sender to notify.txt (desktop notifications opt-in) | 104 + | `<space>N (reader)` | append @domain of open email's sender to notify.txt | 103 105 | `<space>w` | show welcome screen | 104 106 105 107 ··· 168 170 | `:create-folders / :cf` | create missing IMAP folders from config (safe, idempotent) | 169 171 | `:go-spam / :spam` | open Spam folder (not in tab rotation) | 170 172 | `:debug / :dbg` | diagnostic report — IMAP ping, config, folders, state (saved to /tmp/neomd/debug.log) | 173 + | `:notify-test / :nt` | fire a single test desktop notification using the current [notifications] config | 171 174 | `:quit / :q` | quit neomd | 172 175 173 176
+1
internal/daemon/daemon.go
··· 148 148 Feed: d.cfg.Screener.Feed, 149 149 PaperTrail: d.cfg.Screener.PaperTrail, 150 150 Spam: d.cfg.Screener.Spam, 151 + Notify: d.cfg.Screener.Notify, 151 152 }) 152 153 if err != nil { 153 154 return fmt.Errorf("reload screener: %w", err)
+46 -10
internal/notify/notify.go
··· 6 6 package notify 7 7 8 8 import ( 9 + "bytes" 10 + "fmt" 9 11 "os/exec" 10 12 "strconv" 11 13 "strings" ··· 33 35 } 34 36 35 37 // Send fires a single notification synchronously. Safe to call when disabled 36 - // (returns nil). Errors from the underlying command are swallowed by callers 37 - // — a failed notification should never break the email flow. 38 + // (returns nil). Returned error wraps the command's stderr so callers can 39 + // surface useful diagnostics in the TUI status bar. 38 40 func (n *Notifier) Send(title, body string) error { 39 41 if !n.cfg.Enabled { 40 42 return nil ··· 46 48 title, 47 49 truncate(body, 200), 48 50 } 49 - return exec.Command(n.cfg.Command, args...).Run() 51 + cmd := exec.Command(n.cfg.Command, args...) 52 + var stderr bytes.Buffer 53 + cmd.Stderr = &stderr 54 + if err := cmd.Run(); err != nil { 55 + msg := strings.TrimSpace(stderr.String()) 56 + if msg == "" { 57 + return err 58 + } 59 + return fmt.Errorf("%s: %s", n.cfg.Command, msg) 60 + } 61 + return nil 62 + } 63 + 64 + // Result summarises a MaybeNotify pass. The counters help diagnose why a 65 + // notification did or did not fire. 66 + type Result struct { 67 + Sent int // notifications dispatched without error 68 + Failed int // notifications attempted but the command returned an error 69 + Err string // last error message (if any) 70 + NewSinceBase int // emails whose UID was strictly above the recorded baseline 71 + MatchedNotify int // of those, how many had a sender on the notify list 72 + FolderAllowed int // of those, how many landed in a folder in the allowlist 73 + HadBaseline bool // false → first-run pass, no notifications fire by design 74 + Baseline uint32 // baseline used for the comparison 75 + MaxUIDObserved uint32 // highest UID seen in this batch 50 76 } 51 77 52 78 // MaybeNotify processes a freshly fetched batch of emails from sourceFolder. ··· 63 89 // after auto-screening (caller computes this from screener.ClassifyForScreen). 64 90 // UIDs missing from dstByUID are assumed to stay in sourceFolder. 65 91 // 66 - // Returns the number of notifications dispatched. 67 - func (n *Notifier) MaybeNotify(account, sourceFolder string, emails []imap.Email, dstByUID map[uint32]string, sc *screener.Screener, state *State) int { 92 + // Returns a Result describing how many notifications fired and the last 93 + // error encountered (if any). 94 + func (n *Notifier) MaybeNotify(account, sourceFolder string, emails []imap.Email, dstByUID map[uint32]string, sc *screener.Screener, state *State) Result { 95 + var res Result 68 96 if !n.Enabled() || sc == nil || state == nil || len(emails) == 0 { 69 - return 0 97 + return res 70 98 } 71 99 key := stateKey(account, sourceFolder) 72 100 baseline, hadBaseline := state.Get(key) 101 + res.HadBaseline = hadBaseline 102 + res.Baseline = baseline 73 103 74 104 var maxUID uint32 75 - sent := 0 76 105 for i := range emails { 77 106 e := &emails[i] 78 107 if e.UID > maxUID { ··· 81 110 if !hadBaseline || e.UID <= baseline { 82 111 continue 83 112 } 113 + res.NewSinceBase++ 84 114 if !sc.ShouldNotify(e.From) { 85 115 continue 86 116 } 117 + res.MatchedNotify++ 87 118 dst, ok := dstByUID[e.UID] 88 119 if !ok { 89 120 dst = sourceFolder ··· 91 122 if !n.cfg.FolderAllowed(dst) { 92 123 continue 93 124 } 125 + res.FolderAllowed++ 94 126 title := "neomd: " + truncate(e.From, 80) 95 127 body := e.Subject 96 128 if body == "" { 97 129 body = "(no subject)" 98 130 } 99 - if err := n.Send(title, body); err == nil { 100 - sent++ 131 + if err := n.Send(title, body); err != nil { 132 + res.Failed++ 133 + res.Err = err.Error() 134 + continue 101 135 } 136 + res.Sent++ 102 137 } 138 + res.MaxUIDObserved = maxUID 103 139 104 140 if maxUID > baseline { 105 141 state.Set(key, maxUID) 106 142 _ = state.Save() 107 143 } 108 - return sent 144 + return res 109 145 } 110 146 111 147 func stateKey(account, folder string) string {
+12 -12
internal/notify/notify_test.go
··· 73 73 {UID: 100, From: "vip@example.com", Subject: "hi"}, 74 74 {UID: 101, From: "other@example.com", Subject: "hello"}, 75 75 } 76 - sent := n.MaybeNotify("acct", "Inbox", emails, nil, sc, state) 77 - if sent != 0 { 78 - t.Errorf("first run sent = %d, want 0 (baseline-only pass)", sent) 76 + res := n.MaybeNotify("acct", "Inbox", emails, nil, sc, state) 77 + if res.Sent != 0 { 78 + t.Errorf("first run sent = %d, want 0 (baseline-only pass)", res.Sent) 79 79 } 80 80 uid, ok := state.Get(stateKey("acct", "Inbox")) 81 81 if !ok || uid != 101 { ··· 96 96 {UID: 101, From: "vip@example.com", Subject: "new!"}, // new + on notify list 97 97 {UID: 102, From: "other@example.com", Subject: "noise"}, // new but not on notify list 98 98 } 99 - sent := n.MaybeNotify("acct", "Inbox", emails, nil, sc, state) 100 - if sent != 1 { 101 - t.Errorf("sent = %d, want 1", sent) 99 + res := n.MaybeNotify("acct", "Inbox", emails, nil, sc, state) 100 + if res.Sent != 1 { 101 + t.Errorf("sent = %d, want 1", res.Sent) 102 102 } 103 103 } 104 104 ··· 115 115 {UID: 2, From: "bob@important.org", Subject: "y"}, 116 116 {UID: 3, From: "spam@nowhere.com", Subject: "z"}, 117 117 } 118 - sent := n.MaybeNotify("acct", "Inbox", emails, nil, sc, state) 119 - if sent != 2 { 120 - t.Errorf("sent = %d, want 2 (both @important.org senders)", sent) 118 + res := n.MaybeNotify("acct", "Inbox", emails, nil, sc, state) 119 + if res.Sent != 2 { 120 + t.Errorf("sent = %d, want 2 (both @important.org senders)", res.Sent) 121 121 } 122 122 } 123 123 ··· 134 134 } 135 135 // Email is auto-screened to Feed → allowlist excludes it. 136 136 dst := map[uint32]string{5: "Feed"} 137 - sent := n.MaybeNotify("acct", "Inbox", emails, dst, sc, state) 138 - if sent != 0 { 139 - t.Errorf("sent = %d, want 0 (Feed not in allowlist)", sent) 137 + res := n.MaybeNotify("acct", "Inbox", emails, dst, sc, state) 138 + if res.Sent != 0 { 139 + t.Errorf("sent = %d, want 0 (Feed not in allowlist)", res.Sent) 140 140 } 141 141 } 142 142
+19
internal/screener/screener.go
··· 192 192 return domainMatch(addr, set) 193 193 } 194 194 195 + // AddNotify appends a sender (or "@domain" entry) to notify.txt. No-op when 196 + // the entry is already present or when no notify list is configured. 197 + // Independent of the screening category — does not touch screened_in / 198 + // screened_out / feed / papertrail / spam. 199 + func (s *Screener) AddNotify(from string) error { 200 + if s.cfg.Notify == "" { 201 + return fmt.Errorf("notify list path not configured (set [screener].notify in config.toml)") 202 + } 203 + return s.addToList(s.cfg.Notify, s.notify, from) 204 + } 205 + 206 + // RemoveNotify deletes a sender (or "@domain" entry) from notify.txt. 207 + func (s *Screener) RemoveNotify(from string) error { 208 + if s.cfg.Notify == "" { 209 + return nil 210 + } 211 + return s.removeFromList(s.cfg.Notify, s.notify, from) 212 + } 213 + 195 214 // domainMatch returns true only via the "@domain" form (skipping the exact 196 215 // check). Used by Classify so that the priority loop can be split into an 197 216 // exact-match pass first, then a domain-match pass.
+94
internal/screener/screener_test.go
··· 504 504 } 505 505 }) 506 506 507 + t.Run("AddNotify appends and persists, RemoveNotify clears", func(t *testing.T) { 508 + dir := t.TempDir() 509 + notifyPath := filepath.Join(dir, "notify.txt") 510 + s, err := New(Config{ 511 + ScreenedIn: filepath.Join(dir, "in.txt"), 512 + ScreenedOut: filepath.Join(dir, "out.txt"), 513 + Feed: filepath.Join(dir, "feed.txt"), 514 + PaperTrail: filepath.Join(dir, "pt.txt"), 515 + Spam: filepath.Join(dir, "spam.txt"), 516 + Notify: notifyPath, 517 + }) 518 + if err != nil { 519 + t.Fatal(err) 520 + } 521 + if err := s.AddNotify("alice@example.com"); err != nil { 522 + t.Fatalf("AddNotify exact: %v", err) 523 + } 524 + if err := s.AddNotify("@important.org"); err != nil { 525 + t.Fatalf("AddNotify domain: %v", err) 526 + } 527 + // Re-add is no-op (no duplicate). 528 + if err := s.AddNotify("alice@example.com"); err != nil { 529 + t.Fatalf("AddNotify duplicate: %v", err) 530 + } 531 + if !s.ShouldNotify("alice@example.com") { 532 + t.Error("alice should notify") 533 + } 534 + if !s.ShouldNotify("anyone@important.org") { 535 + t.Error("@important.org should match anyone@important.org") 536 + } 537 + // Reload from disk to confirm persistence. 538 + s2, err := New(s.cfg) 539 + if err != nil { 540 + t.Fatal(err) 541 + } 542 + if !s2.ShouldNotify("alice@example.com") || !s2.ShouldNotify("bob@important.org") { 543 + t.Error("reload lost entries") 544 + } 545 + // Remove. 546 + if err := s.RemoveNotify("alice@example.com"); err != nil { 547 + t.Fatal(err) 548 + } 549 + if s.ShouldNotify("alice@example.com") { 550 + t.Error("removed entry should not match") 551 + } 552 + }) 553 + 554 + t.Run("AddNotify errors when path not configured", func(t *testing.T) { 555 + s := &Screener{notify: map[string]bool{}, cfg: Config{}} 556 + if err := s.AddNotify("x@example.com"); err == nil { 557 + t.Error("expected error when notify path empty") 558 + } 559 + }) 560 + 561 + t.Run("ShouldNotify is false when Notify path is omitted from Config (regression)", func(t *testing.T) { 562 + // Regression: cmd/neomd/main.go used to construct screener.Config without 563 + // the Notify field, so the in-memory notify set stayed empty even when 564 + // notify.txt had entries. Result: ShouldNotify returned false silently. 565 + dir := t.TempDir() 566 + notifyPath := filepath.Join(dir, "notify.txt") 567 + os.WriteFile(notifyPath, []byte("vip@example.com\n"), 0600) 568 + // Build a screener WITHOUT passing Notify — mimics the broken main.go 569 + // before the fix. 570 + s, err := New(Config{ 571 + ScreenedIn: filepath.Join(dir, "in.txt"), 572 + ScreenedOut: filepath.Join(dir, "out.txt"), 573 + Feed: filepath.Join(dir, "feed.txt"), 574 + PaperTrail: filepath.Join(dir, "pt.txt"), 575 + Spam: filepath.Join(dir, "spam.txt"), 576 + // Notify intentionally unset 577 + }) 578 + if err != nil { 579 + t.Fatal(err) 580 + } 581 + if s.ShouldNotify("vip@example.com") { 582 + t.Fatal("ShouldNotify should be false when Notify path is omitted") 583 + } 584 + // Now pass Notify — ShouldNotify must return true. 585 + s2, err := New(Config{ 586 + ScreenedIn: filepath.Join(dir, "in.txt"), 587 + ScreenedOut: filepath.Join(dir, "out.txt"), 588 + Feed: filepath.Join(dir, "feed.txt"), 589 + PaperTrail: filepath.Join(dir, "pt.txt"), 590 + Spam: filepath.Join(dir, "spam.txt"), 591 + Notify: notifyPath, 592 + }) 593 + if err != nil { 594 + t.Fatal(err) 595 + } 596 + if !s2.ShouldNotify("vip@example.com") { 597 + t.Fatal("ShouldNotify should return true when Notify path is wired up") 598 + } 599 + }) 600 + 507 601 t.Run("Notify path empty leaves the list empty", func(t *testing.T) { 508 602 dir := t.TempDir() 509 603 s, err := New(Config{
+20
internal/ui/cmdline.go
··· 236 236 }, 237 237 }, 238 238 { 239 + name: "notify-test", 240 + aliases: []string{"nt"}, 241 + desc: "fire a single test desktop notification using the current [notifications] config (diagnostic)", 242 + run: func(m *Model) (tea.Model, tea.Cmd) { 243 + if !m.notifier.Enabled() { 244 + m.status = "Notifications disabled. Set [notifications].enabled = true in config.toml." 245 + m.isError = true 246 + return m, nil 247 + } 248 + if err := m.notifier.Send("neomd: test", "If you can see this, notify-send works."); err != nil { 249 + m.status = "notify-send failed: " + err.Error() 250 + m.isError = true 251 + return m, nil 252 + } 253 + m.status = "Test notification sent — check your desktop notifications. Listening folders: " + 254 + strings.Join(m.cfg.Notifications.Resolved().Folders, ", ") 255 + return m, nil 256 + }, 257 + }, 258 + { 239 259 name: "quit", 240 260 aliases: []string{"q"}, 241 261 desc: "quit neomd",
+3
internal/ui/keys.go
··· 75 75 {"<space>/", "IMAP search ALL emails on server (From + Subject)"}, 76 76 {"<space>S", "scan current folder for spy pixels (skips already scanned)"}, 77 77 {"<space>d (reader)", "download raw email source (.eml) to ~/Downloads"}, 78 + {"<space>n (reader)", "append open email's sender to notify.txt (desktop notifications opt-in)"}, 79 + {"<space>N (reader)", "append @domain of open email's sender to notify.txt"}, 78 80 {"<space>w", "show welcome screen"}, 79 81 }}, 80 82 {"Sort (, prefix)", [][2]string{ ··· 131 133 {":create-folders / :cf", "create missing IMAP folders from config (safe, idempotent)"}, 132 134 {":go-spam / :spam", "open Spam folder (not in tab rotation)"}, 133 135 {":debug / :dbg", "diagnostic report — IMAP ping, config, folders, state (saved to /tmp/neomd/debug.log)"}, 136 + {":notify-test / :nt", "fire a single test desktop notification using the current [notifications] config"}, 134 137 {":quit / :q", "quit neomd"}, 135 138 }}, 136 139 {"Composing", [][2]string{
+171 -5
internal/ui/model.go
··· 129 129 bgSyncTickMsg struct{} 130 130 bgInboxFetchedMsg struct{ emails []imap.Email } 131 131 bgScreenDoneMsg struct{ moved, total int } 132 + 133 + // bgVipFolderFetchedMsg carries a non-Inbox folder fetch used purely to 134 + // dispatch desktop notifications for VIP senders whose mail the daemon 135 + // (or any other sync path) may have already moved out of Inbox. 136 + bgVipFolderFetchedMsg struct { 137 + folder string 138 + emails []imap.Email 139 + } 132 140 // mark-as-read timer (fires after N seconds in reader) 133 141 markAsReadTimerMsg struct { 134 142 uid uint32 ··· 340 348 if i < len(m.accounts) { 341 349 name = m.accounts[i].Name 342 350 } 351 + if cli == nil { 352 + // imap_disabled accounts have a nil client by design (send-only). 353 + b.WriteString(fmt.Sprintf("- **%s** — IMAP disabled (send-only)\n", name)) 354 + continue 355 + } 343 356 b.WriteString(fmt.Sprintf("- **%s** → `%s`\n", name, cli.Addr())) 344 357 if err := cli.Ping(nil); err != nil { 345 358 b.WriteString(fmt.Sprintf(" - PING: FAILED — `%s`\n", err)) ··· 377 390 lists := [][2]string{ 378 391 {"screened_in", sc.ScreenedIn}, {"screened_out", sc.ScreenedOut}, 379 392 {"feed", sc.Feed}, {"papertrail", sc.PaperTrail}, {"spam", sc.Spam}, 393 + {"notify", sc.Notify}, 380 394 } 381 395 for _, kv := range lists { 382 396 path := kv[1] ··· 391 405 b.WriteString(fmt.Sprintf("- %s: `%s` (%d bytes)\n", kv[0], path, info.Size())) 392 406 } 393 407 } 408 + 409 + // Notifications 410 + b.WriteString("\n## Notifications\n\n") 411 + nc := m.cfg.Notifications.Resolved() 412 + b.WriteString(fmt.Sprintf("- enabled: %v\n", nc.Enabled)) 413 + b.WriteString(fmt.Sprintf("- command: `%s`\n", nc.Command)) 414 + b.WriteString(fmt.Sprintf("- icon: `%s`\n", nc.Icon)) 415 + b.WriteString(fmt.Sprintf("- expire_ms: %d\n", nc.ExpireMs)) 416 + b.WriteString(fmt.Sprintf("- folders (allowlist): %s\n", strings.Join(nc.Folders, ", "))) 417 + statePath := config.NotifyStatePath() 418 + if data, err := os.ReadFile(statePath); err == nil { 419 + b.WriteString(fmt.Sprintf("- state file: `%s` → `%s`\n", statePath, strings.TrimSpace(string(data)))) 420 + } else { 421 + b.WriteString(fmt.Sprintf("- state file: `%s` (not yet written)\n", statePath)) 422 + } 423 + b.WriteString(fmt.Sprintf("- bg-poll folders (non-Inbox): %s\n", strings.Join(m.vipNotifyFolders(), ", "))) 394 424 395 425 // UI config 396 426 b.WriteString("\n## UI Config\n\n") ··· 1418 1448 // emails. moves describes where each email is heading after auto-screening 1419 1449 // (pass nil if no auto-screen pass ran). Folder is the IMAP source folder 1420 1450 // label the fetch came from. No-op when the notifier is disabled. 1421 - func (m Model) maybeNotifyInbox(folder string, emails []imap.Email, moves []autoScreenMove) { 1451 + // 1452 + // Reports activity on the TUI status bar so the user can confirm the pipeline 1453 + // is wired up — notifications themselves are easy to miss. 1454 + func (m *Model) maybeNotifyInbox(folder string, emails []imap.Email, moves []autoScreenMove) { 1422 1455 if !m.notifier.Enabled() || len(emails) == 0 { 1423 1456 return 1424 1457 } ··· 1428 1461 dstByUID[mv.email.UID] = mv.dst 1429 1462 } 1430 1463 } 1431 - m.notifier.MaybeNotify(m.activeAccount().Name, folder, emails, dstByUID, m.screener, m.notifyState) 1464 + res := m.notifier.MaybeNotify(m.activeAccount().Name, folder, emails, dstByUID, m.screener, m.notifyState) 1465 + switch { 1466 + case res.Failed > 0: 1467 + m.status = fmt.Sprintf("Notification command failed (%d): %s", res.Failed, res.Err) 1468 + m.isError = true 1469 + case res.Sent > 0: 1470 + m.status = fmt.Sprintf("Notified %d VIP sender(s) in %s.", res.Sent, folder) 1471 + 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) 1473 + case res.NewSinceBase == 0: 1474 + // No new emails — silent (avoid noisy status on every refresh). 1475 + case res.MatchedNotify == 0: 1476 + 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) 1478 + case res.FolderAllowed == 0: 1479 + 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) 1481 + } 1432 1482 } 1433 1483 1434 1484 // previewAutoScreen classifies the currently loaded inbox emails (no IMAP). ··· 1653 1703 } 1654 1704 } 1655 1705 1706 + // bgFetchVipFolderCmd silently fetches headers from one configured 1707 + // notification folder (other than Inbox, which is handled by the regular bg 1708 + // sync). The result feeds straight into the notifier — no auto-screening, 1709 + // no UI mutation beyond a brief status update. 1710 + func (m Model) bgFetchVipFolderCmd(folder string) tea.Cmd { 1711 + return func() tea.Msg { 1712 + cli := m.imapCli() 1713 + if cli == nil { 1714 + return bgVipFolderFetchedMsg{folder: folder, emails: nil} 1715 + } 1716 + cli.ResetMailboxSelection() 1717 + emails, err := cli.FetchHeaders(nil, folder, m.cfg.UI.InboxCount) 1718 + if err != nil { 1719 + return bgVipFolderFetchedMsg{folder: folder, emails: nil} 1720 + } 1721 + return bgVipFolderFetchedMsg{folder: folder, emails: emails} 1722 + } 1723 + } 1724 + 1725 + // vipNotifyFolders returns the configured non-Inbox folder labels (mapped to 1726 + // IMAP folder names) that should be polled for VIP notifications by the 1727 + // background sync. Empty when the notifier is disabled or only "Inbox" is set. 1728 + func (m Model) vipNotifyFolders() []string { 1729 + if !m.notifier.Enabled() { 1730 + return nil 1731 + } 1732 + cfg := m.cfg.Notifications.Resolved() 1733 + var out []string 1734 + for _, label := range cfg.Folders { 1735 + if strings.EqualFold(label, "Inbox") { 1736 + continue // Inbox is already covered by bgFetchInboxCmd 1737 + } 1738 + if dst := folderLabelToIMAP(label, m.cfg.Folders); dst != "" { 1739 + out = append(out, dst) 1740 + } 1741 + } 1742 + return out 1743 + } 1744 + 1745 + // folderLabelToIMAP maps a UI folder label (e.g. "PaperTrail") to its 1746 + // configured IMAP folder name. Returns "" for unknown labels. 1747 + func folderLabelToIMAP(label string, fc config.FoldersConfig) string { 1748 + switch strings.ToLower(label) { 1749 + case "inbox": 1750 + return fc.Inbox 1751 + case "sent": 1752 + return fc.Sent 1753 + case "trash": 1754 + return fc.Trash 1755 + case "drafts": 1756 + return fc.Drafts 1757 + case "toscreen": 1758 + return fc.ToScreen 1759 + case "feed": 1760 + return fc.Feed 1761 + case "papertrail": 1762 + return fc.PaperTrail 1763 + case "screenedout": 1764 + return fc.ScreenedOut 1765 + case "archive": 1766 + return fc.Archive 1767 + case "waiting": 1768 + return fc.Waiting 1769 + case "scheduled": 1770 + return fc.Scheduled 1771 + case "someday": 1772 + return fc.Someday 1773 + case "spam": 1774 + return fc.Spam 1775 + case "work": 1776 + return fc.Work 1777 + } 1778 + return "" 1779 + } 1780 + 1656 1781 // bgExecAutoScreenCmd silently moves emails and returns bgScreenDoneMsg. 1657 1782 func (m Model) bgExecAutoScreenCmd(moves []autoScreenMove) tea.Cmd { 1658 1783 src := m.cfg.Folders.Inbox ··· 1835 1960 return m, tea.Batch(sortCmd, m.fetchFolderCountsCmd(), m.spinner.Tick, m.execAutoScreenCmd(moves)) 1836 1961 } 1837 1962 } 1838 - if msg.folder == m.cfg.Folders.Inbox { 1963 + // Notify on any folder load that matches the user's allowlist — not 1964 + // just Inbox. Covers the case where a headless daemon has already 1965 + // screened a VIP email out of Inbox into PaperTrail/Feed before the 1966 + // TUI sees it; the notification still fires when the user (or the 1967 + // next refresh) loads that destination folder. 1968 + if m.cfg.Notifications.FolderAllowed(msg.folder) { 1839 1969 m.maybeNotifyInbox(msg.folder, msg.emails, nil) 1840 1970 } 1841 1971 return m, tea.Batch(sortCmd, m.fetchFolderCountsCmd()) ··· 2252 2382 return m, m.scheduleBgSync() // just reschedule the next tick 2253 2383 } 2254 2384 // Fire background inbox fetch; reschedule next tick in parallel. 2385 + // Also poll any extra notification folders (e.g. PaperTrail, Feed) 2386 + // so VIP mail screened out of Inbox by the daemon still notifies. 2255 2387 m.bgSyncInProgress = true 2256 - return m, tea.Batch(m.bgFetchInboxCmd(), m.scheduleBgSync()) 2388 + cmds := []tea.Cmd{m.bgFetchInboxCmd(), m.scheduleBgSync()} 2389 + for _, f := range m.vipNotifyFolders() { 2390 + cmds = append(cmds, m.bgFetchVipFolderCmd(f)) 2391 + } 2392 + return m, tea.Batch(cmds...) 2257 2393 2258 2394 case bgInboxFetchedMsg: 2259 2395 // Keep bgSyncInProgress set until the entire fetch-and-screen cycle completes. ··· 2278 2414 } 2279 2415 // bgSyncInProgress stays set - will be cleared in bgScreenDoneMsg 2280 2416 return m, m.bgExecAutoScreenCmd(moves) 2417 + 2418 + case bgVipFolderFetchedMsg: 2419 + if msg.emails == nil { 2420 + return m, nil // transient error; next tick retries 2421 + } 2422 + m.maybeNotifyInbox(msg.folder, msg.emails, nil) 2423 + return m, nil 2281 2424 2282 2425 case bgScreenDoneMsg: 2283 2426 // Background sync cycle complete - clear the guard flag ··· 3336 3479 m.isError = false 3337 3480 return m, m.downloadEMLCmd() 3338 3481 } 3482 + // space + n / N = add open email's sender (or @domain) to notify.txt 3483 + if key == "n" || key == "N" { 3484 + if m.openEmail == nil { 3485 + return m, nil 3486 + } 3487 + entry := strings.ToLower(extractEmailAddr(m.openEmail.From)) 3488 + if key == "N" { 3489 + entry = domainEntry(m.openEmail.From) 3490 + } 3491 + if entry == "" { 3492 + m.status = "Could not parse sender address." 3493 + m.isError = true 3494 + return m, nil 3495 + } 3496 + if err := m.screener.AddNotify(entry); err != nil { 3497 + m.status = "Add to notify.txt failed: " + err.Error() 3498 + m.isError = true 3499 + return m, nil 3500 + } 3501 + m.status = fmt.Sprintf("Added %s to notify.txt — desktop notifications will fire for new mail from this %s.", 3502 + entry, map[bool]string{true: "domain", false: "sender"}[key == "N"]) 3503 + return m, nil 3504 + } 3339 3505 // Not a digit or 'l' — fall through 3340 3506 case "l": // l + first digit (waiting for second digit) 3341 3507 if len(key) == 1 && key >= "0" && key <= "9" { ··· 3472 3638 } else if len(m.openLinks) > 0 { 3473 3639 hints = append(hints, "1-0 links") 3474 3640 } 3475 - hints = append(hints, "d download .eml") 3641 + hints = append(hints, "d download .eml", "n add sender to notify.txt", "N add @domain to notify.txt") 3476 3642 m.status = "space: " + strings.Join(hints, " · ") 3477 3643 return m, nil 3478 3644 case "g":