···148148- **Conversation view** — `T` or `:thread` shows the full conversation across folders (Inbox, Sent, Archive, etc.) in a temporary tab with `[Folder]` prefix; see your replies alongside received emails [more](https://neomd.ssp.sh/docs/reading/#conversation-view)
149149- **Glamour reading** — incoming emails rendered as styled Markdown in the terminal [more](https://neomd.ssp.sh/docs/reading/)
150150- **HEY-style screener** — unknown senders land in `ToScreen`; press `I/O/F/P` to approve, block, mark as Feed, or mark as PaperTrail; reuses your existing `screened_in.txt` lists from neomutt; also acts as a **phishing defense** — impersonation emails from senders you've already approved land in ToScreen instead of Inbox, making them immediately suspicious [more](https://neomd.ssp.sh/docs/screener/)
151151+- **Whole-domain screening** — list entries beginning with `@` (e.g. `@ssp.sh`) match every address at that domain; per-address entries always win over a domain rule so a single blocked address inside an otherwise-approved domain stays blocked. Press `Di` / `Do` on the highlighted/open email to append `@<domain>` to `screened_in.txt` / `screened_out.txt` (with a `y/n` confirm) [more](https://neomd.ssp.sh/docs/screener/#domain-entries)
152152+- **Desktop notifications for VIP senders** — opt-in `[notifications]` block fires `notify-send` (or any compatible CLI) only for senders/domains you list in `notify.txt`; independent of the screener categories so you can be quiet on your inbox but still be paged for the boss; first run silently records a baseline so you don't get flooded by your existing inbox [more](https://neomd.ssp.sh/docs/notifications/)
151153- **Folder tabs** — Inbox, ToScreen, Feed, PaperTrail, Archive, Waiting, Someday, Scheduled, Sent, Trash, ScreenedOut [more](https://neomd.ssp.sh/docs/keybindings/#folders)
152154- **Emoji reactions** — press `ctrl+e` from inbox or reader to react with emoji (👍 ❤️ 😂 🎉 🙏 💯 👀 ✅); instant send with proper threading and quoted message history, no editor needed; reactions appear in conversation threads with neomd branding [more](https://neomd.ssp.sh/docs/sending/#emoji-reactions)
153155- **Spy pixel blocking** — tracking pixels from newsletter services (Mailchimp, SendGrid, HubSpot, etc.) are automatically detected, counted, and stripped; `°` indicator in the inbox and tracker domains in the reader header; browser view (`O`) blocks remote images via CSP — senders cannot tell if you read their email, similar to [HEY's spy pixel blocker](https://www.hey.com/features/spy-pixel-blocker/) [more](https://neomd.ssp.sh/docs/reading/#spy-pixel-blocking)
+2
docs/content/docs/_index.md
···151151- **Conversation view** — `T` or `:thread` shows the full conversation across folders (Inbox, Sent, Archive, etc.) in a temporary tab with `[Folder]` prefix; see your replies alongside received emails [more](https://neomd.ssp.sh/docs/reading/#conversation-view)
152152- **Glamour reading** — incoming emails rendered as styled Markdown in the terminal [more](https://neomd.ssp.sh/docs/reading/)
153153- **HEY-style screener** — unknown senders land in `ToScreen`; press `I/O/F/P` to approve, block, mark as Feed, or mark as PaperTrail; reuses your existing `screened_in.txt` lists from neomutt; also acts as a **phishing defense** — impersonation emails from senders you've already approved land in ToScreen instead of Inbox, making them immediately suspicious [more](https://neomd.ssp.sh/docs/screener/)
154154+- **Whole-domain screening** — list entries beginning with `@` (e.g. `@ssp.sh`) match every address at that domain; per-address entries always win over a domain rule so a single blocked address inside an otherwise-approved domain stays blocked. Press `Di` / `Do` on the highlighted/open email to append `@<domain>` to `screened_in.txt` / `screened_out.txt` (with a `y/n` confirm) [more](https://neomd.ssp.sh/docs/screener/#domain-entries)
155155+- **Desktop notifications for VIP senders** — opt-in `[notifications]` block fires `notify-send` (or any compatible CLI) only for senders/domains you list in `notify.txt`; independent of the screener categories so you can be quiet on your inbox but still be paged for the boss; first run silently records a baseline so you don't get flooded by your existing inbox [more](https://neomd.ssp.sh/docs/notifications/)
154156- **Folder tabs** — Inbox, ToScreen, Feed, PaperTrail, Archive, Waiting, Someday, Scheduled, Sent, Trash, ScreenedOut [more](https://neomd.ssp.sh/docs/keybindings/#folders)
155157- **Emoji reactions** — press `ctrl+e` from inbox or reader to react with emoji (👍 ❤️ 😂 🎉 🙏 💯 👀 ✅); instant send with proper threading and quoted message history, no editor needed; reactions appear in conversation threads with neomd branding [more](https://neomd.ssp.sh/docs/sending/#emoji-reactions)
156158- **Spy pixel blocking** — tracking pixels from newsletter services (Mailchimp, SendGrid, HubSpot, etc.) are automatically detected, counted, and stripped; `°` indicator in the inbox and tracker domains in the reader header; browser view (`O`) blocks remote images via CSP — senders cannot tell if you read their email, similar to [HEY's spy pixel blocker](https://www.hey.com/features/spy-pixel-blocker/) [more](https://neomd.ssp.sh/docs/reading/#spy-pixel-blocking)
+2
docs/content/docs/keybindings.md
···5757| `$` | mark as Spam → spam.txt + move to Spam (removes from screened_in/out) |
5858| `F` | mark as Feed → feed.txt + move to Feed |
5959| `P` | mark as PaperTrail → papertrail.txt + move to PaperTrail |
6060+| `Di` | approve whole DOMAIN → @domain.tld appended to screened_in.txt (cursor or open email; y/n) |
6161+| `Do` | block whole DOMAIN → @domain.tld appended to screened_out.txt (cursor or open email; y/n) |
6062| `A` | archive (move to Archive, no screener update) |
6163| `B` | move to Work/business (no screener update, if configured) |
6264| `S` | dry-run screen inbox (loaded emails), then y/n |
+112
docs/content/docs/notifications.md
···11+---
22+title: Notifications
33+weight: 6
44+---
55+66+neomd can fire desktop notifications via `notify-send` (or any compatible CLI) when **specific** senders deliver new mail. The list of "notify-worthy" senders is independent of the screener categories — being approved (`screened_in`) does not automatically mean you want to be paged about it.
77+88+Notifications fire from the **TUI only**. The headless daemon (`neomd --headless`) screens mail silently so notifications don't end up on a NAS where no one sees them.
99+1010+## Quick start
1111+1212+1. Make sure `notify-send` is installed and a notification daemon is running (`mako`, `dunst`, `swaync`, …). On Hyprland with mako:
1313+1414+ ```sh
1515+ notify-send "neomd" "test"
1616+ ```
1717+1818+ should pop up a notification.
1919+2020+2. Add the senders (or domains) you care about to `~/.config/neomd/lists/notify.txt`, one per line:
2121+2222+ ```
2323+ # exact addresses
2424+ alice@example.com
2525+ boss@work.com
2626+2727+ # whole domain (any address at the domain)
2828+ @important.org
2929+ ```
3030+3131+3. Enable notifications in `~/.config/neomd/config.toml`:
3232+3333+ ```toml
3434+ [notifications]
3535+ enabled = true # default: false
3636+ command = "notify-send" # default
3737+ icon = "mail-message-new" # default; passed as --icon
3838+ expire_ms = 5000 # default
3939+ folders = ["Inbox"] # only fire when the new mail lands here
4040+ ```
4141+4242+4. Restart neomd. The first inbox load **records a baseline** silently — no notifications fire for the mail you already have. From then on, any new mail whose sender matches `notify.txt` and lands in one of `folders` triggers a notification.
4343+4444+## When notifications fire
4545+4646+A notification is fired when **all** of the following are true:
4747+4848+- `[notifications].enabled = true`
4949+- The email's UID is greater than the per-folder baseline neomd has recorded (`~/.cache/neomd/notify_state.json`)
5050+- The sender (after lower-casing) matches an exact entry or `@domain` entry in `notify.txt`
5151+- After auto-screening, the email's destination folder is in `[notifications].folders`
5252+5353+If any check fails the email is processed normally but no notification fires.
5454+5555+## Domain entries
5656+5757+Same syntax as the screener lists — a line starting with `@` matches every address at that domain:
5858+5959+```
6060+# notify.txt
6161+@ssp.sh
6262+ceo@bigcorp.com
6363+```
6464+6565+Exact entries match before `@domain` entries, but for `notify.txt` the priority doesn't matter (both produce the same notification). Use whichever is more convenient.
6666+6767+## Configuration reference
6868+6969+| Field | Default | Description |
7070+| ------------ | ------------------ | ---------------------------------------------------------------------------- |
7171+| `enabled` | `false` | Master switch; opt-in. |
7272+| `command` | `"notify-send"` | Notification binary. See [Custom command](#custom-command) below. |
7373+| `icon` | `"mail-message-new"` | Passed as `-i`/`--icon`. |
7474+| `expire_ms` | `5000` | Passed as `-t`. Milliseconds the notification stays on screen. |
7575+| `folders` | `["Inbox"]` | Folder labels (case-insensitive) that count for notifications. |
7676+7777+### Notification content
7878+7979+Notifications are sent with these arguments:
8080+8181+```
8282+notify-send -i <icon> -t <expire_ms> -a neomd "neomd: <From>" "<Subject>"
8383+```
8484+8585+Subjects longer than 200 characters are truncated with `…`.
8686+8787+### Custom command
8888+8989+`command` is passed to `os/exec` directly with the same positional arguments as `notify-send`. If you want to use a non-`notify-send`-compatible notifier (e.g. `hyprctl notify`, which takes a totally different argument layout), wrap it in a small shell script:
9090+9191+```sh
9292+#!/usr/bin/env bash
9393+# ~/.local/bin/neomd-hyprctl-notify
9494+# Usage: <-i icon> <-t expire_ms> <-a app> <title> <body>
9595+shift 6
9696+hyprctl notify 0 5000 "rgb(00ff00)" "$1: $2"
9797+```
9898+9999+Then in `config.toml`:
100100+101101+```toml
102102+[notifications]
103103+command = "/home/you/.local/bin/neomd-hyprctl-notify"
104104+```
105105+106106+## State file
107107+108108+The per-folder baseline UID is stored at `~/.cache/neomd/notify_state.json` (`~/.cache/neomd-demo/...` for the demo config). Deleting this file forces a re-baseline on next launch (no notifications fire until the *next* new email arrives).
109109+110110+## Why this is opt-in
111111+112112+The default is `enabled = false` so neomd never surprises a fresh user with desktop popups. Once turned on, the **first** fetch is also silent — neomd records the highest UID it sees and only notifies for messages newer than that. This way you don't get flooded with notifications for your entire current Inbox the first time the feature is enabled.
+28
docs/content/docs/screener.md
···1313| `screened_out.txt` | Blocked | ScreenedOut |
1414| `feed.txt` | Newsletter / feed | Feed |
1515| `papertrail.txt` | Receipts / notifications | PaperTrail |
1616+| `notify.txt` | Desktop notification | (no move; only fires `notify-send` — see [Notifications](../notifications/)) |
1617| _(not in any list)_ | Unknown | ToScreen |
17181919+### Domain entries
2020+2121+Any list line beginning with `@` (e.g. `@ssp.sh`) matches **every** address at that domain. Plain email addresses keep their exact-match behaviour and **always win over a domain rule** when both are present, so per-address overrides remain possible:
2222+2323+```
2424+# screened_in.txt
2525+@ssp.sh # everyone at ssp.sh is approved …
2626+```
2727+2828+```
2929+# screened_out.txt
3030+spammy@ssp.sh # … except this one address, which is blocked
3131+```
3232+3333+Domain entries work in every screener list (`screened_in`, `screened_out`, `feed`, `papertrail`, `spam`, `notify`). The `Di` / `Do` chord (see below) writes them for you from inside neomd.
3434+1835## Auto-screen and background sync
19362037By default neomd screens your inbox automatically so you never have to press `S`:
···3754Press `S` (or run `:screen`) to dry-run the screener against the emails currently loaded in your Inbox. A preview shows what would move where — press `y` to apply, `n` to cancel.
38553956For individual senders, use `I` / `O` / `F` / `P` from any folder or the ToScreen queue.
5757+5858+### Whole-domain shortcuts: `Di` / `Do`
5959+6060+When you want to approve or block **every** future address at a domain in one go, press the `D` chord:
6161+6262+| Keys | Effect |
6363+| ---- | -------------------------------------------------------------------- |
6464+| `Di` | Append `@<domain>` to `screened_in.txt` (asks `y/n` first) |
6565+| `Do` | Append `@<domain>` to `screened_out.txt` (asks `y/n` first) |
6666+6767+The chord works on the highlighted email in the inbox **and** on the open email in the reader. The domain is taken from the email's `From` header. Existing per-address entries (in any list) still take precedence over the domain rule, so a single blocked address inside an otherwise-approved domain stays blocked.
40684169## Bulk re-classification after updating your lists
4270
+77-12
internal/config/config.go
···5959 Feed string `toml:"feed"`
6060 PaperTrail string `toml:"papertrail"`
6161 Spam string `toml:"spam"`
6262+ Notify string `toml:"notify"` // optional: addresses or @domain entries that fire desktop notifications
6363+}
6464+6565+// NotificationsConfig controls desktop notifications for emails landing in
6666+// folders the user cares about, scoped to senders listed in screener.notify.
6767+// TUI-only: the headless daemon never fires notifications.
6868+type NotificationsConfig struct {
6969+ Enabled bool `toml:"enabled"` // opt-in, default false
7070+ Command string `toml:"command"` // notify binary, default "notify-send"
7171+ Icon string `toml:"icon"` // -i/--icon arg, default "mail-message-new"
7272+ ExpireMs int `toml:"expire_ms"` // -t arg in milliseconds, default 5000
7373+ Folders []string `toml:"folders"` // folder labels (e.g. "Inbox") to fire on; default ["Inbox"]
6274}
63756476// FoldersConfig maps logical names to actual IMAP mailbox names.
···130142131143// UIConfig holds display preferences.
132144type UIConfig struct {
133133- Theme string `toml:"theme"` // dark | light | auto
134134- InboxCount int `toml:"inbox_count"` // number of messages to fetch
135135- Signature string `toml:"signature"` // legacy: plain signature (markdown). Deprecated in favor of [ui.signature] block.
136136- SignatureBlock SignatureConfig `toml:"signature_block"` // new structured signature config
137137- AutoScreenOnLoad *bool `toml:"auto_screen_on_load"` // screen inbox on every load (default true)
138138- BgSyncInterval int `toml:"bg_sync_interval"` // background sync interval in minutes (0 = disabled, default 5)
139139- BulkProgressThreshold int `toml:"bulk_progress_threshold"` // show progress counter for batches larger than this (default 10)
140140- DraftBackupCount int `toml:"draft_backup_count"` // rolling compose backups in ~/.cache/neomd/drafts/ (default 20, -1 = disabled)
141141- MarkAsReadAfterSecs int `toml:"mark_as_read_after_secs"` // seconds in reader before marking as read (0 = immediate, default 7)
145145+ Theme string `toml:"theme"` // dark | light | auto
146146+ InboxCount int `toml:"inbox_count"` // number of messages to fetch
147147+ Signature string `toml:"signature"` // legacy: plain signature (markdown). Deprecated in favor of [ui.signature] block.
148148+ SignatureBlock SignatureConfig `toml:"signature_block"` // new structured signature config
149149+ AutoScreenOnLoad *bool `toml:"auto_screen_on_load"` // screen inbox on every load (default true)
150150+ BgSyncInterval int `toml:"bg_sync_interval"` // background sync interval in minutes (0 = disabled, default 5)
151151+ BulkProgressThreshold int `toml:"bulk_progress_threshold"` // show progress counter for batches larger than this (default 10)
152152+ DraftBackupCount int `toml:"draft_backup_count"` // rolling compose backups in ~/.cache/neomd/drafts/ (default 20, -1 = disabled)
153153+ MarkAsReadAfterSecs int `toml:"mark_as_read_after_secs"` // seconds in reader before marking as read (0 = immediate, default 7)
142154}
143155144156// TextSignature returns the text/markdown signature for editor and text/plain part.
···179191 return *u.AutoScreenOnLoad
180192}
181193194194+// Resolved returns a copy with sensible fallbacks filled in for any field
195195+// the user enabled-but-left-blank. Safe to call when Enabled is false.
196196+func (n NotificationsConfig) Resolved() NotificationsConfig {
197197+ out := n
198198+ if out.Command == "" {
199199+ out.Command = "notify-send"
200200+ }
201201+ if out.Icon == "" {
202202+ out.Icon = "mail-message-new"
203203+ }
204204+ if out.ExpireMs <= 0 {
205205+ out.ExpireMs = 5000
206206+ }
207207+ if len(out.Folders) == 0 {
208208+ out.Folders = []string{"Inbox"}
209209+ }
210210+ return out
211211+}
212212+213213+// FolderAllowed reports whether folder is in the configured Folders list
214214+// (case-insensitive, with sensible defaults applied).
215215+func (n NotificationsConfig) FolderAllowed(folder string) bool {
216216+ r := n.Resolved()
217217+ for _, f := range r.Folders {
218218+ if strings.EqualFold(f, folder) {
219219+ return true
220220+ }
221221+ }
222222+ return false
223223+}
224224+182225// Config is the root neomd configuration.
183226type Config struct {
184227 // Accounts is the list of email accounts (use [[accounts]] in config.toml).
···196239 // These share the active account's SMTP connection — no IMAP or credentials needed.
197240 Senders []SenderConfig `toml:"senders"`
198241199199- Screener ScreenerConfig `toml:"screener"`
200200- Folders FoldersConfig `toml:"folders"`
201201- UI UIConfig `toml:"ui"`
242242+ Screener ScreenerConfig `toml:"screener"`
243243+ Folders FoldersConfig `toml:"folders"`
244244+ UI UIConfig `toml:"ui"`
245245+ Notifications NotificationsConfig `toml:"notifications"`
202246203247 // AutoBCC, if set, is added to every outgoing email's Bcc field so the
204248 // user keeps a copy in an external mailbox (e.g. their hey.com archive).
···295339 return filepath.Join(os.TempDir(), fmt.Sprintf("neomd_%d_spy_pixels", os.Getuid()))
296340}
297341342342+// NotifyStatePath returns the path for the per-folder last-seen-UID baseline
343343+// used by the notification system to decide which messages count as "new".
344344+func NotifyStatePath() string {
345345+ if dir, err := os.UserCacheDir(); err == nil {
346346+ p := filepath.Join(dir, cacheDirName)
347347+ _ = os.MkdirAll(p, 0700)
348348+ return filepath.Join(p, "notify_state.json")
349349+ }
350350+ return filepath.Join(os.TempDir(), fmt.Sprintf("neomd_%d_notify_state.json", os.Getuid()))
351351+}
352352+298353// welcomePath returns the path of the first-run marker file.
299354func welcomePath() string {
300355 if dir, err := os.UserCacheDir(); err == nil {
···346401 cfg.Screener.Feed = expandPath(cfg.Screener.Feed)
347402 cfg.Screener.PaperTrail = expandPath(cfg.Screener.PaperTrail)
348403 cfg.Screener.Spam = expandPath(cfg.Screener.Spam)
404404+ cfg.Screener.Notify = expandPath(cfg.Screener.Notify)
349405350406 // Ensure screener list directories and files exist so appending (I/O/F/P/$)
351407 // works on a fresh install without manual mkdir or touching files.
352408 for _, p := range []string{
353409 cfg.Screener.ScreenedIn, cfg.Screener.ScreenedOut,
354410 cfg.Screener.Feed, cfg.Screener.PaperTrail, cfg.Screener.Spam,
411411+ cfg.Screener.Notify,
355412 } {
356413 if p != "" {
357414 _ = os.MkdirAll(filepath.Dir(p), 0700)
···464521 Feed: filepath.Join(listsDir, "feed.txt"),
465522 PaperTrail: filepath.Join(listsDir, "papertrail.txt"),
466523 Spam: filepath.Join(listsDir, "spam.txt"),
524524+ Notify: filepath.Join(listsDir, "notify.txt"),
525525+ },
526526+ Notifications: NotificationsConfig{
527527+ Enabled: false,
528528+ Command: "notify-send",
529529+ Icon: "mail-message-new",
530530+ ExpireMs: 5000,
531531+ Folders: []string{"Inbox"},
467532 },
468533 Folders: FoldersConfig{
469534 Inbox: "INBOX",
+122
internal/notify/notify.go
···11+// Package notify fires desktop notifications (via notify-send or compatible
22+// CLI) for newly arrived emails whose sender is on the screener notify list.
33+//
44+// TUI-only: the headless daemon does not invoke this package, so notifications
55+// never fire on a server / NAS where no one would see them.
66+package notify
77+88+import (
99+ "os/exec"
1010+ "strconv"
1111+ "strings"
1212+1313+ "github.com/sspaeti/neomd/internal/config"
1414+ "github.com/sspaeti/neomd/internal/imap"
1515+ "github.com/sspaeti/neomd/internal/screener"
1616+)
1717+1818+// Notifier wraps a notification command (notify-send by default) and a
1919+// resolved-defaults config snapshot.
2020+type Notifier struct {
2121+ cfg config.NotificationsConfig
2222+}
2323+2424+// New returns a Notifier. Send is a no-op when cfg.Enabled is false.
2525+func New(cfg config.NotificationsConfig) *Notifier {
2626+ return &Notifier{cfg: cfg.Resolved()}
2727+}
2828+2929+// Enabled reports whether notifications would be sent. Useful so callers can
3030+// skip building dstByUID maps when the feature is off.
3131+func (n *Notifier) Enabled() bool {
3232+ return n != nil && n.cfg.Enabled
3333+}
3434+3535+// 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+func (n *Notifier) Send(title, body string) error {
3939+ if !n.cfg.Enabled {
4040+ return nil
4141+ }
4242+ args := []string{
4343+ "-i", n.cfg.Icon,
4444+ "-t", strconv.Itoa(n.cfg.ExpireMs),
4545+ "-a", "neomd",
4646+ title,
4747+ truncate(body, 200),
4848+ }
4949+ return exec.Command(n.cfg.Command, args...).Run()
5050+}
5151+5252+// MaybeNotify processes a freshly fetched batch of emails from sourceFolder.
5353+// For each email with UID > the per-(account, folder) baseline whose sender is
5454+// in the screener notify list and whose post-screening destination is in the
5555+// configured Folders allowlist, a notification fires.
5656+//
5757+// First-run behaviour: when no baseline exists yet, MaybeNotify silently
5858+// records the highest UID it saw and fires *no* notifications — this prevents
5959+// the entire current Inbox from notifying the first time the feature is
6060+// enabled.
6161+//
6262+// dstByUID maps a UID to the folder label where the email is *about to* live
6363+// after auto-screening (caller computes this from screener.ClassifyForScreen).
6464+// UIDs missing from dstByUID are assumed to stay in sourceFolder.
6565+//
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 {
6868+ if !n.Enabled() || sc == nil || state == nil || len(emails) == 0 {
6969+ return 0
7070+ }
7171+ key := stateKey(account, sourceFolder)
7272+ baseline, hadBaseline := state.Get(key)
7373+7474+ var maxUID uint32
7575+ sent := 0
7676+ for i := range emails {
7777+ e := &emails[i]
7878+ if e.UID > maxUID {
7979+ maxUID = e.UID
8080+ }
8181+ if !hadBaseline || e.UID <= baseline {
8282+ continue
8383+ }
8484+ if !sc.ShouldNotify(e.From) {
8585+ continue
8686+ }
8787+ dst, ok := dstByUID[e.UID]
8888+ if !ok {
8989+ dst = sourceFolder
9090+ }
9191+ if !n.cfg.FolderAllowed(dst) {
9292+ continue
9393+ }
9494+ title := "neomd: " + truncate(e.From, 80)
9595+ body := e.Subject
9696+ if body == "" {
9797+ body = "(no subject)"
9898+ }
9999+ if err := n.Send(title, body); err == nil {
100100+ sent++
101101+ }
102102+ }
103103+104104+ if maxUID > baseline {
105105+ state.Set(key, maxUID)
106106+ _ = state.Save()
107107+ }
108108+ return sent
109109+}
110110+111111+func stateKey(account, folder string) string {
112112+ return account + "|" + folder
113113+}
114114+115115+func truncate(s string, n int) string {
116116+ s = strings.TrimSpace(s)
117117+ if len([]rune(s)) <= n {
118118+ return s
119119+ }
120120+ r := []rune(s)
121121+ return string(r[:n]) + "…"
122122+}
+168
internal/notify/notify_test.go
···11+package notify
22+33+import (
44+ "os"
55+ "path/filepath"
66+ "testing"
77+88+ "github.com/sspaeti/neomd/internal/config"
99+ "github.com/sspaeti/neomd/internal/imap"
1010+ "github.com/sspaeti/neomd/internal/screener"
1111+)
1212+1313+func newScreener(t *testing.T, notifyEntries []string) *screener.Screener {
1414+ t.Helper()
1515+ dir := t.TempDir()
1616+ notifyPath := filepath.Join(dir, "notify.txt")
1717+ if len(notifyEntries) > 0 {
1818+ body := ""
1919+ for _, e := range notifyEntries {
2020+ body += e + "\n"
2121+ }
2222+ if err := os.WriteFile(notifyPath, []byte(body), 0600); err != nil {
2323+ t.Fatal(err)
2424+ }
2525+ }
2626+ sc, err := screener.New(screener.Config{
2727+ ScreenedIn: filepath.Join(dir, "in.txt"),
2828+ ScreenedOut: filepath.Join(dir, "out.txt"),
2929+ Feed: filepath.Join(dir, "feed.txt"),
3030+ PaperTrail: filepath.Join(dir, "pt.txt"),
3131+ Spam: filepath.Join(dir, "spam.txt"),
3232+ Notify: notifyPath,
3333+ })
3434+ if err != nil {
3535+ t.Fatal(err)
3636+ }
3737+ return sc
3838+}
3939+4040+func TestNotifier_DisabledIsNoop(t *testing.T) {
4141+ n := New(config.NotificationsConfig{Enabled: false})
4242+ if n.Enabled() {
4343+ t.Error("expected Enabled() = false")
4444+ }
4545+ if err := n.Send("title", "body"); err != nil {
4646+ t.Errorf("Send when disabled returned %v, want nil", err)
4747+ }
4848+}
4949+5050+func TestNotifier_ResolvedDefaults(t *testing.T) {
5151+ n := New(config.NotificationsConfig{Enabled: true})
5252+ if n.cfg.Command != "notify-send" {
5353+ t.Errorf("Command default = %q, want notify-send", n.cfg.Command)
5454+ }
5555+ if n.cfg.Icon != "mail-message-new" {
5656+ t.Errorf("Icon default = %q", n.cfg.Icon)
5757+ }
5858+ if n.cfg.ExpireMs != 5000 {
5959+ t.Errorf("ExpireMs default = %d", n.cfg.ExpireMs)
6060+ }
6161+ if len(n.cfg.Folders) != 1 || n.cfg.Folders[0] != "Inbox" {
6262+ t.Errorf("Folders default = %v", n.cfg.Folders)
6363+ }
6464+}
6565+6666+func TestMaybeNotify_FirstRunBaselineSilent(t *testing.T) {
6767+ statePath := filepath.Join(t.TempDir(), "state.json")
6868+ state := LoadState(statePath)
6969+ sc := newScreener(t, []string{"vip@example.com"})
7070+ // Use a fake command that always succeeds so we'd notice if it was invoked.
7171+ n := New(config.NotificationsConfig{Enabled: true, Command: "true", Folders: []string{"Inbox"}})
7272+ emails := []imap.Email{
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)
7979+ }
8080+ uid, ok := state.Get(stateKey("acct", "Inbox"))
8181+ if !ok || uid != 101 {
8282+ t.Errorf("baseline = (%d, %v), want (101, true)", uid, ok)
8383+ }
8484+}
8585+8686+func TestMaybeNotify_OnlyNewEmailsFromNotifyList(t *testing.T) {
8787+ statePath := filepath.Join(t.TempDir(), "state.json")
8888+ state := LoadState(statePath)
8989+ state.Set(stateKey("acct", "Inbox"), 100)
9090+9191+ sc := newScreener(t, []string{"vip@example.com"})
9292+ n := New(config.NotificationsConfig{Enabled: true, Command: "true", Folders: []string{"Inbox"}})
9393+9494+ emails := []imap.Email{
9595+ {UID: 100, From: "vip@example.com", Subject: "old"}, // not new
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)
102102+ }
103103+}
104104+105105+func TestMaybeNotify_DomainEntry(t *testing.T) {
106106+ statePath := filepath.Join(t.TempDir(), "state.json")
107107+ state := LoadState(statePath)
108108+ state.Set(stateKey("acct", "Inbox"), 0)
109109+110110+ sc := newScreener(t, []string{"@important.org"})
111111+ n := New(config.NotificationsConfig{Enabled: true, Command: "true", Folders: []string{"Inbox"}})
112112+113113+ emails := []imap.Email{
114114+ {UID: 1, From: "alice@important.org", Subject: "x"},
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)
121121+ }
122122+}
123123+124124+func TestMaybeNotify_FolderAllowlistFiltersOut(t *testing.T) {
125125+ statePath := filepath.Join(t.TempDir(), "state.json")
126126+ state := LoadState(statePath)
127127+ state.Set(stateKey("acct", "Inbox"), 0)
128128+129129+ sc := newScreener(t, []string{"vip@example.com"})
130130+ n := New(config.NotificationsConfig{Enabled: true, Command: "true", Folders: []string{"Inbox"}})
131131+132132+ emails := []imap.Email{
133133+ {UID: 5, From: "vip@example.com", Subject: "moved-to-feed"},
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)
140140+ }
141141+}
142142+143143+func TestState_PersistAndReload(t *testing.T) {
144144+ path := filepath.Join(t.TempDir(), "state.json")
145145+ state := LoadState(path)
146146+ state.Set("acct|Inbox", 42)
147147+ if err := state.Save(); err != nil {
148148+ t.Fatal(err)
149149+ }
150150+ reloaded := LoadState(path)
151151+ uid, ok := reloaded.Get("acct|Inbox")
152152+ if !ok || uid != 42 {
153153+ t.Errorf("reloaded = (%d, %v), want (42, true)", uid, ok)
154154+ }
155155+}
156156+157157+func TestState_MissingFileReturnsEmpty(t *testing.T) {
158158+ state := LoadState(filepath.Join(t.TempDir(), "does-not-exist.json"))
159159+ if state == nil {
160160+ t.Fatal("LoadState should never return nil")
161161+ }
162162+ if state.UIDs == nil {
163163+ t.Error("UIDs map should be initialised")
164164+ }
165165+ if _, ok := state.Get("anything"); ok {
166166+ t.Error("expected no entries")
167167+ }
168168+}
+64
internal/notify/state.go
···11+package notify
22+33+import (
44+ "encoding/json"
55+ "os"
66+ "path/filepath"
77+ "sync"
88+)
99+1010+// State persists per-(account, folder) "highest UID seen" baselines so a
1111+// neomd restart doesn't replay every Inbox notification. Concurrent-safe.
1212+type State struct {
1313+ path string
1414+ mu sync.Mutex
1515+ UIDs map[string]uint32 `json:"uids"`
1616+}
1717+1818+// LoadState reads path. A missing or corrupt file yields an empty State; the
1919+// caller will treat the first observation per folder as the new baseline.
2020+func LoadState(path string) *State {
2121+ s := &State{path: path, UIDs: map[string]uint32{}}
2222+ data, err := os.ReadFile(path)
2323+ if err != nil {
2424+ return s
2525+ }
2626+ _ = json.Unmarshal(data, s)
2727+ if s.UIDs == nil {
2828+ s.UIDs = map[string]uint32{}
2929+ }
3030+ return s
3131+}
3232+3333+// Get returns the recorded UID and whether one existed.
3434+func (s *State) Get(key string) (uint32, bool) {
3535+ s.mu.Lock()
3636+ defer s.mu.Unlock()
3737+ uid, ok := s.UIDs[key]
3838+ return uid, ok
3939+}
4040+4141+// Set records uid for key. Caller is responsible for calling Save.
4242+func (s *State) Set(key string, uid uint32) {
4343+ s.mu.Lock()
4444+ defer s.mu.Unlock()
4545+ s.UIDs[key] = uid
4646+}
4747+4848+// Save writes the state to disk atomically (temp file + rename).
4949+func (s *State) Save() error {
5050+ s.mu.Lock()
5151+ data, err := json.Marshal(s)
5252+ s.mu.Unlock()
5353+ if err != nil {
5454+ return err
5555+ }
5656+ if err := os.MkdirAll(filepath.Dir(s.path), 0700); err != nil {
5757+ return err
5858+ }
5959+ tmp := s.path + ".tmp"
6060+ if err := os.WriteFile(tmp, data, 0600); err != nil {
6161+ return err
6262+ }
6363+ return os.Rename(tmp, s.path)
6464+}
+61-4
internal/screener/screener.go
···4747 Feed string
4848 PaperTrail string
4949 Spam string
5050+ Notify string // optional: addresses (or @domain) to fire desktop notifications for
5051}
51525253// Screener holds loaded allowlists in memory for fast classification.
···5758 feed map[string]bool
5859 paperTrail map[string]bool
5960 spam map[string]bool
6161+ notify map[string]bool
6062}
61636264// Snapshot is a point-in-time copy of all screener list files and in-memory sets.
···7981 feed: make(map[string]bool),
8082 paperTrail: make(map[string]bool),
8183 spam: make(map[string]bool),
8484+ notify: make(map[string]bool),
8285 }
8386 for path, m := range map[string]map[string]bool{
8487 cfg.ScreenedIn: s.screenedIn,
···8689 cfg.Feed: s.feed,
8790 cfg.PaperTrail: s.paperTrail,
8891 cfg.Spam: s.spam,
9292+ cfg.Notify: s.notify,
8993 } {
9494+ if path == "" {
9595+ continue
9696+ }
9097 if err := loadList(path, m); err != nil {
9198 return nil, fmt.Errorf("load screener list %s: %w", path, err)
9299 }
···120127121128// Classify returns the category for a given "from" email address.
122129// The address is normalised to lowercase before matching.
130130+//
131131+// List entries can be either an exact email ("john@ssp.sh") or a domain
132132+// prefixed with "@" ("@ssp.sh") that matches any address at that domain.
133133+// Exact matches always win over domain matches: an exact entry in any list
134134+// is consulted before the @-domain entries in the same priority pass, but
135135+// crucially the per-list priority itself (spam > out > feed > papertrail >
136136+// in) is preserved across both passes by iterating the lists in order twice.
123137func (s *Screener) Classify(from string) Category {
124138 addr := normalise(from)
125139 switch {
126140 case s.spam[addr]:
127127- return CategorySpam // spam wins over everything
141141+ return CategorySpam
128142 case s.screenedOut[addr]:
129129- return CategoryScreenedOut // hard block
143143+ return CategoryScreenedOut
130144 case s.feed[addr]:
131131- return CategoryFeed // specific routing beats generic approval
145145+ return CategoryFeed
132146 case s.paperTrail[addr]:
133147 return CategoryPaperTrail
134148 case s.screenedIn[addr]:
135135- return CategoryInbox // trusted but no specific folder → stay in Inbox
149149+ return CategoryInbox
150150+ }
151151+ // No exact match — try @domain entries in the same priority order so a
152152+ // per-address override remains stronger than a domain rule.
153153+ switch {
154154+ case domainMatch(addr, s.spam):
155155+ return CategorySpam
156156+ case domainMatch(addr, s.screenedOut):
157157+ return CategoryScreenedOut
158158+ case domainMatch(addr, s.feed):
159159+ return CategoryFeed
160160+ case domainMatch(addr, s.paperTrail):
161161+ return CategoryPaperTrail
162162+ case domainMatch(addr, s.screenedIn):
163163+ return CategoryInbox
136164 default:
137165 return CategoryToScreen
138166 }
···143171func (s *Screener) ClassifyDebug(from string) (Category, string) {
144172 addr := normalise(from)
145173 return s.Classify(from), addr
174174+}
175175+176176+// ShouldNotify reports whether a desktop notification should fire for this
177177+// sender. True when from (or its domain via "@domain.tld") is in notify.txt.
178178+// Independent of the screening Category: notify and screening are orthogonal.
179179+func (s *Screener) ShouldNotify(from string) bool {
180180+ if len(s.notify) == 0 {
181181+ return false
182182+ }
183183+ return matchAddr(normalise(from), s.notify)
184184+}
185185+186186+// matchAddr returns true if addr is in set, either as an exact email or as
187187+// a "@domain" entry covering its domain. addr must already be normalised.
188188+func matchAddr(addr string, set map[string]bool) bool {
189189+ if set[addr] {
190190+ return true
191191+ }
192192+ return domainMatch(addr, set)
193193+}
194194+195195+// domainMatch returns true only via the "@domain" form (skipping the exact
196196+// check). Used by Classify so that the priority loop can be split into an
197197+// exact-match pass first, then a domain-match pass.
198198+func domainMatch(addr string, set map[string]bool) bool {
199199+ if i := strings.IndexByte(addr, '@'); i >= 0 {
200200+ return set[addr[i:]]
201201+ }
202202+ return false
146203}
147204148205// Approve adds addr to screened_in.txt and removes it from all conflicting lists.
+145
internal/screener/screener_test.go
···127127 from: "",
128128 want: CategoryToScreen,
129129 },
130130+ {
131131+ name: "@domain in screened_in matches any address at that domain",
132132+ screener: &Screener{
133133+ screenedIn: map[string]bool{"@ssp.sh": true},
134134+ screenedOut: map[string]bool{},
135135+ feed: map[string]bool{},
136136+ paperTrail: map[string]bool{},
137137+ spam: map[string]bool{},
138138+ },
139139+ from: "anyone@ssp.sh",
140140+ want: CategoryInbox,
141141+ },
142142+ {
143143+ name: "@domain in screened_out blocks any address at that domain",
144144+ screener: &Screener{
145145+ screenedIn: map[string]bool{},
146146+ screenedOut: map[string]bool{"@spammy.io": true},
147147+ feed: map[string]bool{},
148148+ paperTrail: map[string]bool{},
149149+ spam: map[string]bool{},
150150+ },
151151+ from: "Promo Bot <promo@spammy.io>",
152152+ want: CategoryScreenedOut,
153153+ },
154154+ {
155155+ name: "exact email beats @domain in different lists",
156156+ screener: &Screener{
157157+ // Exact john@ssp.sh is blocked, but @ssp.sh is approved overall.
158158+ screenedIn: map[string]bool{"@ssp.sh": true},
159159+ screenedOut: map[string]bool{"john@ssp.sh": true},
160160+ feed: map[string]bool{},
161161+ paperTrail: map[string]bool{},
162162+ spam: map[string]bool{},
163163+ },
164164+ from: "john@ssp.sh",
165165+ want: CategoryScreenedOut,
166166+ },
167167+ {
168168+ name: "domain rule does not match different domain",
169169+ screener: &Screener{
170170+ screenedIn: map[string]bool{"@ssp.sh": true},
171171+ screenedOut: map[string]bool{},
172172+ feed: map[string]bool{},
173173+ paperTrail: map[string]bool{},
174174+ spam: map[string]bool{},
175175+ },
176176+ from: "alice@example.com",
177177+ want: CategoryToScreen,
178178+ },
179179+ {
180180+ name: "@domain entry is case-insensitive on the domain part",
181181+ screener: &Screener{
182182+ screenedIn: map[string]bool{"@example.com": true},
183183+ screenedOut: map[string]bool{},
184184+ feed: map[string]bool{},
185185+ paperTrail: map[string]bool{},
186186+ spam: map[string]bool{},
187187+ },
188188+ from: "User@EXAMPLE.com",
189189+ want: CategoryInbox,
190190+ },
130191 }
131192132193 for _, tt := range tests {
···374435 }
375436 if string(data) != "undo@example.com\n" {
376437 t.Fatalf("screened_in contents = %q, want restored entry", data)
438438+ }
439439+ })
440440+}
441441+442442+// ---------------------------------------------------------------------------
443443+// TestShouldNotify — notify list is independent of categories
444444+// ---------------------------------------------------------------------------
445445+446446+func TestShouldNotify(t *testing.T) {
447447+ t.Run("empty list returns false", func(t *testing.T) {
448448+ s := &Screener{notify: map[string]bool{}}
449449+ if s.ShouldNotify("anyone@example.com") {
450450+ t.Error("ShouldNotify should be false when notify is empty")
451451+ }
452452+ })
453453+454454+ t.Run("exact email match", func(t *testing.T) {
455455+ s := &Screener{notify: map[string]bool{"vip@example.com": true}}
456456+ if !s.ShouldNotify("vip@example.com") {
457457+ t.Error("expected exact match")
458458+ }
459459+ if s.ShouldNotify("other@example.com") {
460460+ t.Error("non-listed address should not notify")
461461+ }
462462+ })
463463+464464+ t.Run("@domain match notifies any address at that domain", func(t *testing.T) {
465465+ s := &Screener{notify: map[string]bool{"@ssp.sh": true}}
466466+ if !s.ShouldNotify("anyone@ssp.sh") {
467467+ t.Error("expected domain match")
468468+ }
469469+ if s.ShouldNotify("anyone@other.tld") {
470470+ t.Error("different domain should not notify")
471471+ }
472472+ })
473473+474474+ t.Run("normalises display name and case", func(t *testing.T) {
475475+ s := &Screener{notify: map[string]bool{"vip@example.com": true}}
476476+ if !s.ShouldNotify("VIP <VIP@Example.com>") {
477477+ t.Error("expected normalised match")
478478+ }
479479+ })
480480+481481+ t.Run("loads from notify.txt via New", func(t *testing.T) {
482482+ dir := t.TempDir()
483483+ notifyPath := filepath.Join(dir, "notify.txt")
484484+ os.WriteFile(notifyPath, []byte("@important.org\nboss@work.com\n"), 0600)
485485+ s, err := New(Config{
486486+ ScreenedIn: filepath.Join(dir, "in.txt"),
487487+ ScreenedOut: filepath.Join(dir, "out.txt"),
488488+ Feed: filepath.Join(dir, "feed.txt"),
489489+ PaperTrail: filepath.Join(dir, "pt.txt"),
490490+ Spam: filepath.Join(dir, "spam.txt"),
491491+ Notify: notifyPath,
492492+ })
493493+ if err != nil {
494494+ t.Fatal(err)
495495+ }
496496+ if !s.ShouldNotify("alice@important.org") {
497497+ t.Error("@important.org domain entry should match")
498498+ }
499499+ if !s.ShouldNotify("boss@work.com") {
500500+ t.Error("boss@work.com exact entry should match")
501501+ }
502502+ if s.ShouldNotify("nobody@nowhere.com") {
503503+ t.Error("unrelated address should not notify")
504504+ }
505505+ })
506506+507507+ t.Run("Notify path empty leaves the list empty", func(t *testing.T) {
508508+ dir := t.TempDir()
509509+ s, err := New(Config{
510510+ ScreenedIn: filepath.Join(dir, "in.txt"),
511511+ ScreenedOut: filepath.Join(dir, "out.txt"),
512512+ Feed: filepath.Join(dir, "feed.txt"),
513513+ PaperTrail: filepath.Join(dir, "pt.txt"),
514514+ Spam: filepath.Join(dir, "spam.txt"),
515515+ // Notify intentionally unset
516516+ })
517517+ if err != nil {
518518+ t.Fatal(err)
519519+ }
520520+ if s.ShouldNotify("anyone@anywhere.com") {
521521+ t.Error("ShouldNotify should be false when no notify list configured")
377522 }
378523 })
379524}
+2
internal/ui/keys.go
···4444 {"$", "mark as Spam → spam.txt + move to Spam (removes from screened_in/out)"},
4545 {"F", "mark as Feed → feed.txt + move to Feed"},
4646 {"P", "mark as PaperTrail → papertrail.txt + move to PaperTrail"},
4747+ {"Di", "approve whole DOMAIN → @domain.tld appended to screened_in.txt (cursor or open email; y/n)"},
4848+ {"Do", "block whole DOMAIN → @domain.tld appended to screened_out.txt (cursor or open email; y/n)"},
4749 {"A", "archive (move to Archive, no screener update)"},
4850 {"B", "move to Work/business (no screener update, if configured)"},
4951 {"S", "dry-run screen inbox (loaded emails), then y/n"},
+191-25
internal/ui/model.go
···2525 "github.com/sspaeti/neomd/internal/editor"
2626 "github.com/sspaeti/neomd/internal/imap"
2727 "github.com/sspaeti/neomd/internal/listmonk"
2828+ "github.com/sspaeti/neomd/internal/notify"
2829 "github.com/sspaeti/neomd/internal/render"
2930 "github.com/sspaeti/neomd/internal/screener"
3031 "github.com/sspaeti/neomd/internal/smtp"
···3435type viewState int
35363637const (
3737- stateInbox viewState = iota
3838- stateReading // reading a single email
3939- stateCompose // composing a new email
4040- statePresend // pre-send review: add attachments, then send or edit again
4141- stateHelp // help overlay
4242- stateWelcome // first-run welcome popup
4343- stateReaction // emoji reaction picker
3838+ stateInbox viewState = iota
3939+ stateReading // reading a single email
4040+ stateCompose // composing a new email
4141+ statePresend // pre-send review: add attachments, then send or edit again
4242+ stateHelp // help overlay
4343+ stateWelcome // first-run welcome popup
4444+ stateReaction // emoji reaction picker
4445)
45464647// async message types
···455456 dst string
456457}
457458459459+// pendingDomainAction queues a domain-level screener mutation awaiting y/n.
460460+// entry is the storage form ("@ssp.sh"); action is "I" (approve) or "O" (block).
461461+type pendingDomainAction struct {
462462+ entry string
463463+ action string
464464+}
465465+458466// Model is the root bubbletea model.
459467type Model struct {
460460- cfg *config.Config
461461- accounts []config.AccountConfig // all configured accounts
462462- clients []*imap.Client // one IMAP client per account
463463- accountI int // index of the active account
464464- screener *screener.Screener
468468+ cfg *config.Config
469469+ accounts []config.AccountConfig // all configured accounts
470470+ clients []*imap.Client // one IMAP client per account
471471+ accountI int // index of the active account
472472+ screener *screener.Screener
473473+ notifier *notify.Notifier
474474+ notifyState *notify.State
465475466476 state viewState
467477 width int
···487497 openBody string // markdown body used by the TUI reader
488498 openHTMLBody string // original HTML part; used by openInExternalViewer when available
489499 openWebURL string // canonical "view online" URL for ctrl+o (may be empty)
490490- openAttachments []imap.Attachment // attachments of the currently open email
491491- openLinks []emailLink // extracted links from the email body
492492- openSpyPixels imap.SpyPixelInfo // spy pixels detected in the currently open email
493493- readerPending string // chord prefix in reader (space for link open)
500500+ openAttachments []imap.Attachment // attachments of the currently open email
501501+ openLinks []emailLink // extracted links from the email body
502502+ openSpyPixels imap.SpyPixelInfo // spy pixels detected in the currently open email
503503+ readerPending string // chord prefix in reader (space for link open)
494504 // Mark-as-read timer tracking
495505 markAsReadUID uint32 // UID of email with pending mark-as-read timer
496506 markAsReadFolder string // folder of email with pending mark-as-read timer
···574584 // being bulk-moved back to Inbox.
575585 pendingResetUIDs []uint32
576586587587+ // pendingDomainOp holds an "@domain" screener entry (e.g. "@ssp.sh")
588588+ // awaiting y/n confirmation, plus the action to execute ("I" approve,
589589+ // "O" block). Set by the Di / Do reader chord; cleared on y, n, or any
590590+ // other key.
591591+ pendingDomainOp *pendingDomainAction
592592+577593 // pendingDeleteAll holds UIDs + folder awaiting y/n before permanent deletion.
578594 pendingDeleteAll *deleteAllReadyMsg
579595···611627612628 spyKeys, scannedKeys := loadSpyPixelCache()
613629 return Model{
614614- cfg: cfg,
615615- accounts: cfg.ActiveAccounts(),
616616- clients: clients,
617617- screener: sc,
618618- state: stateInbox,
619619- loading: true,
620620- folders: cfg.Folders.TabLabels(),
621621- cmdHistory: loadCmdHistory(config.HistoryPath()),
622622- cmdHistI: -1,
630630+ cfg: cfg,
631631+ accounts: cfg.ActiveAccounts(),
632632+ clients: clients,
633633+ screener: sc,
634634+ notifier: notify.New(cfg.Notifications),
635635+ notifyState: notify.LoadState(config.NotifyStatePath()),
636636+ state: stateInbox,
637637+ loading: true,
638638+ folders: cfg.Folders.TabLabels(),
639639+ cmdHistory: loadCmdHistory(config.HistoryPath()),
640640+ cmdHistI: -1,
623641 // Note: Spam is intentionally excluded from tabs — use :go-spam to visit.
624642 compose: compose,
625643 spinner: sp,
···13671385 return moves
13681386}
1369138713881388+// execDomainScreen applies a confirmed domain-level screener mutation
13891389+// (Approve or Block on a "@domain" entry). Reloads the active folder so the
13901390+// view reflects any senders that have just been reclassified.
13911391+func (m Model) execDomainScreen(op *pendingDomainAction) (tea.Model, tea.Cmd) {
13921392+ var err error
13931393+ switch op.action {
13941394+ case "I":
13951395+ err = m.screener.Approve(op.entry)
13961396+ case "O":
13971397+ err = m.screener.Block(op.entry)
13981398+ default:
13991399+ m.status = "internal error: unknown domain action"
14001400+ m.isError = true
14011401+ return m, nil
14021402+ }
14031403+ if err != nil {
14041404+ m.status = fmt.Sprintf("Domain screen failed: %v", err)
14051405+ m.isError = true
14061406+ return m, nil
14071407+ }
14081408+ verb := "approved"
14091409+ if op.action == "O" {
14101410+ verb = "blocked"
14111411+ }
14121412+ m.status = fmt.Sprintf("Domain %s %s.", op.entry, verb)
14131413+ m.loading = true
14141414+ return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder()))
14151415+}
14161416+14171417+// maybeNotifyInbox dispatches desktop notifications for the freshly fetched
14181418+// emails. moves describes where each email is heading after auto-screening
14191419+// (pass nil if no auto-screen pass ran). Folder is the IMAP source folder
14201420+// label the fetch came from. No-op when the notifier is disabled.
14211421+func (m Model) maybeNotifyInbox(folder string, emails []imap.Email, moves []autoScreenMove) {
14221422+ if !m.notifier.Enabled() || len(emails) == 0 {
14231423+ return
14241424+ }
14251425+ dstByUID := make(map[uint32]string, len(moves))
14261426+ for _, mv := range moves {
14271427+ if mv.email != nil {
14281428+ dstByUID[mv.email.UID] = mv.dst
14291429+ }
14301430+ }
14311431+ m.notifier.MaybeNotify(m.activeAccount().Name, folder, emails, dstByUID, m.screener, m.notifyState)
14321432+}
14331433+13701434// previewAutoScreen classifies the currently loaded inbox emails (no IMAP).
13711435func (m Model) previewAutoScreen() []autoScreenMove {
13721436 return m.classifyForScreen(m.emails)
···17651829 return m, tea.Batch(sortCmd, m.fetchFolderCountsCmd())
17661830 }
17671831 if moves := m.previewAutoScreen(); len(moves) > 0 {
18321832+ m.maybeNotifyInbox(msg.folder, msg.emails, moves)
17681833 m.loading = true
17691834 m.bulkProgress = m.newBulkOp("Screening", len(moves))
17701835 return m, tea.Batch(sortCmd, m.fetchFolderCountsCmd(), m.spinner.Tick, m.execAutoScreenCmd(moves))
17711836 }
17721837 }
18381838+ if msg.folder == m.cfg.Folders.Inbox {
18391839+ m.maybeNotifyInbox(msg.folder, msg.emails, nil)
18401840+ }
17731841 return m, tea.Batch(sortCmd, m.fetchFolderCountsCmd())
1774184217751843 case folderCountsMsg:
···22022270 return m, nil
22032271 }
22042272 moves := m.classifyForScreen(msg.emails)
22732273+ m.maybeNotifyInbox(m.cfg.Folders.Inbox, msg.emails, moves)
22052274 if len(moves) == 0 {
22062275 // No moves needed - background sync is complete
22072276 m.bgSyncInProgress = false
···24532522 m.pendingMoves = nil
24542523 m.pendingResetUIDs = nil
24552524 m.pendingDeleteAll = nil
25252525+ m.pendingDomainOp = nil
24562526 }
24572527 m.status = ""
24582528 m.isError = false
···25022572 m.status = "move to: Mi inbox Ma archive Mf feed Mp papertrail Mt trash Mo screened-out Mw waiting Mc scheduled Mm someday"
25032573 return m, nil
2504257425752575+ case "D":
25762576+ m.pendingKey = "D"
25772577+ m.status = "domain screen: Di in (@domain → screened_in) Do out (@domain → screened_out) (esc to cancel)"
25782578+ return m, nil
25792579+25052580 case ",":
25062581 m.pendingKey = ","
25072582 m.status = "sort: ,m date↓ ,M date↑ ,a from A-Z ,A from Z-A ,s size↑ ,S size↓ ,n subject A-Z ,N subject Z-A"
···26182693 return m, nil
2619269426202695 case "y":
26962696+ if m.pendingDomainOp != nil {
26972697+ op := m.pendingDomainOp
26982698+ m.pendingDomainOp = nil
26992699+ return m.execDomainScreen(op)
27002700+ }
26212701 if m.pendingDeleteAll != nil {
26222702 p := m.pendingDeleteAll
26232703 m.pendingDeleteAll = nil
···26412721 return m, tea.Batch(m.spinner.Tick, m.execAutoScreenCmd(moves))
2642272226432723 case "n":
27242724+ if m.pendingDomainOp != nil {
27252725+ m.pendingDomainOp = nil
27262726+ m.status = "Cancelled."
27272727+ return m, nil
27282728+ }
26442729 if m.pendingDeleteAll != nil || len(m.pendingResetUIDs) > 0 || len(m.pendingMoves) > 0 {
26452730 m.pendingDeleteAll = nil
26462731 m.pendingResetUIDs = nil
···31563241 }
31573242 m.status = fmt.Sprintf("unknown: M%s", key)
3158324332443244+ case "D":
32453245+ if key != "i" && key != "o" {
32463246+ m.status = fmt.Sprintf("unknown: D%s (use Di or Do)", key)
32473247+ return m, nil
32483248+ }
32493249+ e := selectedEmail(m.inbox)
32503250+ if e == nil {
32513251+ m.status = "No email selected for domain action."
32523252+ m.isError = true
32533253+ return m, nil
32543254+ }
32553255+ entry := domainEntry(e.From)
32563256+ if entry == "" {
32573257+ m.status = "Selected email has no parseable domain."
32583258+ m.isError = true
32593259+ return m, nil
32603260+ }
32613261+ action := strings.ToUpper(key)
32623262+ m.pendingDomainOp = &pendingDomainAction{entry: entry, action: action}
32633263+ verb := "IN"
32643264+ if action == "O" {
32653265+ verb = "OUT"
32663266+ }
32673267+ m.status = fmt.Sprintf("Screen %s domain %s? (y/n) — affects every future sender at this domain", verb, entry)
32683268+ return m, nil
32693269+31593270 case ",":
31603271 type sortSpec struct {
31613272 field string
···32383349 m.reader.GotoTop()
32393350 return m, nil
32403351 }
33523352+ case "D": // Di / Do = domain-level screen IN / OUT for the open email
33533353+ if key != "i" && key != "o" {
33543354+ m.status = fmt.Sprintf("unknown: D%s (use Di or Do)", key)
33553355+ m.isError = true
33563356+ return m, nil
33573357+ }
33583358+ if m.openEmail == nil {
33593359+ return m, nil
33603360+ }
33613361+ entry := domainEntry(m.openEmail.From)
33623362+ if entry == "" {
33633363+ m.status = "Open email has no parseable domain."
33643364+ m.isError = true
33653365+ return m, nil
33663366+ }
33673367+ action := strings.ToUpper(key)
33683368+ m.pendingDomainOp = &pendingDomainAction{entry: entry, action: action}
33693369+ verb := "IN"
33703370+ if action == "O" {
33713371+ verb = "OUT"
33723372+ }
33733373+ m.status = fmt.Sprintf("Screen %s domain %s? (y/n) — affects every future sender at this domain", verb, entry)
33743374+ return m, nil
32413375 default:
32423376 // Handle "l[0-9]" pattern (first digit entered, waiting for second)
32433377 if len(pending) == 2 && pending[0] == 'l' && pending[1] >= '0' && pending[1] <= '9' {
···32533387 return m, nil
32543388 }
32553389 }
33903390+ }
33913391+ }
33923392+33933393+ // Pending domain-screen confirmation (set by Di / Do chord) — only y/n accepted.
33943394+ if m.pendingDomainOp != nil {
33953395+ switch key {
33963396+ case "y":
33973397+ op := m.pendingDomainOp
33983398+ m.pendingDomainOp = nil
33993399+ return m.execDomainScreen(op)
34003400+ case "n", "esc":
34013401+ m.pendingDomainOp = nil
34023402+ m.status = "Cancelled."
34033403+ return m, nil
34043404+ default:
34053405+ // Any other key cancels the confirmation but does not consume the key.
34063406+ m.pendingDomainOp = nil
34073407+ m.status = ""
32563408 }
32573409 }
32583410···33283480 return m, nil
33293481 case "G":
33303482 m.reader.GotoBottom()
34833483+ return m, nil
34843484+ case "D":
34853485+ m.readerPending = "D"
34863486+ m.status = "domain screen: Di in (@domain → screened_in) Do out (@domain → screened_out) (esc to cancel)"
33313487 return m, nil
33323488 }
33333489 var cmd tea.Cmd
···45044660 }
45054661 }
45064662 return strings.TrimSpace(s)
46634663+}
46644664+46654665+// domainEntry returns the lowercased "@domain.tld" form of from for use as a
46664666+// screener list entry, or an empty string if the address has no '@' part.
46674667+func domainEntry(from string) string {
46684668+ addr := strings.ToLower(extractEmailAddr(from))
46694669+ if i := strings.IndexByte(addr, '@'); i >= 0 {
46704670+ return addr[i:]
46714671+ }
46724672+ return ""
45074673}
4508467445094675// extractName extracts the name part from "Name <email@example.com>" format.