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.

first stab at notification and allowing domains

+976 -41
+2
README.md
··· 148 148 - **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) 149 149 - **Glamour reading** — incoming emails rendered as styled Markdown in the terminal [more](https://neomd.ssp.sh/docs/reading/) 150 150 - **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/) 151 + - **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) 152 + - **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/) 151 153 - **Folder tabs** — Inbox, ToScreen, Feed, PaperTrail, Archive, Waiting, Someday, Scheduled, Sent, Trash, ScreenedOut [more](https://neomd.ssp.sh/docs/keybindings/#folders) 152 154 - **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) 153 155 - **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
··· 151 151 - **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) 152 152 - **Glamour reading** — incoming emails rendered as styled Markdown in the terminal [more](https://neomd.ssp.sh/docs/reading/) 153 153 - **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/) 154 + - **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) 155 + - **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/) 154 156 - **Folder tabs** — Inbox, ToScreen, Feed, PaperTrail, Archive, Waiting, Someday, Scheduled, Sent, Trash, ScreenedOut [more](https://neomd.ssp.sh/docs/keybindings/#folders) 155 157 - **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) 156 158 - **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
··· 57 57 | `$` | mark as Spam → spam.txt + move to Spam (removes from screened_in/out) | 58 58 | `F` | mark as Feed → feed.txt + move to Feed | 59 59 | `P` | mark as PaperTrail → papertrail.txt + move to PaperTrail | 60 + | `Di` | approve whole DOMAIN → @domain.tld appended to screened_in.txt (cursor or open email; y/n) | 61 + | `Do` | block whole DOMAIN → @domain.tld appended to screened_out.txt (cursor or open email; y/n) | 60 62 | `A` | archive (move to Archive, no screener update) | 61 63 | `B` | move to Work/business (no screener update, if configured) | 62 64 | `S` | dry-run screen inbox (loaded emails), then y/n |
+112
docs/content/docs/notifications.md
··· 1 + --- 2 + title: Notifications 3 + weight: 6 4 + --- 5 + 6 + 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. 7 + 8 + 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. 9 + 10 + ## Quick start 11 + 12 + 1. Make sure `notify-send` is installed and a notification daemon is running (`mako`, `dunst`, `swaync`, …). On Hyprland with mako: 13 + 14 + ```sh 15 + notify-send "neomd" "test" 16 + ``` 17 + 18 + should pop up a notification. 19 + 20 + 2. Add the senders (or domains) you care about to `~/.config/neomd/lists/notify.txt`, one per line: 21 + 22 + ``` 23 + # exact addresses 24 + alice@example.com 25 + boss@work.com 26 + 27 + # whole domain (any address at the domain) 28 + @important.org 29 + ``` 30 + 31 + 3. Enable notifications in `~/.config/neomd/config.toml`: 32 + 33 + ```toml 34 + [notifications] 35 + enabled = true # default: false 36 + command = "notify-send" # default 37 + icon = "mail-message-new" # default; passed as --icon 38 + expire_ms = 5000 # default 39 + folders = ["Inbox"] # only fire when the new mail lands here 40 + ``` 41 + 42 + 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. 43 + 44 + ## When notifications fire 45 + 46 + A notification is fired when **all** of the following are true: 47 + 48 + - `[notifications].enabled = true` 49 + - The email's UID is greater than the per-folder baseline neomd has recorded (`~/.cache/neomd/notify_state.json`) 50 + - The sender (after lower-casing) matches an exact entry or `@domain` entry in `notify.txt` 51 + - After auto-screening, the email's destination folder is in `[notifications].folders` 52 + 53 + If any check fails the email is processed normally but no notification fires. 54 + 55 + ## Domain entries 56 + 57 + Same syntax as the screener lists — a line starting with `@` matches every address at that domain: 58 + 59 + ``` 60 + # notify.txt 61 + @ssp.sh 62 + ceo@bigcorp.com 63 + ``` 64 + 65 + 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. 66 + 67 + ## Configuration reference 68 + 69 + | Field | Default | Description | 70 + | ------------ | ------------------ | ---------------------------------------------------------------------------- | 71 + | `enabled` | `false` | Master switch; opt-in. | 72 + | `command` | `"notify-send"` | Notification binary. See [Custom command](#custom-command) below. | 73 + | `icon` | `"mail-message-new"` | Passed as `-i`/`--icon`. | 74 + | `expire_ms` | `5000` | Passed as `-t`. Milliseconds the notification stays on screen. | 75 + | `folders` | `["Inbox"]` | Folder labels (case-insensitive) that count for notifications. | 76 + 77 + ### Notification content 78 + 79 + Notifications are sent with these arguments: 80 + 81 + ``` 82 + notify-send -i <icon> -t <expire_ms> -a neomd "neomd: <From>" "<Subject>" 83 + ``` 84 + 85 + Subjects longer than 200 characters are truncated with `…`. 86 + 87 + ### Custom command 88 + 89 + `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: 90 + 91 + ```sh 92 + #!/usr/bin/env bash 93 + # ~/.local/bin/neomd-hyprctl-notify 94 + # Usage: <-i icon> <-t expire_ms> <-a app> <title> <body> 95 + shift 6 96 + hyprctl notify 0 5000 "rgb(00ff00)" "$1: $2" 97 + ``` 98 + 99 + Then in `config.toml`: 100 + 101 + ```toml 102 + [notifications] 103 + command = "/home/you/.local/bin/neomd-hyprctl-notify" 104 + ``` 105 + 106 + ## State file 107 + 108 + 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). 109 + 110 + ## Why this is opt-in 111 + 112 + 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
··· 13 13 | `screened_out.txt` | Blocked | ScreenedOut | 14 14 | `feed.txt` | Newsletter / feed | Feed | 15 15 | `papertrail.txt` | Receipts / notifications | PaperTrail | 16 + | `notify.txt` | Desktop notification | (no move; only fires `notify-send` — see [Notifications](../notifications/)) | 16 17 | _(not in any list)_ | Unknown | ToScreen | 17 18 19 + ### Domain entries 20 + 21 + 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: 22 + 23 + ``` 24 + # screened_in.txt 25 + @ssp.sh # everyone at ssp.sh is approved … 26 + ``` 27 + 28 + ``` 29 + # screened_out.txt 30 + spammy@ssp.sh # … except this one address, which is blocked 31 + ``` 32 + 33 + 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. 34 + 18 35 ## Auto-screen and background sync 19 36 20 37 By default neomd screens your inbox automatically so you never have to press `S`: ··· 37 54 Press `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. 38 55 39 56 For individual senders, use `I` / `O` / `F` / `P` from any folder or the ToScreen queue. 57 + 58 + ### Whole-domain shortcuts: `Di` / `Do` 59 + 60 + When you want to approve or block **every** future address at a domain in one go, press the `D` chord: 61 + 62 + | Keys | Effect | 63 + | ---- | -------------------------------------------------------------------- | 64 + | `Di` | Append `@<domain>` to `screened_in.txt` (asks `y/n` first) | 65 + | `Do` | Append `@<domain>` to `screened_out.txt` (asks `y/n` first) | 66 + 67 + 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. 40 68 41 69 ## Bulk re-classification after updating your lists 42 70
+77 -12
internal/config/config.go
··· 59 59 Feed string `toml:"feed"` 60 60 PaperTrail string `toml:"papertrail"` 61 61 Spam string `toml:"spam"` 62 + Notify string `toml:"notify"` // optional: addresses or @domain entries that fire desktop notifications 63 + } 64 + 65 + // NotificationsConfig controls desktop notifications for emails landing in 66 + // folders the user cares about, scoped to senders listed in screener.notify. 67 + // TUI-only: the headless daemon never fires notifications. 68 + type NotificationsConfig struct { 69 + Enabled bool `toml:"enabled"` // opt-in, default false 70 + Command string `toml:"command"` // notify binary, default "notify-send" 71 + Icon string `toml:"icon"` // -i/--icon arg, default "mail-message-new" 72 + ExpireMs int `toml:"expire_ms"` // -t arg in milliseconds, default 5000 73 + Folders []string `toml:"folders"` // folder labels (e.g. "Inbox") to fire on; default ["Inbox"] 62 74 } 63 75 64 76 // FoldersConfig maps logical names to actual IMAP mailbox names. ··· 130 142 131 143 // UIConfig holds display preferences. 132 144 type UIConfig struct { 133 - Theme string `toml:"theme"` // dark | light | auto 134 - InboxCount int `toml:"inbox_count"` // number of messages to fetch 135 - Signature string `toml:"signature"` // legacy: plain signature (markdown). Deprecated in favor of [ui.signature] block. 136 - SignatureBlock SignatureConfig `toml:"signature_block"` // new structured signature config 137 - AutoScreenOnLoad *bool `toml:"auto_screen_on_load"` // screen inbox on every load (default true) 138 - BgSyncInterval int `toml:"bg_sync_interval"` // background sync interval in minutes (0 = disabled, default 5) 139 - BulkProgressThreshold int `toml:"bulk_progress_threshold"` // show progress counter for batches larger than this (default 10) 140 - DraftBackupCount int `toml:"draft_backup_count"` // rolling compose backups in ~/.cache/neomd/drafts/ (default 20, -1 = disabled) 141 - MarkAsReadAfterSecs int `toml:"mark_as_read_after_secs"` // seconds in reader before marking as read (0 = immediate, default 7) 145 + Theme string `toml:"theme"` // dark | light | auto 146 + InboxCount int `toml:"inbox_count"` // number of messages to fetch 147 + Signature string `toml:"signature"` // legacy: plain signature (markdown). Deprecated in favor of [ui.signature] block. 148 + SignatureBlock SignatureConfig `toml:"signature_block"` // new structured signature config 149 + AutoScreenOnLoad *bool `toml:"auto_screen_on_load"` // screen inbox on every load (default true) 150 + BgSyncInterval int `toml:"bg_sync_interval"` // background sync interval in minutes (0 = disabled, default 5) 151 + BulkProgressThreshold int `toml:"bulk_progress_threshold"` // show progress counter for batches larger than this (default 10) 152 + DraftBackupCount int `toml:"draft_backup_count"` // rolling compose backups in ~/.cache/neomd/drafts/ (default 20, -1 = disabled) 153 + MarkAsReadAfterSecs int `toml:"mark_as_read_after_secs"` // seconds in reader before marking as read (0 = immediate, default 7) 142 154 } 143 155 144 156 // TextSignature returns the text/markdown signature for editor and text/plain part. ··· 179 191 return *u.AutoScreenOnLoad 180 192 } 181 193 194 + // Resolved returns a copy with sensible fallbacks filled in for any field 195 + // the user enabled-but-left-blank. Safe to call when Enabled is false. 196 + func (n NotificationsConfig) Resolved() NotificationsConfig { 197 + out := n 198 + if out.Command == "" { 199 + out.Command = "notify-send" 200 + } 201 + if out.Icon == "" { 202 + out.Icon = "mail-message-new" 203 + } 204 + if out.ExpireMs <= 0 { 205 + out.ExpireMs = 5000 206 + } 207 + if len(out.Folders) == 0 { 208 + out.Folders = []string{"Inbox"} 209 + } 210 + return out 211 + } 212 + 213 + // FolderAllowed reports whether folder is in the configured Folders list 214 + // (case-insensitive, with sensible defaults applied). 215 + func (n NotificationsConfig) FolderAllowed(folder string) bool { 216 + r := n.Resolved() 217 + for _, f := range r.Folders { 218 + if strings.EqualFold(f, folder) { 219 + return true 220 + } 221 + } 222 + return false 223 + } 224 + 182 225 // Config is the root neomd configuration. 183 226 type Config struct { 184 227 // Accounts is the list of email accounts (use [[accounts]] in config.toml). ··· 196 239 // These share the active account's SMTP connection — no IMAP or credentials needed. 197 240 Senders []SenderConfig `toml:"senders"` 198 241 199 - Screener ScreenerConfig `toml:"screener"` 200 - Folders FoldersConfig `toml:"folders"` 201 - UI UIConfig `toml:"ui"` 242 + Screener ScreenerConfig `toml:"screener"` 243 + Folders FoldersConfig `toml:"folders"` 244 + UI UIConfig `toml:"ui"` 245 + Notifications NotificationsConfig `toml:"notifications"` 202 246 203 247 // AutoBCC, if set, is added to every outgoing email's Bcc field so the 204 248 // user keeps a copy in an external mailbox (e.g. their hey.com archive). ··· 295 339 return filepath.Join(os.TempDir(), fmt.Sprintf("neomd_%d_spy_pixels", os.Getuid())) 296 340 } 297 341 342 + // NotifyStatePath returns the path for the per-folder last-seen-UID baseline 343 + // used by the notification system to decide which messages count as "new". 344 + func NotifyStatePath() string { 345 + if dir, err := os.UserCacheDir(); err == nil { 346 + p := filepath.Join(dir, cacheDirName) 347 + _ = os.MkdirAll(p, 0700) 348 + return filepath.Join(p, "notify_state.json") 349 + } 350 + return filepath.Join(os.TempDir(), fmt.Sprintf("neomd_%d_notify_state.json", os.Getuid())) 351 + } 352 + 298 353 // welcomePath returns the path of the first-run marker file. 299 354 func welcomePath() string { 300 355 if dir, err := os.UserCacheDir(); err == nil { ··· 346 401 cfg.Screener.Feed = expandPath(cfg.Screener.Feed) 347 402 cfg.Screener.PaperTrail = expandPath(cfg.Screener.PaperTrail) 348 403 cfg.Screener.Spam = expandPath(cfg.Screener.Spam) 404 + cfg.Screener.Notify = expandPath(cfg.Screener.Notify) 349 405 350 406 // Ensure screener list directories and files exist so appending (I/O/F/P/$) 351 407 // works on a fresh install without manual mkdir or touching files. 352 408 for _, p := range []string{ 353 409 cfg.Screener.ScreenedIn, cfg.Screener.ScreenedOut, 354 410 cfg.Screener.Feed, cfg.Screener.PaperTrail, cfg.Screener.Spam, 411 + cfg.Screener.Notify, 355 412 } { 356 413 if p != "" { 357 414 _ = os.MkdirAll(filepath.Dir(p), 0700) ··· 464 521 Feed: filepath.Join(listsDir, "feed.txt"), 465 522 PaperTrail: filepath.Join(listsDir, "papertrail.txt"), 466 523 Spam: filepath.Join(listsDir, "spam.txt"), 524 + Notify: filepath.Join(listsDir, "notify.txt"), 525 + }, 526 + Notifications: NotificationsConfig{ 527 + Enabled: false, 528 + Command: "notify-send", 529 + Icon: "mail-message-new", 530 + ExpireMs: 5000, 531 + Folders: []string{"Inbox"}, 467 532 }, 468 533 Folders: FoldersConfig{ 469 534 Inbox: "INBOX",
+122
internal/notify/notify.go
··· 1 + // Package notify fires desktop notifications (via notify-send or compatible 2 + // CLI) for newly arrived emails whose sender is on the screener notify list. 3 + // 4 + // TUI-only: the headless daemon does not invoke this package, so notifications 5 + // never fire on a server / NAS where no one would see them. 6 + package notify 7 + 8 + import ( 9 + "os/exec" 10 + "strconv" 11 + "strings" 12 + 13 + "github.com/sspaeti/neomd/internal/config" 14 + "github.com/sspaeti/neomd/internal/imap" 15 + "github.com/sspaeti/neomd/internal/screener" 16 + ) 17 + 18 + // Notifier wraps a notification command (notify-send by default) and a 19 + // resolved-defaults config snapshot. 20 + type Notifier struct { 21 + cfg config.NotificationsConfig 22 + } 23 + 24 + // New returns a Notifier. Send is a no-op when cfg.Enabled is false. 25 + func New(cfg config.NotificationsConfig) *Notifier { 26 + return &Notifier{cfg: cfg.Resolved()} 27 + } 28 + 29 + // Enabled reports whether notifications would be sent. Useful so callers can 30 + // skip building dstByUID maps when the feature is off. 31 + func (n *Notifier) Enabled() bool { 32 + return n != nil && n.cfg.Enabled 33 + } 34 + 35 + // 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 + func (n *Notifier) Send(title, body string) error { 39 + if !n.cfg.Enabled { 40 + return nil 41 + } 42 + args := []string{ 43 + "-i", n.cfg.Icon, 44 + "-t", strconv.Itoa(n.cfg.ExpireMs), 45 + "-a", "neomd", 46 + title, 47 + truncate(body, 200), 48 + } 49 + return exec.Command(n.cfg.Command, args...).Run() 50 + } 51 + 52 + // MaybeNotify processes a freshly fetched batch of emails from sourceFolder. 53 + // For each email with UID > the per-(account, folder) baseline whose sender is 54 + // in the screener notify list and whose post-screening destination is in the 55 + // configured Folders allowlist, a notification fires. 56 + // 57 + // First-run behaviour: when no baseline exists yet, MaybeNotify silently 58 + // records the highest UID it saw and fires *no* notifications — this prevents 59 + // the entire current Inbox from notifying the first time the feature is 60 + // enabled. 61 + // 62 + // dstByUID maps a UID to the folder label where the email is *about to* live 63 + // after auto-screening (caller computes this from screener.ClassifyForScreen). 64 + // UIDs missing from dstByUID are assumed to stay in sourceFolder. 65 + // 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 { 68 + if !n.Enabled() || sc == nil || state == nil || len(emails) == 0 { 69 + return 0 70 + } 71 + key := stateKey(account, sourceFolder) 72 + baseline, hadBaseline := state.Get(key) 73 + 74 + var maxUID uint32 75 + sent := 0 76 + for i := range emails { 77 + e := &emails[i] 78 + if e.UID > maxUID { 79 + maxUID = e.UID 80 + } 81 + if !hadBaseline || e.UID <= baseline { 82 + continue 83 + } 84 + if !sc.ShouldNotify(e.From) { 85 + continue 86 + } 87 + dst, ok := dstByUID[e.UID] 88 + if !ok { 89 + dst = sourceFolder 90 + } 91 + if !n.cfg.FolderAllowed(dst) { 92 + continue 93 + } 94 + title := "neomd: " + truncate(e.From, 80) 95 + body := e.Subject 96 + if body == "" { 97 + body = "(no subject)" 98 + } 99 + if err := n.Send(title, body); err == nil { 100 + sent++ 101 + } 102 + } 103 + 104 + if maxUID > baseline { 105 + state.Set(key, maxUID) 106 + _ = state.Save() 107 + } 108 + return sent 109 + } 110 + 111 + func stateKey(account, folder string) string { 112 + return account + "|" + folder 113 + } 114 + 115 + func truncate(s string, n int) string { 116 + s = strings.TrimSpace(s) 117 + if len([]rune(s)) <= n { 118 + return s 119 + } 120 + r := []rune(s) 121 + return string(r[:n]) + "…" 122 + }
+168
internal/notify/notify_test.go
··· 1 + package notify 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "testing" 7 + 8 + "github.com/sspaeti/neomd/internal/config" 9 + "github.com/sspaeti/neomd/internal/imap" 10 + "github.com/sspaeti/neomd/internal/screener" 11 + ) 12 + 13 + func newScreener(t *testing.T, notifyEntries []string) *screener.Screener { 14 + t.Helper() 15 + dir := t.TempDir() 16 + notifyPath := filepath.Join(dir, "notify.txt") 17 + if len(notifyEntries) > 0 { 18 + body := "" 19 + for _, e := range notifyEntries { 20 + body += e + "\n" 21 + } 22 + if err := os.WriteFile(notifyPath, []byte(body), 0600); err != nil { 23 + t.Fatal(err) 24 + } 25 + } 26 + sc, err := screener.New(screener.Config{ 27 + ScreenedIn: filepath.Join(dir, "in.txt"), 28 + ScreenedOut: filepath.Join(dir, "out.txt"), 29 + Feed: filepath.Join(dir, "feed.txt"), 30 + PaperTrail: filepath.Join(dir, "pt.txt"), 31 + Spam: filepath.Join(dir, "spam.txt"), 32 + Notify: notifyPath, 33 + }) 34 + if err != nil { 35 + t.Fatal(err) 36 + } 37 + return sc 38 + } 39 + 40 + func TestNotifier_DisabledIsNoop(t *testing.T) { 41 + n := New(config.NotificationsConfig{Enabled: false}) 42 + if n.Enabled() { 43 + t.Error("expected Enabled() = false") 44 + } 45 + if err := n.Send("title", "body"); err != nil { 46 + t.Errorf("Send when disabled returned %v, want nil", err) 47 + } 48 + } 49 + 50 + func TestNotifier_ResolvedDefaults(t *testing.T) { 51 + n := New(config.NotificationsConfig{Enabled: true}) 52 + if n.cfg.Command != "notify-send" { 53 + t.Errorf("Command default = %q, want notify-send", n.cfg.Command) 54 + } 55 + if n.cfg.Icon != "mail-message-new" { 56 + t.Errorf("Icon default = %q", n.cfg.Icon) 57 + } 58 + if n.cfg.ExpireMs != 5000 { 59 + t.Errorf("ExpireMs default = %d", n.cfg.ExpireMs) 60 + } 61 + if len(n.cfg.Folders) != 1 || n.cfg.Folders[0] != "Inbox" { 62 + t.Errorf("Folders default = %v", n.cfg.Folders) 63 + } 64 + } 65 + 66 + func TestMaybeNotify_FirstRunBaselineSilent(t *testing.T) { 67 + statePath := filepath.Join(t.TempDir(), "state.json") 68 + state := LoadState(statePath) 69 + sc := newScreener(t, []string{"vip@example.com"}) 70 + // Use a fake command that always succeeds so we'd notice if it was invoked. 71 + n := New(config.NotificationsConfig{Enabled: true, Command: "true", Folders: []string{"Inbox"}}) 72 + emails := []imap.Email{ 73 + {UID: 100, From: "vip@example.com", Subject: "hi"}, 74 + {UID: 101, From: "other@example.com", Subject: "hello"}, 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) 79 + } 80 + uid, ok := state.Get(stateKey("acct", "Inbox")) 81 + if !ok || uid != 101 { 82 + t.Errorf("baseline = (%d, %v), want (101, true)", uid, ok) 83 + } 84 + } 85 + 86 + func TestMaybeNotify_OnlyNewEmailsFromNotifyList(t *testing.T) { 87 + statePath := filepath.Join(t.TempDir(), "state.json") 88 + state := LoadState(statePath) 89 + state.Set(stateKey("acct", "Inbox"), 100) 90 + 91 + sc := newScreener(t, []string{"vip@example.com"}) 92 + n := New(config.NotificationsConfig{Enabled: true, Command: "true", Folders: []string{"Inbox"}}) 93 + 94 + emails := []imap.Email{ 95 + {UID: 100, From: "vip@example.com", Subject: "old"}, // not new 96 + {UID: 101, From: "vip@example.com", Subject: "new!"}, // new + on notify list 97 + {UID: 102, From: "other@example.com", Subject: "noise"}, // new but not on notify list 98 + } 99 + sent := n.MaybeNotify("acct", "Inbox", emails, nil, sc, state) 100 + if sent != 1 { 101 + t.Errorf("sent = %d, want 1", sent) 102 + } 103 + } 104 + 105 + func TestMaybeNotify_DomainEntry(t *testing.T) { 106 + statePath := filepath.Join(t.TempDir(), "state.json") 107 + state := LoadState(statePath) 108 + state.Set(stateKey("acct", "Inbox"), 0) 109 + 110 + sc := newScreener(t, []string{"@important.org"}) 111 + n := New(config.NotificationsConfig{Enabled: true, Command: "true", Folders: []string{"Inbox"}}) 112 + 113 + emails := []imap.Email{ 114 + {UID: 1, From: "alice@important.org", Subject: "x"}, 115 + {UID: 2, From: "bob@important.org", Subject: "y"}, 116 + {UID: 3, From: "spam@nowhere.com", Subject: "z"}, 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) 121 + } 122 + } 123 + 124 + func TestMaybeNotify_FolderAllowlistFiltersOut(t *testing.T) { 125 + statePath := filepath.Join(t.TempDir(), "state.json") 126 + state := LoadState(statePath) 127 + state.Set(stateKey("acct", "Inbox"), 0) 128 + 129 + sc := newScreener(t, []string{"vip@example.com"}) 130 + n := New(config.NotificationsConfig{Enabled: true, Command: "true", Folders: []string{"Inbox"}}) 131 + 132 + emails := []imap.Email{ 133 + {UID: 5, From: "vip@example.com", Subject: "moved-to-feed"}, 134 + } 135 + // Email is auto-screened to Feed → allowlist excludes it. 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) 140 + } 141 + } 142 + 143 + func TestState_PersistAndReload(t *testing.T) { 144 + path := filepath.Join(t.TempDir(), "state.json") 145 + state := LoadState(path) 146 + state.Set("acct|Inbox", 42) 147 + if err := state.Save(); err != nil { 148 + t.Fatal(err) 149 + } 150 + reloaded := LoadState(path) 151 + uid, ok := reloaded.Get("acct|Inbox") 152 + if !ok || uid != 42 { 153 + t.Errorf("reloaded = (%d, %v), want (42, true)", uid, ok) 154 + } 155 + } 156 + 157 + func TestState_MissingFileReturnsEmpty(t *testing.T) { 158 + state := LoadState(filepath.Join(t.TempDir(), "does-not-exist.json")) 159 + if state == nil { 160 + t.Fatal("LoadState should never return nil") 161 + } 162 + if state.UIDs == nil { 163 + t.Error("UIDs map should be initialised") 164 + } 165 + if _, ok := state.Get("anything"); ok { 166 + t.Error("expected no entries") 167 + } 168 + }
+64
internal/notify/state.go
··· 1 + package notify 2 + 3 + import ( 4 + "encoding/json" 5 + "os" 6 + "path/filepath" 7 + "sync" 8 + ) 9 + 10 + // State persists per-(account, folder) "highest UID seen" baselines so a 11 + // neomd restart doesn't replay every Inbox notification. Concurrent-safe. 12 + type State struct { 13 + path string 14 + mu sync.Mutex 15 + UIDs map[string]uint32 `json:"uids"` 16 + } 17 + 18 + // LoadState reads path. A missing or corrupt file yields an empty State; the 19 + // caller will treat the first observation per folder as the new baseline. 20 + func LoadState(path string) *State { 21 + s := &State{path: path, UIDs: map[string]uint32{}} 22 + data, err := os.ReadFile(path) 23 + if err != nil { 24 + return s 25 + } 26 + _ = json.Unmarshal(data, s) 27 + if s.UIDs == nil { 28 + s.UIDs = map[string]uint32{} 29 + } 30 + return s 31 + } 32 + 33 + // Get returns the recorded UID and whether one existed. 34 + func (s *State) Get(key string) (uint32, bool) { 35 + s.mu.Lock() 36 + defer s.mu.Unlock() 37 + uid, ok := s.UIDs[key] 38 + return uid, ok 39 + } 40 + 41 + // Set records uid for key. Caller is responsible for calling Save. 42 + func (s *State) Set(key string, uid uint32) { 43 + s.mu.Lock() 44 + defer s.mu.Unlock() 45 + s.UIDs[key] = uid 46 + } 47 + 48 + // Save writes the state to disk atomically (temp file + rename). 49 + func (s *State) Save() error { 50 + s.mu.Lock() 51 + data, err := json.Marshal(s) 52 + s.mu.Unlock() 53 + if err != nil { 54 + return err 55 + } 56 + if err := os.MkdirAll(filepath.Dir(s.path), 0700); err != nil { 57 + return err 58 + } 59 + tmp := s.path + ".tmp" 60 + if err := os.WriteFile(tmp, data, 0600); err != nil { 61 + return err 62 + } 63 + return os.Rename(tmp, s.path) 64 + }
+61 -4
internal/screener/screener.go
··· 47 47 Feed string 48 48 PaperTrail string 49 49 Spam string 50 + Notify string // optional: addresses (or @domain) to fire desktop notifications for 50 51 } 51 52 52 53 // Screener holds loaded allowlists in memory for fast classification. ··· 57 58 feed map[string]bool 58 59 paperTrail map[string]bool 59 60 spam map[string]bool 61 + notify map[string]bool 60 62 } 61 63 62 64 // Snapshot is a point-in-time copy of all screener list files and in-memory sets. ··· 79 81 feed: make(map[string]bool), 80 82 paperTrail: make(map[string]bool), 81 83 spam: make(map[string]bool), 84 + notify: make(map[string]bool), 82 85 } 83 86 for path, m := range map[string]map[string]bool{ 84 87 cfg.ScreenedIn: s.screenedIn, ··· 86 89 cfg.Feed: s.feed, 87 90 cfg.PaperTrail: s.paperTrail, 88 91 cfg.Spam: s.spam, 92 + cfg.Notify: s.notify, 89 93 } { 94 + if path == "" { 95 + continue 96 + } 90 97 if err := loadList(path, m); err != nil { 91 98 return nil, fmt.Errorf("load screener list %s: %w", path, err) 92 99 } ··· 120 127 121 128 // Classify returns the category for a given "from" email address. 122 129 // The address is normalised to lowercase before matching. 130 + // 131 + // List entries can be either an exact email ("john@ssp.sh") or a domain 132 + // prefixed with "@" ("@ssp.sh") that matches any address at that domain. 133 + // Exact matches always win over domain matches: an exact entry in any list 134 + // is consulted before the @-domain entries in the same priority pass, but 135 + // crucially the per-list priority itself (spam > out > feed > papertrail > 136 + // in) is preserved across both passes by iterating the lists in order twice. 123 137 func (s *Screener) Classify(from string) Category { 124 138 addr := normalise(from) 125 139 switch { 126 140 case s.spam[addr]: 127 - return CategorySpam // spam wins over everything 141 + return CategorySpam 128 142 case s.screenedOut[addr]: 129 - return CategoryScreenedOut // hard block 143 + return CategoryScreenedOut 130 144 case s.feed[addr]: 131 - return CategoryFeed // specific routing beats generic approval 145 + return CategoryFeed 132 146 case s.paperTrail[addr]: 133 147 return CategoryPaperTrail 134 148 case s.screenedIn[addr]: 135 - return CategoryInbox // trusted but no specific folder → stay in Inbox 149 + return CategoryInbox 150 + } 151 + // No exact match — try @domain entries in the same priority order so a 152 + // per-address override remains stronger than a domain rule. 153 + switch { 154 + case domainMatch(addr, s.spam): 155 + return CategorySpam 156 + case domainMatch(addr, s.screenedOut): 157 + return CategoryScreenedOut 158 + case domainMatch(addr, s.feed): 159 + return CategoryFeed 160 + case domainMatch(addr, s.paperTrail): 161 + return CategoryPaperTrail 162 + case domainMatch(addr, s.screenedIn): 163 + return CategoryInbox 136 164 default: 137 165 return CategoryToScreen 138 166 } ··· 143 171 func (s *Screener) ClassifyDebug(from string) (Category, string) { 144 172 addr := normalise(from) 145 173 return s.Classify(from), addr 174 + } 175 + 176 + // ShouldNotify reports whether a desktop notification should fire for this 177 + // sender. True when from (or its domain via "@domain.tld") is in notify.txt. 178 + // Independent of the screening Category: notify and screening are orthogonal. 179 + func (s *Screener) ShouldNotify(from string) bool { 180 + if len(s.notify) == 0 { 181 + return false 182 + } 183 + return matchAddr(normalise(from), s.notify) 184 + } 185 + 186 + // matchAddr returns true if addr is in set, either as an exact email or as 187 + // a "@domain" entry covering its domain. addr must already be normalised. 188 + func matchAddr(addr string, set map[string]bool) bool { 189 + if set[addr] { 190 + return true 191 + } 192 + return domainMatch(addr, set) 193 + } 194 + 195 + // domainMatch returns true only via the "@domain" form (skipping the exact 196 + // check). Used by Classify so that the priority loop can be split into an 197 + // exact-match pass first, then a domain-match pass. 198 + func domainMatch(addr string, set map[string]bool) bool { 199 + if i := strings.IndexByte(addr, '@'); i >= 0 { 200 + return set[addr[i:]] 201 + } 202 + return false 146 203 } 147 204 148 205 // Approve adds addr to screened_in.txt and removes it from all conflicting lists.
+145
internal/screener/screener_test.go
··· 127 127 from: "", 128 128 want: CategoryToScreen, 129 129 }, 130 + { 131 + name: "@domain in screened_in matches any address at that domain", 132 + screener: &Screener{ 133 + screenedIn: map[string]bool{"@ssp.sh": true}, 134 + screenedOut: map[string]bool{}, 135 + feed: map[string]bool{}, 136 + paperTrail: map[string]bool{}, 137 + spam: map[string]bool{}, 138 + }, 139 + from: "anyone@ssp.sh", 140 + want: CategoryInbox, 141 + }, 142 + { 143 + name: "@domain in screened_out blocks any address at that domain", 144 + screener: &Screener{ 145 + screenedIn: map[string]bool{}, 146 + screenedOut: map[string]bool{"@spammy.io": true}, 147 + feed: map[string]bool{}, 148 + paperTrail: map[string]bool{}, 149 + spam: map[string]bool{}, 150 + }, 151 + from: "Promo Bot <promo@spammy.io>", 152 + want: CategoryScreenedOut, 153 + }, 154 + { 155 + name: "exact email beats @domain in different lists", 156 + screener: &Screener{ 157 + // Exact john@ssp.sh is blocked, but @ssp.sh is approved overall. 158 + screenedIn: map[string]bool{"@ssp.sh": true}, 159 + screenedOut: map[string]bool{"john@ssp.sh": true}, 160 + feed: map[string]bool{}, 161 + paperTrail: map[string]bool{}, 162 + spam: map[string]bool{}, 163 + }, 164 + from: "john@ssp.sh", 165 + want: CategoryScreenedOut, 166 + }, 167 + { 168 + name: "domain rule does not match different domain", 169 + screener: &Screener{ 170 + screenedIn: map[string]bool{"@ssp.sh": true}, 171 + screenedOut: map[string]bool{}, 172 + feed: map[string]bool{}, 173 + paperTrail: map[string]bool{}, 174 + spam: map[string]bool{}, 175 + }, 176 + from: "alice@example.com", 177 + want: CategoryToScreen, 178 + }, 179 + { 180 + name: "@domain entry is case-insensitive on the domain part", 181 + screener: &Screener{ 182 + screenedIn: map[string]bool{"@example.com": true}, 183 + screenedOut: map[string]bool{}, 184 + feed: map[string]bool{}, 185 + paperTrail: map[string]bool{}, 186 + spam: map[string]bool{}, 187 + }, 188 + from: "User@EXAMPLE.com", 189 + want: CategoryInbox, 190 + }, 130 191 } 131 192 132 193 for _, tt := range tests { ··· 374 435 } 375 436 if string(data) != "undo@example.com\n" { 376 437 t.Fatalf("screened_in contents = %q, want restored entry", data) 438 + } 439 + }) 440 + } 441 + 442 + // --------------------------------------------------------------------------- 443 + // TestShouldNotify — notify list is independent of categories 444 + // --------------------------------------------------------------------------- 445 + 446 + func TestShouldNotify(t *testing.T) { 447 + t.Run("empty list returns false", func(t *testing.T) { 448 + s := &Screener{notify: map[string]bool{}} 449 + if s.ShouldNotify("anyone@example.com") { 450 + t.Error("ShouldNotify should be false when notify is empty") 451 + } 452 + }) 453 + 454 + t.Run("exact email match", func(t *testing.T) { 455 + s := &Screener{notify: map[string]bool{"vip@example.com": true}} 456 + if !s.ShouldNotify("vip@example.com") { 457 + t.Error("expected exact match") 458 + } 459 + if s.ShouldNotify("other@example.com") { 460 + t.Error("non-listed address should not notify") 461 + } 462 + }) 463 + 464 + t.Run("@domain match notifies any address at that domain", func(t *testing.T) { 465 + s := &Screener{notify: map[string]bool{"@ssp.sh": true}} 466 + if !s.ShouldNotify("anyone@ssp.sh") { 467 + t.Error("expected domain match") 468 + } 469 + if s.ShouldNotify("anyone@other.tld") { 470 + t.Error("different domain should not notify") 471 + } 472 + }) 473 + 474 + t.Run("normalises display name and case", func(t *testing.T) { 475 + s := &Screener{notify: map[string]bool{"vip@example.com": true}} 476 + if !s.ShouldNotify("VIP <VIP@Example.com>") { 477 + t.Error("expected normalised match") 478 + } 479 + }) 480 + 481 + t.Run("loads from notify.txt via New", func(t *testing.T) { 482 + dir := t.TempDir() 483 + notifyPath := filepath.Join(dir, "notify.txt") 484 + os.WriteFile(notifyPath, []byte("@important.org\nboss@work.com\n"), 0600) 485 + s, err := New(Config{ 486 + ScreenedIn: filepath.Join(dir, "in.txt"), 487 + ScreenedOut: filepath.Join(dir, "out.txt"), 488 + Feed: filepath.Join(dir, "feed.txt"), 489 + PaperTrail: filepath.Join(dir, "pt.txt"), 490 + Spam: filepath.Join(dir, "spam.txt"), 491 + Notify: notifyPath, 492 + }) 493 + if err != nil { 494 + t.Fatal(err) 495 + } 496 + if !s.ShouldNotify("alice@important.org") { 497 + t.Error("@important.org domain entry should match") 498 + } 499 + if !s.ShouldNotify("boss@work.com") { 500 + t.Error("boss@work.com exact entry should match") 501 + } 502 + if s.ShouldNotify("nobody@nowhere.com") { 503 + t.Error("unrelated address should not notify") 504 + } 505 + }) 506 + 507 + t.Run("Notify path empty leaves the list empty", func(t *testing.T) { 508 + dir := t.TempDir() 509 + s, err := New(Config{ 510 + ScreenedIn: filepath.Join(dir, "in.txt"), 511 + ScreenedOut: filepath.Join(dir, "out.txt"), 512 + Feed: filepath.Join(dir, "feed.txt"), 513 + PaperTrail: filepath.Join(dir, "pt.txt"), 514 + Spam: filepath.Join(dir, "spam.txt"), 515 + // Notify intentionally unset 516 + }) 517 + if err != nil { 518 + t.Fatal(err) 519 + } 520 + if s.ShouldNotify("anyone@anywhere.com") { 521 + t.Error("ShouldNotify should be false when no notify list configured") 377 522 } 378 523 }) 379 524 }
+2
internal/ui/keys.go
··· 44 44 {"$", "mark as Spam → spam.txt + move to Spam (removes from screened_in/out)"}, 45 45 {"F", "mark as Feed → feed.txt + move to Feed"}, 46 46 {"P", "mark as PaperTrail → papertrail.txt + move to PaperTrail"}, 47 + {"Di", "approve whole DOMAIN → @domain.tld appended to screened_in.txt (cursor or open email; y/n)"}, 48 + {"Do", "block whole DOMAIN → @domain.tld appended to screened_out.txt (cursor or open email; y/n)"}, 47 49 {"A", "archive (move to Archive, no screener update)"}, 48 50 {"B", "move to Work/business (no screener update, if configured)"}, 49 51 {"S", "dry-run screen inbox (loaded emails), then y/n"},
+191 -25
internal/ui/model.go
··· 25 25 "github.com/sspaeti/neomd/internal/editor" 26 26 "github.com/sspaeti/neomd/internal/imap" 27 27 "github.com/sspaeti/neomd/internal/listmonk" 28 + "github.com/sspaeti/neomd/internal/notify" 28 29 "github.com/sspaeti/neomd/internal/render" 29 30 "github.com/sspaeti/neomd/internal/screener" 30 31 "github.com/sspaeti/neomd/internal/smtp" ··· 34 35 type viewState int 35 36 36 37 const ( 37 - stateInbox viewState = iota 38 - stateReading // reading a single email 39 - stateCompose // composing a new email 40 - statePresend // pre-send review: add attachments, then send or edit again 41 - stateHelp // help overlay 42 - stateWelcome // first-run welcome popup 43 - stateReaction // emoji reaction picker 38 + stateInbox viewState = iota 39 + stateReading // reading a single email 40 + stateCompose // composing a new email 41 + statePresend // pre-send review: add attachments, then send or edit again 42 + stateHelp // help overlay 43 + stateWelcome // first-run welcome popup 44 + stateReaction // emoji reaction picker 44 45 ) 45 46 46 47 // async message types ··· 455 456 dst string 456 457 } 457 458 459 + // pendingDomainAction queues a domain-level screener mutation awaiting y/n. 460 + // entry is the storage form ("@ssp.sh"); action is "I" (approve) or "O" (block). 461 + type pendingDomainAction struct { 462 + entry string 463 + action string 464 + } 465 + 458 466 // Model is the root bubbletea model. 459 467 type Model struct { 460 - cfg *config.Config 461 - accounts []config.AccountConfig // all configured accounts 462 - clients []*imap.Client // one IMAP client per account 463 - accountI int // index of the active account 464 - screener *screener.Screener 468 + cfg *config.Config 469 + accounts []config.AccountConfig // all configured accounts 470 + clients []*imap.Client // one IMAP client per account 471 + accountI int // index of the active account 472 + screener *screener.Screener 473 + notifier *notify.Notifier 474 + notifyState *notify.State 465 475 466 476 state viewState 467 477 width int ··· 487 497 openBody string // markdown body used by the TUI reader 488 498 openHTMLBody string // original HTML part; used by openInExternalViewer when available 489 499 openWebURL string // canonical "view online" URL for ctrl+o (may be empty) 490 - openAttachments []imap.Attachment // attachments of the currently open email 491 - openLinks []emailLink // extracted links from the email body 492 - openSpyPixels imap.SpyPixelInfo // spy pixels detected in the currently open email 493 - readerPending string // chord prefix in reader (space for link open) 500 + openAttachments []imap.Attachment // attachments of the currently open email 501 + openLinks []emailLink // extracted links from the email body 502 + openSpyPixels imap.SpyPixelInfo // spy pixels detected in the currently open email 503 + readerPending string // chord prefix in reader (space for link open) 494 504 // Mark-as-read timer tracking 495 505 markAsReadUID uint32 // UID of email with pending mark-as-read timer 496 506 markAsReadFolder string // folder of email with pending mark-as-read timer ··· 574 584 // being bulk-moved back to Inbox. 575 585 pendingResetUIDs []uint32 576 586 587 + // pendingDomainOp holds an "@domain" screener entry (e.g. "@ssp.sh") 588 + // awaiting y/n confirmation, plus the action to execute ("I" approve, 589 + // "O" block). Set by the Di / Do reader chord; cleared on y, n, or any 590 + // other key. 591 + pendingDomainOp *pendingDomainAction 592 + 577 593 // pendingDeleteAll holds UIDs + folder awaiting y/n before permanent deletion. 578 594 pendingDeleteAll *deleteAllReadyMsg 579 595 ··· 611 627 612 628 spyKeys, scannedKeys := loadSpyPixelCache() 613 629 return Model{ 614 - cfg: cfg, 615 - accounts: cfg.ActiveAccounts(), 616 - clients: clients, 617 - screener: sc, 618 - state: stateInbox, 619 - loading: true, 620 - folders: cfg.Folders.TabLabels(), 621 - cmdHistory: loadCmdHistory(config.HistoryPath()), 622 - cmdHistI: -1, 630 + cfg: cfg, 631 + accounts: cfg.ActiveAccounts(), 632 + clients: clients, 633 + screener: sc, 634 + notifier: notify.New(cfg.Notifications), 635 + notifyState: notify.LoadState(config.NotifyStatePath()), 636 + state: stateInbox, 637 + loading: true, 638 + folders: cfg.Folders.TabLabels(), 639 + cmdHistory: loadCmdHistory(config.HistoryPath()), 640 + cmdHistI: -1, 623 641 // Note: Spam is intentionally excluded from tabs — use :go-spam to visit. 624 642 compose: compose, 625 643 spinner: sp, ··· 1367 1385 return moves 1368 1386 } 1369 1387 1388 + // execDomainScreen applies a confirmed domain-level screener mutation 1389 + // (Approve or Block on a "@domain" entry). Reloads the active folder so the 1390 + // view reflects any senders that have just been reclassified. 1391 + func (m Model) execDomainScreen(op *pendingDomainAction) (tea.Model, tea.Cmd) { 1392 + var err error 1393 + switch op.action { 1394 + case "I": 1395 + err = m.screener.Approve(op.entry) 1396 + case "O": 1397 + err = m.screener.Block(op.entry) 1398 + default: 1399 + m.status = "internal error: unknown domain action" 1400 + m.isError = true 1401 + return m, nil 1402 + } 1403 + if err != nil { 1404 + m.status = fmt.Sprintf("Domain screen failed: %v", err) 1405 + m.isError = true 1406 + return m, nil 1407 + } 1408 + verb := "approved" 1409 + if op.action == "O" { 1410 + verb = "blocked" 1411 + } 1412 + m.status = fmt.Sprintf("Domain %s %s.", op.entry, verb) 1413 + m.loading = true 1414 + return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 1415 + } 1416 + 1417 + // maybeNotifyInbox dispatches desktop notifications for the freshly fetched 1418 + // emails. moves describes where each email is heading after auto-screening 1419 + // (pass nil if no auto-screen pass ran). Folder is the IMAP source folder 1420 + // label the fetch came from. No-op when the notifier is disabled. 1421 + func (m Model) maybeNotifyInbox(folder string, emails []imap.Email, moves []autoScreenMove) { 1422 + if !m.notifier.Enabled() || len(emails) == 0 { 1423 + return 1424 + } 1425 + dstByUID := make(map[uint32]string, len(moves)) 1426 + for _, mv := range moves { 1427 + if mv.email != nil { 1428 + dstByUID[mv.email.UID] = mv.dst 1429 + } 1430 + } 1431 + m.notifier.MaybeNotify(m.activeAccount().Name, folder, emails, dstByUID, m.screener, m.notifyState) 1432 + } 1433 + 1370 1434 // previewAutoScreen classifies the currently loaded inbox emails (no IMAP). 1371 1435 func (m Model) previewAutoScreen() []autoScreenMove { 1372 1436 return m.classifyForScreen(m.emails) ··· 1765 1829 return m, tea.Batch(sortCmd, m.fetchFolderCountsCmd()) 1766 1830 } 1767 1831 if moves := m.previewAutoScreen(); len(moves) > 0 { 1832 + m.maybeNotifyInbox(msg.folder, msg.emails, moves) 1768 1833 m.loading = true 1769 1834 m.bulkProgress = m.newBulkOp("Screening", len(moves)) 1770 1835 return m, tea.Batch(sortCmd, m.fetchFolderCountsCmd(), m.spinner.Tick, m.execAutoScreenCmd(moves)) 1771 1836 } 1772 1837 } 1838 + if msg.folder == m.cfg.Folders.Inbox { 1839 + m.maybeNotifyInbox(msg.folder, msg.emails, nil) 1840 + } 1773 1841 return m, tea.Batch(sortCmd, m.fetchFolderCountsCmd()) 1774 1842 1775 1843 case folderCountsMsg: ··· 2202 2270 return m, nil 2203 2271 } 2204 2272 moves := m.classifyForScreen(msg.emails) 2273 + m.maybeNotifyInbox(m.cfg.Folders.Inbox, msg.emails, moves) 2205 2274 if len(moves) == 0 { 2206 2275 // No moves needed - background sync is complete 2207 2276 m.bgSyncInProgress = false ··· 2453 2522 m.pendingMoves = nil 2454 2523 m.pendingResetUIDs = nil 2455 2524 m.pendingDeleteAll = nil 2525 + m.pendingDomainOp = nil 2456 2526 } 2457 2527 m.status = "" 2458 2528 m.isError = false ··· 2502 2572 m.status = "move to: Mi inbox Ma archive Mf feed Mp papertrail Mt trash Mo screened-out Mw waiting Mc scheduled Mm someday" 2503 2573 return m, nil 2504 2574 2575 + case "D": 2576 + m.pendingKey = "D" 2577 + m.status = "domain screen: Di in (@domain → screened_in) Do out (@domain → screened_out) (esc to cancel)" 2578 + return m, nil 2579 + 2505 2580 case ",": 2506 2581 m.pendingKey = "," 2507 2582 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" ··· 2618 2693 return m, nil 2619 2694 2620 2695 case "y": 2696 + if m.pendingDomainOp != nil { 2697 + op := m.pendingDomainOp 2698 + m.pendingDomainOp = nil 2699 + return m.execDomainScreen(op) 2700 + } 2621 2701 if m.pendingDeleteAll != nil { 2622 2702 p := m.pendingDeleteAll 2623 2703 m.pendingDeleteAll = nil ··· 2641 2721 return m, tea.Batch(m.spinner.Tick, m.execAutoScreenCmd(moves)) 2642 2722 2643 2723 case "n": 2724 + if m.pendingDomainOp != nil { 2725 + m.pendingDomainOp = nil 2726 + m.status = "Cancelled." 2727 + return m, nil 2728 + } 2644 2729 if m.pendingDeleteAll != nil || len(m.pendingResetUIDs) > 0 || len(m.pendingMoves) > 0 { 2645 2730 m.pendingDeleteAll = nil 2646 2731 m.pendingResetUIDs = nil ··· 3156 3241 } 3157 3242 m.status = fmt.Sprintf("unknown: M%s", key) 3158 3243 3244 + case "D": 3245 + if key != "i" && key != "o" { 3246 + m.status = fmt.Sprintf("unknown: D%s (use Di or Do)", key) 3247 + return m, nil 3248 + } 3249 + e := selectedEmail(m.inbox) 3250 + if e == nil { 3251 + m.status = "No email selected for domain action." 3252 + m.isError = true 3253 + return m, nil 3254 + } 3255 + entry := domainEntry(e.From) 3256 + if entry == "" { 3257 + m.status = "Selected email has no parseable domain." 3258 + m.isError = true 3259 + return m, nil 3260 + } 3261 + action := strings.ToUpper(key) 3262 + m.pendingDomainOp = &pendingDomainAction{entry: entry, action: action} 3263 + verb := "IN" 3264 + if action == "O" { 3265 + verb = "OUT" 3266 + } 3267 + m.status = fmt.Sprintf("Screen %s domain %s? (y/n) — affects every future sender at this domain", verb, entry) 3268 + return m, nil 3269 + 3159 3270 case ",": 3160 3271 type sortSpec struct { 3161 3272 field string ··· 3238 3349 m.reader.GotoTop() 3239 3350 return m, nil 3240 3351 } 3352 + case "D": // Di / Do = domain-level screen IN / OUT for the open email 3353 + if key != "i" && key != "o" { 3354 + m.status = fmt.Sprintf("unknown: D%s (use Di or Do)", key) 3355 + m.isError = true 3356 + return m, nil 3357 + } 3358 + if m.openEmail == nil { 3359 + return m, nil 3360 + } 3361 + entry := domainEntry(m.openEmail.From) 3362 + if entry == "" { 3363 + m.status = "Open email has no parseable domain." 3364 + m.isError = true 3365 + return m, nil 3366 + } 3367 + action := strings.ToUpper(key) 3368 + m.pendingDomainOp = &pendingDomainAction{entry: entry, action: action} 3369 + verb := "IN" 3370 + if action == "O" { 3371 + verb = "OUT" 3372 + } 3373 + m.status = fmt.Sprintf("Screen %s domain %s? (y/n) — affects every future sender at this domain", verb, entry) 3374 + return m, nil 3241 3375 default: 3242 3376 // Handle "l[0-9]" pattern (first digit entered, waiting for second) 3243 3377 if len(pending) == 2 && pending[0] == 'l' && pending[1] >= '0' && pending[1] <= '9' { ··· 3253 3387 return m, nil 3254 3388 } 3255 3389 } 3390 + } 3391 + } 3392 + 3393 + // Pending domain-screen confirmation (set by Di / Do chord) — only y/n accepted. 3394 + if m.pendingDomainOp != nil { 3395 + switch key { 3396 + case "y": 3397 + op := m.pendingDomainOp 3398 + m.pendingDomainOp = nil 3399 + return m.execDomainScreen(op) 3400 + case "n", "esc": 3401 + m.pendingDomainOp = nil 3402 + m.status = "Cancelled." 3403 + return m, nil 3404 + default: 3405 + // Any other key cancels the confirmation but does not consume the key. 3406 + m.pendingDomainOp = nil 3407 + m.status = "" 3256 3408 } 3257 3409 } 3258 3410 ··· 3328 3480 return m, nil 3329 3481 case "G": 3330 3482 m.reader.GotoBottom() 3483 + return m, nil 3484 + case "D": 3485 + m.readerPending = "D" 3486 + m.status = "domain screen: Di in (@domain → screened_in) Do out (@domain → screened_out) (esc to cancel)" 3331 3487 return m, nil 3332 3488 } 3333 3489 var cmd tea.Cmd ··· 4504 4660 } 4505 4661 } 4506 4662 return strings.TrimSpace(s) 4663 + } 4664 + 4665 + // domainEntry returns the lowercased "@domain.tld" form of from for use as a 4666 + // screener list entry, or an empty string if the address has no '@' part. 4667 + func domainEntry(from string) string { 4668 + addr := strings.ToLower(extractEmailAddr(from)) 4669 + if i := strings.IndexByte(addr, '@'); i >= 0 { 4670 + return addr[i:] 4671 + } 4672 + return "" 4507 4673 } 4508 4674 4509 4675 // extractName extracts the name part from "Name <email@example.com>" format.