···11# Changelog
2233+# 2026-05-03
44+- **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/)
55+- **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
66+- **`: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
77+- **`: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
88+- **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
99+- **`<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
1010+- **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
1111+- **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
1212+- **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?"
1313+- **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
1414+315# 2026-04-30
416- **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
517
···66package notify
7788import (
99+ "bytes"
1010+ "fmt"
911 "os/exec"
1012 "strconv"
1113 "strings"
···3335}
34363537// Send fires a single notification synchronously. Safe to call when disabled
3636-// (returns nil). Errors from the underlying command are swallowed by callers
3737-// — a failed notification should never break the email flow.
3838+// (returns nil). Returned error wraps the command's stderr so callers can
3939+// surface useful diagnostics in the TUI status bar.
3840func (n *Notifier) Send(title, body string) error {
3941 if !n.cfg.Enabled {
4042 return nil
···4648 title,
4749 truncate(body, 200),
4850 }
4949- return exec.Command(n.cfg.Command, args...).Run()
5151+ cmd := exec.Command(n.cfg.Command, args...)
5252+ var stderr bytes.Buffer
5353+ cmd.Stderr = &stderr
5454+ if err := cmd.Run(); err != nil {
5555+ msg := strings.TrimSpace(stderr.String())
5656+ if msg == "" {
5757+ return err
5858+ }
5959+ return fmt.Errorf("%s: %s", n.cfg.Command, msg)
6060+ }
6161+ return nil
6262+}
6363+6464+// Result summarises a MaybeNotify pass. The counters help diagnose why a
6565+// notification did or did not fire.
6666+type Result struct {
6767+ Sent int // notifications dispatched without error
6868+ Failed int // notifications attempted but the command returned an error
6969+ Err string // last error message (if any)
7070+ NewSinceBase int // emails whose UID was strictly above the recorded baseline
7171+ MatchedNotify int // of those, how many had a sender on the notify list
7272+ FolderAllowed int // of those, how many landed in a folder in the allowlist
7373+ HadBaseline bool // false → first-run pass, no notifications fire by design
7474+ Baseline uint32 // baseline used for the comparison
7575+ MaxUIDObserved uint32 // highest UID seen in this batch
5076}
51775278// MaybeNotify processes a freshly fetched batch of emails from sourceFolder.
···6389// after auto-screening (caller computes this from screener.ClassifyForScreen).
6490// UIDs missing from dstByUID are assumed to stay in sourceFolder.
6591//
6666-// Returns the number of notifications dispatched.
6767-func (n *Notifier) MaybeNotify(account, sourceFolder string, emails []imap.Email, dstByUID map[uint32]string, sc *screener.Screener, state *State) int {
9292+// Returns a Result describing how many notifications fired and the last
9393+// error encountered (if any).
9494+func (n *Notifier) MaybeNotify(account, sourceFolder string, emails []imap.Email, dstByUID map[uint32]string, sc *screener.Screener, state *State) Result {
9595+ var res Result
6896 if !n.Enabled() || sc == nil || state == nil || len(emails) == 0 {
6969- return 0
9797+ return res
7098 }
7199 key := stateKey(account, sourceFolder)
72100 baseline, hadBaseline := state.Get(key)
101101+ res.HadBaseline = hadBaseline
102102+ res.Baseline = baseline
7310374104 var maxUID uint32
7575- sent := 0
76105 for i := range emails {
77106 e := &emails[i]
78107 if e.UID > maxUID {
···81110 if !hadBaseline || e.UID <= baseline {
82111 continue
83112 }
113113+ res.NewSinceBase++
84114 if !sc.ShouldNotify(e.From) {
85115 continue
86116 }
117117+ res.MatchedNotify++
87118 dst, ok := dstByUID[e.UID]
88119 if !ok {
89120 dst = sourceFolder
···91122 if !n.cfg.FolderAllowed(dst) {
92123 continue
93124 }
125125+ res.FolderAllowed++
94126 title := "neomd: " + truncate(e.From, 80)
95127 body := e.Subject
96128 if body == "" {
97129 body = "(no subject)"
98130 }
9999- if err := n.Send(title, body); err == nil {
100100- sent++
131131+ if err := n.Send(title, body); err != nil {
132132+ res.Failed++
133133+ res.Err = err.Error()
134134+ continue
101135 }
136136+ res.Sent++
102137 }
138138+ res.MaxUIDObserved = maxUID
103139104140 if maxUID > baseline {
105141 state.Set(key, maxUID)
106142 _ = state.Save()
107143 }
108108- return sent
144144+ return res
109145}
110146111147func stateKey(account, folder string) string {
+12-12
internal/notify/notify_test.go
···7373 {UID: 100, From: "vip@example.com", Subject: "hi"},
7474 {UID: 101, From: "other@example.com", Subject: "hello"},
7575 }
7676- sent := n.MaybeNotify("acct", "Inbox", emails, nil, sc, state)
7777- if sent != 0 {
7878- t.Errorf("first run sent = %d, want 0 (baseline-only pass)", sent)
7676+ res := n.MaybeNotify("acct", "Inbox", emails, nil, sc, state)
7777+ if res.Sent != 0 {
7878+ t.Errorf("first run sent = %d, want 0 (baseline-only pass)", res.Sent)
7979 }
8080 uid, ok := state.Get(stateKey("acct", "Inbox"))
8181 if !ok || uid != 101 {
···9696 {UID: 101, From: "vip@example.com", Subject: "new!"}, // new + on notify list
9797 {UID: 102, From: "other@example.com", Subject: "noise"}, // new but not on notify list
9898 }
9999- sent := n.MaybeNotify("acct", "Inbox", emails, nil, sc, state)
100100- if sent != 1 {
101101- t.Errorf("sent = %d, want 1", sent)
9999+ res := n.MaybeNotify("acct", "Inbox", emails, nil, sc, state)
100100+ if res.Sent != 1 {
101101+ t.Errorf("sent = %d, want 1", res.Sent)
102102 }
103103}
104104···115115 {UID: 2, From: "bob@important.org", Subject: "y"},
116116 {UID: 3, From: "spam@nowhere.com", Subject: "z"},
117117 }
118118- sent := n.MaybeNotify("acct", "Inbox", emails, nil, sc, state)
119119- if sent != 2 {
120120- t.Errorf("sent = %d, want 2 (both @important.org senders)", sent)
118118+ res := n.MaybeNotify("acct", "Inbox", emails, nil, sc, state)
119119+ if res.Sent != 2 {
120120+ t.Errorf("sent = %d, want 2 (both @important.org senders)", res.Sent)
121121 }
122122}
123123···134134 }
135135 // Email is auto-screened to Feed → allowlist excludes it.
136136 dst := map[uint32]string{5: "Feed"}
137137- sent := n.MaybeNotify("acct", "Inbox", emails, dst, sc, state)
138138- if sent != 0 {
139139- t.Errorf("sent = %d, want 0 (Feed not in allowlist)", sent)
137137+ res := n.MaybeNotify("acct", "Inbox", emails, dst, sc, state)
138138+ if res.Sent != 0 {
139139+ t.Errorf("sent = %d, want 0 (Feed not in allowlist)", res.Sent)
140140 }
141141}
142142
+19
internal/screener/screener.go
···192192 return domainMatch(addr, set)
193193}
194194195195+// AddNotify appends a sender (or "@domain" entry) to notify.txt. No-op when
196196+// the entry is already present or when no notify list is configured.
197197+// Independent of the screening category — does not touch screened_in /
198198+// screened_out / feed / papertrail / spam.
199199+func (s *Screener) AddNotify(from string) error {
200200+ if s.cfg.Notify == "" {
201201+ return fmt.Errorf("notify list path not configured (set [screener].notify in config.toml)")
202202+ }
203203+ return s.addToList(s.cfg.Notify, s.notify, from)
204204+}
205205+206206+// RemoveNotify deletes a sender (or "@domain" entry) from notify.txt.
207207+func (s *Screener) RemoveNotify(from string) error {
208208+ if s.cfg.Notify == "" {
209209+ return nil
210210+ }
211211+ return s.removeFromList(s.cfg.Notify, s.notify, from)
212212+}
213213+195214// domainMatch returns true only via the "@domain" form (skipping the exact
196215// check). Used by Classify so that the priority loop can be split into an
197216// exact-match pass first, then a domain-match pass.
+94
internal/screener/screener_test.go
···504504 }
505505 })
506506507507+ t.Run("AddNotify appends and persists, RemoveNotify clears", func(t *testing.T) {
508508+ dir := t.TempDir()
509509+ notifyPath := filepath.Join(dir, "notify.txt")
510510+ s, err := New(Config{
511511+ ScreenedIn: filepath.Join(dir, "in.txt"),
512512+ ScreenedOut: filepath.Join(dir, "out.txt"),
513513+ Feed: filepath.Join(dir, "feed.txt"),
514514+ PaperTrail: filepath.Join(dir, "pt.txt"),
515515+ Spam: filepath.Join(dir, "spam.txt"),
516516+ Notify: notifyPath,
517517+ })
518518+ if err != nil {
519519+ t.Fatal(err)
520520+ }
521521+ if err := s.AddNotify("alice@example.com"); err != nil {
522522+ t.Fatalf("AddNotify exact: %v", err)
523523+ }
524524+ if err := s.AddNotify("@important.org"); err != nil {
525525+ t.Fatalf("AddNotify domain: %v", err)
526526+ }
527527+ // Re-add is no-op (no duplicate).
528528+ if err := s.AddNotify("alice@example.com"); err != nil {
529529+ t.Fatalf("AddNotify duplicate: %v", err)
530530+ }
531531+ if !s.ShouldNotify("alice@example.com") {
532532+ t.Error("alice should notify")
533533+ }
534534+ if !s.ShouldNotify("anyone@important.org") {
535535+ t.Error("@important.org should match anyone@important.org")
536536+ }
537537+ // Reload from disk to confirm persistence.
538538+ s2, err := New(s.cfg)
539539+ if err != nil {
540540+ t.Fatal(err)
541541+ }
542542+ if !s2.ShouldNotify("alice@example.com") || !s2.ShouldNotify("bob@important.org") {
543543+ t.Error("reload lost entries")
544544+ }
545545+ // Remove.
546546+ if err := s.RemoveNotify("alice@example.com"); err != nil {
547547+ t.Fatal(err)
548548+ }
549549+ if s.ShouldNotify("alice@example.com") {
550550+ t.Error("removed entry should not match")
551551+ }
552552+ })
553553+554554+ t.Run("AddNotify errors when path not configured", func(t *testing.T) {
555555+ s := &Screener{notify: map[string]bool{}, cfg: Config{}}
556556+ if err := s.AddNotify("x@example.com"); err == nil {
557557+ t.Error("expected error when notify path empty")
558558+ }
559559+ })
560560+561561+ t.Run("ShouldNotify is false when Notify path is omitted from Config (regression)", func(t *testing.T) {
562562+ // Regression: cmd/neomd/main.go used to construct screener.Config without
563563+ // the Notify field, so the in-memory notify set stayed empty even when
564564+ // notify.txt had entries. Result: ShouldNotify returned false silently.
565565+ dir := t.TempDir()
566566+ notifyPath := filepath.Join(dir, "notify.txt")
567567+ os.WriteFile(notifyPath, []byte("vip@example.com\n"), 0600)
568568+ // Build a screener WITHOUT passing Notify — mimics the broken main.go
569569+ // before the fix.
570570+ s, err := New(Config{
571571+ ScreenedIn: filepath.Join(dir, "in.txt"),
572572+ ScreenedOut: filepath.Join(dir, "out.txt"),
573573+ Feed: filepath.Join(dir, "feed.txt"),
574574+ PaperTrail: filepath.Join(dir, "pt.txt"),
575575+ Spam: filepath.Join(dir, "spam.txt"),
576576+ // Notify intentionally unset
577577+ })
578578+ if err != nil {
579579+ t.Fatal(err)
580580+ }
581581+ if s.ShouldNotify("vip@example.com") {
582582+ t.Fatal("ShouldNotify should be false when Notify path is omitted")
583583+ }
584584+ // Now pass Notify — ShouldNotify must return true.
585585+ s2, err := New(Config{
586586+ ScreenedIn: filepath.Join(dir, "in.txt"),
587587+ ScreenedOut: filepath.Join(dir, "out.txt"),
588588+ Feed: filepath.Join(dir, "feed.txt"),
589589+ PaperTrail: filepath.Join(dir, "pt.txt"),
590590+ Spam: filepath.Join(dir, "spam.txt"),
591591+ Notify: notifyPath,
592592+ })
593593+ if err != nil {
594594+ t.Fatal(err)
595595+ }
596596+ if !s2.ShouldNotify("vip@example.com") {
597597+ t.Fatal("ShouldNotify should return true when Notify path is wired up")
598598+ }
599599+ })
600600+507601 t.Run("Notify path empty leaves the list empty", func(t *testing.T) {
508602 dir := t.TempDir()
509603 s, err := New(Config{
+20
internal/ui/cmdline.go
···236236 },
237237 },
238238 {
239239+ name: "notify-test",
240240+ aliases: []string{"nt"},
241241+ desc: "fire a single test desktop notification using the current [notifications] config (diagnostic)",
242242+ run: func(m *Model) (tea.Model, tea.Cmd) {
243243+ if !m.notifier.Enabled() {
244244+ m.status = "Notifications disabled. Set [notifications].enabled = true in config.toml."
245245+ m.isError = true
246246+ return m, nil
247247+ }
248248+ if err := m.notifier.Send("neomd: test", "If you can see this, notify-send works."); err != nil {
249249+ m.status = "notify-send failed: " + err.Error()
250250+ m.isError = true
251251+ return m, nil
252252+ }
253253+ m.status = "Test notification sent — check your desktop notifications. Listening folders: " +
254254+ strings.Join(m.cfg.Notifications.Resolved().Folders, ", ")
255255+ return m, nil
256256+ },
257257+ },
258258+ {
239259 name: "quit",
240260 aliases: []string{"q"},
241261 desc: "quit neomd",
+3
internal/ui/keys.go
···7575 {"<space>/", "IMAP search ALL emails on server (From + Subject)"},
7676 {"<space>S", "scan current folder for spy pixels (skips already scanned)"},
7777 {"<space>d (reader)", "download raw email source (.eml) to ~/Downloads"},
7878+ {"<space>n (reader)", "append open email's sender to notify.txt (desktop notifications opt-in)"},
7979+ {"<space>N (reader)", "append @domain of open email's sender to notify.txt"},
7880 {"<space>w", "show welcome screen"},
7981 }},
8082 {"Sort (, prefix)", [][2]string{
···131133 {":create-folders / :cf", "create missing IMAP folders from config (safe, idempotent)"},
132134 {":go-spam / :spam", "open Spam folder (not in tab rotation)"},
133135 {":debug / :dbg", "diagnostic report — IMAP ping, config, folders, state (saved to /tmp/neomd/debug.log)"},
136136+ {":notify-test / :nt", "fire a single test desktop notification using the current [notifications] config"},
134137 {":quit / :q", "quit neomd"},
135138 }},
136139 {"Composing", [][2]string{
+171-5
internal/ui/model.go
···129129 bgSyncTickMsg struct{}
130130 bgInboxFetchedMsg struct{ emails []imap.Email }
131131 bgScreenDoneMsg struct{ moved, total int }
132132+133133+ // bgVipFolderFetchedMsg carries a non-Inbox folder fetch used purely to
134134+ // dispatch desktop notifications for VIP senders whose mail the daemon
135135+ // (or any other sync path) may have already moved out of Inbox.
136136+ bgVipFolderFetchedMsg struct {
137137+ folder string
138138+ emails []imap.Email
139139+ }
132140 // mark-as-read timer (fires after N seconds in reader)
133141 markAsReadTimerMsg struct {
134142 uid uint32
···340348 if i < len(m.accounts) {
341349 name = m.accounts[i].Name
342350 }
351351+ if cli == nil {
352352+ // imap_disabled accounts have a nil client by design (send-only).
353353+ b.WriteString(fmt.Sprintf("- **%s** — IMAP disabled (send-only)\n", name))
354354+ continue
355355+ }
343356 b.WriteString(fmt.Sprintf("- **%s** → `%s`\n", name, cli.Addr()))
344357 if err := cli.Ping(nil); err != nil {
345358 b.WriteString(fmt.Sprintf(" - PING: FAILED — `%s`\n", err))
···377390 lists := [][2]string{
378391 {"screened_in", sc.ScreenedIn}, {"screened_out", sc.ScreenedOut},
379392 {"feed", sc.Feed}, {"papertrail", sc.PaperTrail}, {"spam", sc.Spam},
393393+ {"notify", sc.Notify},
380394 }
381395 for _, kv := range lists {
382396 path := kv[1]
···391405 b.WriteString(fmt.Sprintf("- %s: `%s` (%d bytes)\n", kv[0], path, info.Size()))
392406 }
393407 }
408408+409409+ // Notifications
410410+ b.WriteString("\n## Notifications\n\n")
411411+ nc := m.cfg.Notifications.Resolved()
412412+ b.WriteString(fmt.Sprintf("- enabled: %v\n", nc.Enabled))
413413+ b.WriteString(fmt.Sprintf("- command: `%s`\n", nc.Command))
414414+ b.WriteString(fmt.Sprintf("- icon: `%s`\n", nc.Icon))
415415+ b.WriteString(fmt.Sprintf("- expire_ms: %d\n", nc.ExpireMs))
416416+ b.WriteString(fmt.Sprintf("- folders (allowlist): %s\n", strings.Join(nc.Folders, ", ")))
417417+ statePath := config.NotifyStatePath()
418418+ if data, err := os.ReadFile(statePath); err == nil {
419419+ b.WriteString(fmt.Sprintf("- state file: `%s` → `%s`\n", statePath, strings.TrimSpace(string(data))))
420420+ } else {
421421+ b.WriteString(fmt.Sprintf("- state file: `%s` (not yet written)\n", statePath))
422422+ }
423423+ b.WriteString(fmt.Sprintf("- bg-poll folders (non-Inbox): %s\n", strings.Join(m.vipNotifyFolders(), ", ")))
394424395425 // UI config
396426 b.WriteString("\n## UI Config\n\n")
···14181448// emails. moves describes where each email is heading after auto-screening
14191449// (pass nil if no auto-screen pass ran). Folder is the IMAP source folder
14201450// label the fetch came from. No-op when the notifier is disabled.
14211421-func (m Model) maybeNotifyInbox(folder string, emails []imap.Email, moves []autoScreenMove) {
14511451+//
14521452+// Reports activity on the TUI status bar so the user can confirm the pipeline
14531453+// is wired up — notifications themselves are easy to miss.
14541454+func (m *Model) maybeNotifyInbox(folder string, emails []imap.Email, moves []autoScreenMove) {
14221455 if !m.notifier.Enabled() || len(emails) == 0 {
14231456 return
14241457 }
···14281461 dstByUID[mv.email.UID] = mv.dst
14291462 }
14301463 }
14311431- m.notifier.MaybeNotify(m.activeAccount().Name, folder, emails, dstByUID, m.screener, m.notifyState)
14641464+ res := m.notifier.MaybeNotify(m.activeAccount().Name, folder, emails, dstByUID, m.screener, m.notifyState)
14651465+ switch {
14661466+ case res.Failed > 0:
14671467+ m.status = fmt.Sprintf("Notification command failed (%d): %s", res.Failed, res.Err)
14681468+ m.isError = true
14691469+ case res.Sent > 0:
14701470+ m.status = fmt.Sprintf("Notified %d VIP sender(s) in %s.", res.Sent, folder)
14711471+ 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)
14731473+ case res.NewSinceBase == 0:
14741474+ // No new emails — silent (avoid noisy status on every refresh).
14751475+ case res.MatchedNotify == 0:
14761476+ 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)
14781478+ case res.FolderAllowed == 0:
14791479+ 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)
14811481+ }
14321482}
1433148314341484// previewAutoScreen classifies the currently loaded inbox emails (no IMAP).
···16531703 }
16541704}
1655170517061706+// bgFetchVipFolderCmd silently fetches headers from one configured
17071707+// notification folder (other than Inbox, which is handled by the regular bg
17081708+// sync). The result feeds straight into the notifier — no auto-screening,
17091709+// no UI mutation beyond a brief status update.
17101710+func (m Model) bgFetchVipFolderCmd(folder string) tea.Cmd {
17111711+ return func() tea.Msg {
17121712+ cli := m.imapCli()
17131713+ if cli == nil {
17141714+ return bgVipFolderFetchedMsg{folder: folder, emails: nil}
17151715+ }
17161716+ cli.ResetMailboxSelection()
17171717+ emails, err := cli.FetchHeaders(nil, folder, m.cfg.UI.InboxCount)
17181718+ if err != nil {
17191719+ return bgVipFolderFetchedMsg{folder: folder, emails: nil}
17201720+ }
17211721+ return bgVipFolderFetchedMsg{folder: folder, emails: emails}
17221722+ }
17231723+}
17241724+17251725+// vipNotifyFolders returns the configured non-Inbox folder labels (mapped to
17261726+// IMAP folder names) that should be polled for VIP notifications by the
17271727+// background sync. Empty when the notifier is disabled or only "Inbox" is set.
17281728+func (m Model) vipNotifyFolders() []string {
17291729+ if !m.notifier.Enabled() {
17301730+ return nil
17311731+ }
17321732+ cfg := m.cfg.Notifications.Resolved()
17331733+ var out []string
17341734+ for _, label := range cfg.Folders {
17351735+ if strings.EqualFold(label, "Inbox") {
17361736+ continue // Inbox is already covered by bgFetchInboxCmd
17371737+ }
17381738+ if dst := folderLabelToIMAP(label, m.cfg.Folders); dst != "" {
17391739+ out = append(out, dst)
17401740+ }
17411741+ }
17421742+ return out
17431743+}
17441744+17451745+// folderLabelToIMAP maps a UI folder label (e.g. "PaperTrail") to its
17461746+// configured IMAP folder name. Returns "" for unknown labels.
17471747+func folderLabelToIMAP(label string, fc config.FoldersConfig) string {
17481748+ switch strings.ToLower(label) {
17491749+ case "inbox":
17501750+ return fc.Inbox
17511751+ case "sent":
17521752+ return fc.Sent
17531753+ case "trash":
17541754+ return fc.Trash
17551755+ case "drafts":
17561756+ return fc.Drafts
17571757+ case "toscreen":
17581758+ return fc.ToScreen
17591759+ case "feed":
17601760+ return fc.Feed
17611761+ case "papertrail":
17621762+ return fc.PaperTrail
17631763+ case "screenedout":
17641764+ return fc.ScreenedOut
17651765+ case "archive":
17661766+ return fc.Archive
17671767+ case "waiting":
17681768+ return fc.Waiting
17691769+ case "scheduled":
17701770+ return fc.Scheduled
17711771+ case "someday":
17721772+ return fc.Someday
17731773+ case "spam":
17741774+ return fc.Spam
17751775+ case "work":
17761776+ return fc.Work
17771777+ }
17781778+ return ""
17791779+}
17801780+16561781// bgExecAutoScreenCmd silently moves emails and returns bgScreenDoneMsg.
16571782func (m Model) bgExecAutoScreenCmd(moves []autoScreenMove) tea.Cmd {
16581783 src := m.cfg.Folders.Inbox
···18351960 return m, tea.Batch(sortCmd, m.fetchFolderCountsCmd(), m.spinner.Tick, m.execAutoScreenCmd(moves))
18361961 }
18371962 }
18381838- if msg.folder == m.cfg.Folders.Inbox {
19631963+ // Notify on any folder load that matches the user's allowlist — not
19641964+ // just Inbox. Covers the case where a headless daemon has already
19651965+ // screened a VIP email out of Inbox into PaperTrail/Feed before the
19661966+ // TUI sees it; the notification still fires when the user (or the
19671967+ // next refresh) loads that destination folder.
19681968+ if m.cfg.Notifications.FolderAllowed(msg.folder) {
18391969 m.maybeNotifyInbox(msg.folder, msg.emails, nil)
18401970 }
18411971 return m, tea.Batch(sortCmd, m.fetchFolderCountsCmd())
···22522382 return m, m.scheduleBgSync() // just reschedule the next tick
22532383 }
22542384 // Fire background inbox fetch; reschedule next tick in parallel.
23852385+ // Also poll any extra notification folders (e.g. PaperTrail, Feed)
23862386+ // so VIP mail screened out of Inbox by the daemon still notifies.
22552387 m.bgSyncInProgress = true
22562256- return m, tea.Batch(m.bgFetchInboxCmd(), m.scheduleBgSync())
23882388+ cmds := []tea.Cmd{m.bgFetchInboxCmd(), m.scheduleBgSync()}
23892389+ for _, f := range m.vipNotifyFolders() {
23902390+ cmds = append(cmds, m.bgFetchVipFolderCmd(f))
23912391+ }
23922392+ return m, tea.Batch(cmds...)
2257239322582394 case bgInboxFetchedMsg:
22592395 // Keep bgSyncInProgress set until the entire fetch-and-screen cycle completes.
···22782414 }
22792415 // bgSyncInProgress stays set - will be cleared in bgScreenDoneMsg
22802416 return m, m.bgExecAutoScreenCmd(moves)
24172417+24182418+ case bgVipFolderFetchedMsg:
24192419+ if msg.emails == nil {
24202420+ return m, nil // transient error; next tick retries
24212421+ }
24222422+ m.maybeNotifyInbox(msg.folder, msg.emails, nil)
24232423+ return m, nil
2281242422822425 case bgScreenDoneMsg:
22832426 // Background sync cycle complete - clear the guard flag
···33363479 m.isError = false
33373480 return m, m.downloadEMLCmd()
33383481 }
34823482+ // space + n / N = add open email's sender (or @domain) to notify.txt
34833483+ if key == "n" || key == "N" {
34843484+ if m.openEmail == nil {
34853485+ return m, nil
34863486+ }
34873487+ entry := strings.ToLower(extractEmailAddr(m.openEmail.From))
34883488+ if key == "N" {
34893489+ entry = domainEntry(m.openEmail.From)
34903490+ }
34913491+ if entry == "" {
34923492+ m.status = "Could not parse sender address."
34933493+ m.isError = true
34943494+ return m, nil
34953495+ }
34963496+ if err := m.screener.AddNotify(entry); err != nil {
34973497+ m.status = "Add to notify.txt failed: " + err.Error()
34983498+ m.isError = true
34993499+ return m, nil
35003500+ }
35013501+ m.status = fmt.Sprintf("Added %s to notify.txt — desktop notifications will fire for new mail from this %s.",
35023502+ entry, map[bool]string{true: "domain", false: "sender"}[key == "N"])
35033503+ return m, nil
35043504+ }
33393505 // Not a digit or 'l' — fall through
33403506 case "l": // l + first digit (waiting for second digit)
33413507 if len(key) == 1 && key >= "0" && key <= "9" {
···34723638 } else if len(m.openLinks) > 0 {
34733639 hints = append(hints, "1-0 links")
34743640 }
34753475- hints = append(hints, "d download .eml")
36413641+ hints = append(hints, "d download .eml", "n add sender to notify.txt", "N add @domain to notify.txt")
34763642 m.status = "space: " + strings.Join(hints, " · ")
34773643 return m, nil
34783644 case "g":